From a0e6a1730705221dc7e162187478fe739e52161b Mon Sep 17 00:00:00 2001 From: Proton <72932384+ProtonDev-sys@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:15:17 +0100 Subject: [PATCH 1/2] Recover live sampling and validate integrated native performance and network attribution --- .github/scripts/performance.py | 195 +++++++++++++++++++++ .github/scripts/test_performance.py | 141 ++++++++++++++++ .github/workflows/ci.yml | 12 +- .github/workflows/performance.yml | 86 ++++++++++ CMakeLists.txt | 5 +- CONTRIBUTING.md | 8 + README.md | 9 +- native/app.hpp | 24 ++- native/commands.cpp | 75 ++++++++- native/core.hpp | 16 ++ native/csv.hpp | 36 ++++ native/diagnostics.cpp | 13 ++ native/main.cpp | 251 ++++++++++++++++++++++++---- native/performance.cpp | 22 ++- native/trace.cpp | 153 ++++++++++++++++- native/verify.cpp | 173 ++++++++++++++++++- tests/csv.cpp | 28 ++++ tests/test_tooling.py | 133 +++++++++++++++ tools/production.py | 4 +- tools/taskmgr.py | 127 +++++++++++++- 20 files changed, 1449 insertions(+), 62 deletions(-) create mode 100644 .github/scripts/performance.py create mode 100644 .github/scripts/test_performance.py create mode 100644 .github/workflows/performance.yml create mode 100644 native/csv.hpp create mode 100644 tests/csv.cpp diff --git a/.github/scripts/performance.py b/.github/scripts/performance.py new file mode 100644 index 0000000..43dc7f7 --- /dev/null +++ b/.github/scripts/performance.py @@ -0,0 +1,195 @@ +"""Validate performance-study source identities and summarize retained evidence.""" + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools")) +import taskmgr + + +KEY_METRICS = ( + ("sampler", "cpuMillisecondsPerSample"), ("sampler", "sampler.meanMilliseconds"), + ("sampler", "privateBytes"), ("sampler", "minimumProcesses"), ("sampler", "maximumProcesses"), + ("idle", "processCpuPercentOneCore"), ("idle", "uiUpdate.meanMilliseconds"), + ("idle", "messageQueue.p95Milliseconds"), ("idle", "privateBytes"), + ("components", "navigation20000.meanMilliseconds"), ("components", "sort20000.meanMilliseconds"), + ("components", "graphPaint1000x600.meanMilliseconds"), +) + + +def exact_sha(value): + if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value): + raise ValueError("Baseline and candidate must be full 40-character hexadecimal commit SHAs.") + return value.lower() + + +def validate_baseline(): + sha = exact_sha(os.environ.get("TASKMGR_BASELINE_SHA")) + output = os.environ.get("GITHUB_OUTPUT") + if output: + with Path(output).open("a", encoding="utf-8") as stream: + stream.write(f"sha={sha}\n") + print(f"Validated immutable baseline {sha}.") + + +def git_sha(directory): + result = subprocess.run(["git", "-C", str(Path(directory).resolve()), "rev-parse", "HEAD"], + capture_output=True, text=True, check=True) + return exact_sha(result.stdout.strip()) + + +def record(arguments): + directory = taskmgr.clear_outputs(arguments.output_dir, ["sources.json", "summary.md"]) + taskmgr.clear_outputs(directory / "reports", ["comparison.json"]) + taskmgr.clear_outputs(directory / "screenshots", ["screenshots.json"]) + taskmgr.clear_outputs(directory / "network-smoke", ["network-smoke.json", "result.json"]) + baseline, candidate = git_sha(arguments.baseline_source), git_sha(ROOT) + if baseline != exact_sha(os.environ.get("TASKMGR_BASELINE_SHA")): + raise ValueError("Baseline checkout differs from the validated commit.") + if candidate != exact_sha(os.environ.get("GITHUB_SHA")): + raise ValueError("Candidate checkout differs from the workflow commit.") + taskmgr.write_json(directory / "sources.json", { + "baselineSha": baseline, "candidateSha": candidate, + "candidateKind": "pull-request merge" if os.environ.get("GITHUB_EVENT_NAME") == "pull_request" else "selected commit", + "runId": os.environ.get("GITHUB_RUN_ID"), "runAttempt": os.environ.get("GITHUB_RUN_ATTEMPT"), + }) + + +def load_json(path): + return json.loads(Path(path).read_text(encoding="utf-8-sig"), object_pairs_hook=taskmgr.unique_object, + parse_constant=taskmgr.reject_constant, parse_float=taskmgr.finite_float) + + +def measurement(value): + value = taskmgr.number(value, "summary") + return f"{value:,}" if type(value) is int else f"{value:,.6g}" + + +def network_summary(directory): + directory = Path(directory) / "network-smoke" + heading = "\n## Live network attribution\n\n" + if not (directory / "result.json").is_file(): + return heading + "The network smoke test did not run or did not produce a validated result. Live attribution remains unverified.\n" + result = load_json(directory / "result.json") + if type(result) is not dict or result.get("status") not in ("verified", "skipped", "failed"): + raise ValueError("Malformed network smoke outcome.") + if result["status"] == "failed": + return heading + "**Failed.** Live attribution was not verified. Inspect the failed step and retained native report and result before retrying.\n" + report = load_json(directory / "network-smoke.json") + status = taskmgr.validate_network_smoke(report, result.get("exitCode")) + if result["status"] != status: + raise ValueError("Network smoke outcome differs from the native evidence.") + if status == "skipped": + return heading + (f"**Explicit environment skip:** `{report['reason']}` (trace status {report['traceStatus']}). " + "The limitation occurred before sending traffic. Live process network attribution remains unverified; " + "this is not a successful traffic test.\n") + return heading + (f"**Verified owned traffic:** {report['receivedDatagrams']} UDP loopback datagrams and " + f"{report['receivedBytes']:,} bytes were received with matching packet contents. " + f"The trace attributed {report['ownUdpEvents']} UDP events to this process across " + f"{report['samples']} samples, with a peak of {measurement(report['peakBytesPerSecond'])} bytes/s. " + "This diagnostic ran after the timing comparisons and screenshots.\n") + + +def render_summary(directory): + directory = Path(directory) + heading = "# Paired Task Manager performance study\n\n" + if not (directory / "reports/comparison.json").is_file(): + return heading + ("The comparison did not complete. No successful performance conclusion is available. " + "Inspect the failed job step and retained raw reports before retrying.\n") + network_summary(directory) + sources = load_json(directory / "sources.json") + report = load_json(directory / "reports/comparison.json") + if type(sources) is not dict or type(report) is not dict: + raise ValueError("Performance evidence must contain JSON objects.") + baseline, candidate = exact_sha(sources.get("baselineSha")), exact_sha(sources.get("candidateSha")) + lines = [heading, f"Baseline: `{baseline}` \nCandidate: `{candidate}`\n\n", + "Both optimized builds ran serially on the same Windows runner. " + "Three repetitions alternate baseline/candidate order after warmup. " + "Ranges below are the observed minimum and maximum across repetitions.\n\n", + "| Scenario | Metric | Baseline median [min, max] | Candidate median [min, max] | Change |\n", + "| --- | --- | ---: | ---: | ---: |\n"] + if type(report.get("repetitions")) is not int or report["repetitions"] != 3 or type(report.get("summary")) is not list: + raise ValueError("The CI study requires three complete repetitions and a metric summary.") + rows = {} + for row in report["summary"]: + if type(row) is not dict or not isinstance(row.get("scenario"), str) or not isinstance(row.get("metric"), str): + raise ValueError("Malformed performance summary row.") + key = row["scenario"], row["metric"] + if key in rows: + raise ValueError("Duplicate performance summary metric.") + rows[key] = row + for scenario, metric in KEY_METRICS: + if (scenario, metric) not in rows: + raise ValueError(f"Missing required summary metric: {scenario}/{metric}") + row = rows[scenario, metric] + before, after = taskmgr.number(row.get("baselineMedian"), metric), taskmgr.number(row.get("candidateMedian"), metric) + change = f"{(after - before) * 100 / before:+.2f}%" if before else "n/a (zero baseline)" + lines.append(f"| {scenario} | `{metric}` | {measurement(before)} " + f"[{measurement(row.get('baselineMin'))}, {measurement(row.get('baselineMax'))}] | " + f"{measurement(after)} [{measurement(row.get('candidateMin'))}, " + f"{measurement(row.get('candidateMax'))}] | {change} |\n") + production = report.get("productionExecutables") + if type(production) is not list or len(production) != 2: + raise ValueError("Expected both production executable identities.") + variants = {item.get("variant"): item for item in production if type(item) is dict} + if set(variants) != {"baseline", "candidate"}: + raise ValueError("Production identities must include baseline and candidate.") + lines.append("\n## Production executable sizes\n\n| Build | Bytes | SHA-256 |\n| --- | ---: | --- |\n") + for variant in ("baseline", "candidate"): + binary = variants[variant] + sha = binary.get("sha256") + if not isinstance(sha, str) or not re.fullmatch(r"[0-9a-f]{64}", sha): + raise ValueError("Production identity requires a SHA-256 digest.") + lines.append(f"| {variant} | {measurement(binary.get('bytes'))} | `{sha}` |\n") + images = directory / "screenshots/screenshots.json" + lines.append("\n## Interpretation and visual review\n\n" + "CPU percent uses one logical core as 100%. Process-count rows describe the workload, " + "not an improvement target. Private bytes measure committed memory and differ from resident working set. " + "Hosted-runner load and process population introduce noise; overlapping ranges and small median shifts " + "are not evidence of a reliable speedup. The artifact retains every report and executable identity.\n\n") + lines.append("Light and dark native screenshot captures are included for visual review; PNG header and completeness " + "checks do not replace reviewing the actual images.\n" if images.is_file() else + "Screenshot capture did not complete; visual review remains outstanding.\n") + lines.append(network_summary(directory)) + return "".join(lines) + + +def summary(arguments): + directory = Path(arguments.output_dir) + directory.mkdir(parents=True, exist_ok=True) + content = render_summary(directory) + (directory / "summary.md").write_text(content, encoding="utf-8") + output = os.environ.get("GITHUB_STEP_SUMMARY") + if output: + with Path(output).open("a", encoding="utf-8") as stream: + stream.write(content) + print(content) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("validate-baseline").set_defaults(function=lambda arguments: validate_baseline()) + recording = commands.add_parser("record") + recording.add_argument("--baseline-source", type=Path, required=True) + recording.add_argument("--output-dir", type=Path, required=True) + recording.set_defaults(function=record) + reporting = commands.add_parser("summary") + reporting.add_argument("--output-dir", type=Path, required=True) + reporting.set_defaults(function=summary) + arguments = parser.parse_args(argv) + try: + arguments.function(arguments) + return 0 + except (OSError, ValueError, subprocess.SubprocessError) as error: + print(f"Performance study failed: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/test_performance.py b/.github/scripts/test_performance.py new file mode 100644 index 0000000..3077748 --- /dev/null +++ b/.github/scripts/test_performance.py @@ -0,0 +1,141 @@ +"""Performance workflow tests use only owned files and fake Git output.""" + +import argparse +import contextlib +import io +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import performance + + +BASELINE = "a" * 40 +CANDIDATE = "b" * 40 + + +class PerformanceWorkflowTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="Task Manager Performance ") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + + def test_only_exact_sha_references_can_reach_checkout_output(self): + output = self.root / "github-output" + output.write_text("", encoding="utf-8") + with patch.dict(os.environ, GITHUB_OUTPUT=str(output), TASKMGR_BASELINE_SHA="A" * 40), \ + contextlib.redirect_stdout(io.StringIO()): + performance.validate_baseline() + self.assertEqual(output.read_text(encoding="utf-8"), f"sha={BASELINE}\n") + for value in (None, "main", "refs/heads/main", "a" * 39, "z" * 40, BASELINE + "\nvalue=bad", "$(command)"): + with self.subTest(value=value), self.assertRaises(ValueError): + performance.exact_sha(value) + + def test_source_record_checks_both_checkout_identities_and_invalidates_old_success(self): + arguments = argparse.Namespace(baseline_source=self.root / "baseline", output_dir=self.root) + for path in (self.root / "reports/comparison.json", self.root / "screenshots/screenshots.json", + self.root / "network-smoke/network-smoke.json", self.root / "network-smoke/result.json"): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('{"stale":true}', encoding="utf-8") + with patch.dict(os.environ, TASKMGR_BASELINE_SHA=BASELINE, GITHUB_SHA=CANDIDATE, GITHUB_EVENT_NAME="pull_request"), \ + patch.object(performance, "git_sha", side_effect=[BASELINE, CANDIDATE]): + performance.record(arguments) + sources = json.loads((self.root / "sources.json").read_text(encoding="utf-8")) + self.assertEqual(sources["baselineSha"], BASELINE) + self.assertEqual(sources["candidateSha"], CANDIDATE) + self.assertEqual(sources["candidateKind"], "pull-request merge") + self.assertFalse((self.root / "reports/comparison.json").exists()) + self.assertFalse((self.root / "screenshots/screenshots.json").exists()) + self.assertFalse((self.root / "network-smoke/result.json").exists()) + self.assertFalse((self.root / "network-smoke/network-smoke.json").exists()) + with patch.dict(os.environ, TASKMGR_BASELINE_SHA=BASELINE, GITHUB_SHA=CANDIDATE), \ + patch.object(performance, "git_sha", side_effect=[CANDIDATE, CANDIDATE]): + with self.assertRaisesRegex(ValueError, "Baseline checkout differs"): + performance.record(arguments) + self.assertFalse((self.root / "sources.json").exists()) + + def write_evidence(self): + (self.root / "reports").mkdir(exist_ok=True) + (self.root / "sources.json").write_text(json.dumps({"baselineSha": BASELINE, "candidateSha": CANDIDATE}), encoding="utf-8") + report = {"repetitions": 3, "summary": [ + {"scenario": scenario, "metric": metric, "baselineMedian": 10, "candidateMedian": 8, + "baselineMin": 5, "baselineMax": 15, "candidateMin": 4, "candidateMax": 12} + for scenario, metric in performance.KEY_METRICS], + "productionExecutables": [ + {"variant": variant, "bytes": 1234567, "sha256": "a" * 64} + for variant in ("baseline", "candidate")]} + path = self.root / "reports/comparison.json" + path.write_text(json.dumps(report), encoding="utf-8") + return report, path + + def test_summary_includes_exact_sources_ranges_sizes_and_measurement_limits(self): + self.write_evidence() + result = performance.render_summary(self.root) + self.assertIn(BASELINE, result) + self.assertIn(CANDIDATE, result) + self.assertIn("10 [5, 15]", result) + self.assertIn("8 [4, 12]", result) + self.assertIn("-20.00%", result) + self.assertIn("1,234,567", result) + self.assertIn("one logical core as 100%", result) + self.assertIn("Screenshot capture did not complete", result) + + def test_incomplete_comparison_never_claims_a_performance_result(self): + result = performance.render_summary(self.root) + self.assertIn("comparison did not complete", result) + self.assertNotIn("| Scenario", result) + + def test_missing_duplicate_and_nonfinite_metrics_are_rejected(self): + for defect in ("missing", "duplicate", "nonfinite"): + with self.subTest(defect=defect): + report, path = self.write_evidence() + if defect == "missing": + report["summary"].pop() + elif defect == "duplicate": + report["summary"].append(report["summary"][0]) + else: + report["summary"][0]["baselineMedian"] = float("nan") + path.write_text(json.dumps(report), encoding="utf-8") + with self.assertRaises(ValueError): + performance.render_summary(self.root) + + def test_network_summary_distinguishes_verified_skipped_failed_and_absent(self): + self.assertIn("remains unverified", performance.network_summary(self.root)) + directory = self.root / "network-smoke" + directory.mkdir() + report = {"passed": True, "skipped": False, "reason": "", "traceStatus": 0, "socketStatus": 0, + "sentBytes": 32768, "receivedBytes": 32768, "sentDatagrams": 32, "receivedDatagrams": 32, + "samples": 2, "peakBytesPerSecond": 16000, "deliveredEvents": 100, "decodedEvents": 80, + "ownUdpEvents": 64, "elapsedMilliseconds": 10} + (directory / "network-smoke.json").write_text(json.dumps(report), encoding="utf-8") + (directory / "result.json").write_text('{"status":"verified","exitCode":0}', encoding="utf-8") + verified = performance.network_summary(self.root) + self.assertIn("Verified owned traffic", verified) + self.assertIn("32,768 bytes", verified) + self.assertIn("64 UDP events", verified) + for name in ("sentBytes", "receivedBytes", "sentDatagrams", "receivedDatagrams", "samples", + "peakBytesPerSecond", "deliveredEvents", "decodedEvents", "ownUdpEvents"): + report[name] = 0 + report.update(passed=False, skipped=True, reason="trace_permission", traceStatus=5) + (directory / "network-smoke.json").write_text(json.dumps(report), encoding="utf-8") + (directory / "result.json").write_text('{"status":"skipped","exitCode":77}', encoding="utf-8") + skipped = performance.network_summary(self.root) + self.assertIn("Explicit environment skip", skipped) + self.assertIn("not a successful traffic test", skipped) + (directory / "result.json").write_text('{"status":"failed","exitCode":1}', encoding="utf-8") + self.assertIn("**Failed.**", performance.network_summary(self.root)) + + def test_network_summary_revalidates_native_evidence_before_claiming_success(self): + directory = self.root / "network-smoke" + directory.mkdir() + (directory / "result.json").write_text('{"status":"verified","exitCode":0}', encoding="utf-8") + (directory / "network-smoke.json").write_text('{"passed":true}', encoding="utf-8") + with self.assertRaises(ValueError): + performance.network_summary(self.root) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2caa5f..cdd1188 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,8 +21,16 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Verify portable tooling and owned-process cleanup run: python -m unittest discover -s tests -p 'test_*.py' -v - - name: Verify release publication guards - run: python -m unittest discover -s .github/scripts -p test_release.py -v + - name: Compile and run portable search and CSV regressions + if: runner.os == 'Linux' + run: | + mkdir -p artifacts/portable + g++ -std=c++20 -O2 -Wall -Wextra -Werror -pedantic tests/search_query.cpp -o artifacts/portable/search-query-tests + artifacts/portable/search-query-tests + g++ -std=c++20 -O2 -Wall -Wextra -Werror -pedantic tests/csv.cpp -o artifacts/portable/csv-tests + artifacts/portable/csv-tests + - name: Verify release and performance workflow guards + run: python -m unittest discover -s .github/scripts -p 'test_*.py' -v verify: needs: tooling runs-on: windows-latest diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml new file mode 100644 index 0000000..275c3e3 --- /dev/null +++ b/.github/workflows/performance.yml @@ -0,0 +1,86 @@ +name: Paired performance study +on: + workflow_dispatch: + inputs: + baseline_sha: + description: Exact 40-character baseline commit SHA + type: string + required: true + default: 062797ecd367c638b56d25ca061d287a3d8d5354 + pull_request: + types: [opened, reopened, synchronize, labeled] +permissions: + contents: read +defaults: + run: + shell: bash +concurrency: + group: task-manager-performance-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + study: + if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'performance-study') + runs-on: windows-latest + timeout-minutes: 30 + env: + TASKMGR_BASELINE_SHA: ${{ inputs.baseline_sha || '062797ecd367c638b56d25ca061d287a3d8d5354' }} + steps: + - name: Check out the exact candidate tested by this workflow + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Require an immutable baseline reference + id: baseline + run: python .github/scripts/performance.py validate-baseline + - name: Check out the validated baseline + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ steps.baseline.outputs.sha }} + path: artifacts/baseline-source + persist-credentials: false + - name: Record the exact source identities + run: python .github/scripts/performance.py record --baseline-source artifacts/baseline-source --output-dir artifacts/performance-study + - name: Build the baseline with production and diagnostic configurations + run: | + cmake -S artifacts/baseline-source -B artifacts/performance-builds/baseline-production -A x64 -DTASKMGR_DIAGNOSTICS=OFF + cmake --build artifacts/performance-builds/baseline-production --config Release --parallel 4 + cmake -S artifacts/baseline-source -B artifacts/performance-builds/baseline-diagnostics -A x64 -DTASKMGR_DIAGNOSTICS=ON + cmake --build artifacts/performance-builds/baseline-diagnostics --config Release --parallel 4 + - name: Build the candidate with the same runner and compiler + run: | + cmake -S . -B artifacts/native-build -A x64 -DTASKMGR_DIAGNOSTICS=OFF + cmake --build artifacts/native-build --config Release --parallel 4 + cmake -S . -B artifacts/native-tests -A x64 -DTASKMGR_DIAGNOSTICS=ON + cmake --build artifacts/native-tests --config Release --parallel 4 + - name: Validate both native baselines and production exclusions + run: | + ctest --test-dir artifacts/performance-builds/baseline-diagnostics -C Release -R '^native-self-test$' --no-tests=error --output-on-failure + ctest --test-dir artifacts/native-tests -C Release -R '^native-self-test$' --no-tests=error --output-on-failure + python tools/taskmgr.py check-production --executable artifacts/performance-builds/baseline-production/Release/TaskManager.exe + python tools/taskmgr.py check-production --executable artifacts/native-build/Release/TaskManager.exe + - name: Measure three alternating pairs serially + run: >- + python tools/taskmgr.py compare + --baseline-executable artifacts/performance-builds/baseline-diagnostics/Release/TaskManager.exe + --baseline-production-executable artifacts/performance-builds/baseline-production/Release/TaskManager.exe + --production-executable artifacts/native-build/Release/TaskManager.exe + --output-dir artifacts/performance-study/reports + --repetitions 3 --samples 500 --seconds 15 --warmup-seconds 5 + - name: Capture native light and dark views after measurement + run: python tools/taskmgr.py screenshots --output-dir artifacts/performance-study/screenshots + - name: Verify owned UDP traffic attribution after measurements and screenshots + run: python tools/taskmgr.py network-smoke --output-dir artifacts/performance-study/network-smoke + - name: Summarize medians, ranges and verification limits + if: always() + run: python .github/scripts/performance.py summary --output-dir artifacts/performance-study + - name: Retain raw comparison evidence and native screenshots + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: paired-performance-${{ github.run_id }}-${{ github.run_attempt }} + path: | + artifacts/performance-study/ + artifacts/performance-builds/baseline-diagnostics/self-test.json + artifacts/native-tests/self-test.json + if-no-files-found: warn diff --git a/CMakeLists.txt b/CMakeLists.txt index 98d408b..54f1f6e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,10 +42,13 @@ endif() enable_testing() if(TASKMGR_DIAGNOSTICS) add_executable(SearchQueryTests tests/search_query.cpp) + add_executable(CsvTests tests/csv.cpp) if(MSVC) target_compile_options(SearchQueryTests PRIVATE /W4 /WX /permissive- /EHsc /utf-8) + target_compile_options(CsvTests PRIVATE /W4 /WX /permissive- /EHsc /utf-8) endif() add_test(NAME native-search-query-test COMMAND SearchQueryTests) + add_test(NAME native-csv-test COMMAND CsvTests) add_test(NAME native-self-test COMMAND TaskManager --self-test --output ${CMAKE_BINARY_DIR}/self-test.json) add_test(NAME native-sampler-test COMMAND TaskManager --benchmark --samples 50 --interval 0 --output ${CMAKE_BINARY_DIR}/sampler-test.json) add_test(NAME native-startup-inventory-test COMMAND TaskManager --startup-inventory --output ${CMAKE_BINARY_DIR}/startup-inventory-test.json) @@ -56,5 +59,5 @@ if(TASKMGR_DIAGNOSTICS) add_test(NAME native-dark-sort-stress COMMAND TaskManager --ui-benchmark --dark --idle --sort-spam --tab 5 --seconds 5 --output ${CMAKE_BINARY_DIR}/dark-sort-test.json) add_test(NAME native-tab-stress COMMAND TaskManager --ui-benchmark --idle --tab-spam --seconds 5 --output ${CMAKE_BINARY_DIR}/tab-test.json) add_test(NAME native-dark-tab-stress COMMAND TaskManager --ui-benchmark --dark --idle --tab-spam --seconds 5 --output ${CMAKE_BINARY_DIR}/dark-tab-test.json) - set_tests_properties(native-search-query-test native-self-test native-sampler-test native-startup-inventory-test native-ui-test native-process-sort-stress native-details-sort-stress native-startup-sort-stress native-dark-sort-stress native-tab-stress native-dark-tab-stress PROPERTIES TIMEOUT 90) + set_tests_properties(native-search-query-test native-csv-test native-self-test native-sampler-test native-startup-inventory-test native-ui-test native-process-sort-stress native-details-sort-stress native-startup-sort-stress native-dark-sort-stress native-tab-stress native-dark-tab-stress PROPERTIES TIMEOUT 90) endif() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6efea82..40f9139 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,14 @@ Both developer executables must include the instrumented headless sampler benchm For a short paired CI investigation, use `--repetitions 3 --samples 500 --seconds 15 --warmup-seconds 5`. Preserve both executables and run them serially on the same runner. The report includes executable SHA-256 hashes, sizes, operating system and logical processor count. `--output-dir` selects a report directory; the command removes only its explicitly named report files there. +The optional **Paired performance study** workflow runs when a pull request has the `performance-study` label, or when manually dispatched. Its default baseline is the exact commit `062797ecd367c638b56d25ca061d287a3d8d5354`; manual runs accept another full commit SHA. Pull-request studies measure the workflow's merge commit, and the artifact records both exact source commits. The workflow builds baseline and candidate production/developer executables on the same Windows runner, checks both self-tests and production exclusions, then runs three serial alternating sampler, fixed-tab idle UI and component pairs. It retains every raw report, executable hash, production size and median/min/max summary. Ordinary pull requests continue to run the full verification suite without this extra study. + +After timing finishes, the study captures the actual native views in light and dark themes for visual review. Run `python tools/taskmgr.py screenshots` locally to reproduce those captures from an optimized developer build. The command verifies that all 19 views per theme have fresh PNG headers and valid dimensions, and records their hashes in `artifacts/screenshots/screenshots.json`. Inspect the images themselves before claiming visual correctness; automated capture does not establish High Contrast, multiple-DPI or physical-monitor coverage. + +After measurements and screenshots, the study runs `python tools/taskmgr.py network-smoke`. This diagnostic sends 32 known UDP datagrams to an owned socket on `127.0.0.1`, verifies their contents and byte counts, and requires positive trace attribution to its own process. It uses no external endpoint. The Python command bounds the native process to 30 seconds by default (`--timeout` accepts 5–120 seconds), clears earlier output, and retains the native report plus a `verified`, `skipped`, or `failed` result in `artifacts/network-smoke/` (or `--output-dir`). + +Only an explicit pre-traffic trace limitation can be skipped: native exit 77 with `trace_permission` and status 5 or 1314, or `trace_session_unavailable` and status 50 or 1450. Skips must report zero sent/received traffic, samples and own UDP events. The Python command accepts these documented environment skips without failing CI, but the summary states that live attribution remains unverified. Unknown skip reasons, mismatched statuses, missing events, malformed JSON and incomplete transfers fail. The smoke command and its diagnostic markers are excluded from production builds. + Headless sampler reports include warmup, initialization time, process-count range, stage timings, CPU milliseconds per sample, private committed bytes and resident working-set bytes. CPU percent uses **one logical core** as 100%; divide by the machine's logical processor count to compare with whole-machine CPU percentages. Private bytes and working set are different measurements. Repeated ranges matter: process population, drivers, background load and heap retention introduce noise. A lower minimum or a single fast run is not proof of an improvement. See [performance investigation](docs/PERFORMANCE.md) for the optimization rationale and measured limits. diff --git a/README.md b/README.md index bde28f0..b71b440 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,10 @@ If you need to restore Windows Task Manager outside the UI, run this from an adm - **Performance:** CPU and per-logical-processor graphs, memory usage, disk activity, network throughput and GPU engines. Available hardware details depend on Windows and the device's drivers. - **App history:** locally recorded CPU time, with a reset option. - **Startup:** Run entries, startup folders, boot/logon scheduled tasks, automatic services and drivers, and packaged startup declarations. Supported Run and folder entries can be enabled or disabled; other sources link to Windows management tools. -- **Users, Details and Services:** session activity, process metadata and controls, service status and start/stop/restart actions. Windows protection and permissions still apply. +- **Users, Details and Services:** session activity, process metadata and controls, service status and start/stop/restart actions. Failed service refreshes retain the last known rows and show an unavailable or outdated status with the Windows error code; F5 retries immediately. Windows protection and permissions still apply. - **Appearance:** Light, Dark and Use Windows setting; High Contrast follows system colours. Columns and sorting are saved separately for each tab. - **Search:** filter table rows by text or combine exact PID, name, publisher, command line, path, account and status fields. Quoted values and field exclusions are supported. Matching groups retain their context. Search does not request a fresh system sample. +- **Export:** save the currently filtered and sorted table to a UTF-8 CSV file with **File > Export current table** or **Ctrl+Shift+S**. The export includes the displayed columns in their current order, with complete cell text. Right-click a column heading to choose visible columns. **Ctrl+F** opens search; **Enter** applies it immediately and focuses the results; **Esc** clears it. **F5** refreshes. **Ctrl+Tab** cycles tabs, and **Alt+1** through **Alt+7** selects one directly. Double-click a Performance graph or its sidebar to enter a summary view. @@ -65,6 +66,12 @@ Plain text remains a case-insensitive substring, including spaces: `Orbit Browse Fields are `name`, `pid`, `publisher`, `command`, `path`, `user` and `status`. They use the current snapshot and available metadata; an unavailable field cannot satisfy a positive term. Startup, service and history entries use the corresponding fields where available. User rows can also match the session account and status. For process groups, one member must satisfy the whole query. The group and its visible children remain together, so some context rows may not match individually. Group headings retain their unfiltered totals. Performance resources are not filtered. +### Exporting a table + +Export captures the rows and displayed values when you invoke it, including expanded group rows. Category headings and hidden columns are omitted. Refreshes while the Save dialog is open do not change that snapshot. Saving runs in the background; the existing destination is replaced only after the new file has been completely written. + +CSV files use UTF-8 with an encoding marker, quoted fields, and CRLF record endings. Quotes and embedded newlines are preserved. Formula-like cell text is prefixed with an apostrophe for spreadsheet import; embedded NUL characters are replaced with the Unicode replacement character. These are display-value exports rather than a raw numeric data feed. Export is available on table tabs with results in the full view. + ## Differences from Windows Task Manager This is an independent implementation, not a complete copy of Microsoft's Task Manager. diff --git a/native/app.hpp b/native/app.hpp index a511135..0207e37 100644 --- a/native/app.hpp +++ b/native/app.hpp @@ -22,7 +22,8 @@ inline bool sameRowPaint(const Row& previous, const Row& next) { struct ColumnDef { const wchar_t* title; int width; bool right, visible, heat; bool numeric = false; }; struct PerfItem { std::wstring key, title, subtitle, value, description; COLORREF color; }; struct ActionResult { std::wstring error, report; }; -constexpr int FewerId = 15, EndId = 14, LinkId = 18, HistoryLinkId = 19, ListId = 12, TabsId = 11, PerformanceId = 13; +constexpr UINT SamplingStatusMessage = WM_APP + 13; +constexpr int FewerId = 15, EndId = 14, LinkId = 18, HistoryLinkId = 19, ServiceStatusId = 20, ListId = 12, TabsId = 11, PerformanceId = 13; constexpr int StartupManageId = 241; constexpr int RunId = 100, ExitId = 101, RefreshId = 102, TopmostId = 103, MinimizeOnUseId = 104, HideId = 105, GroupId = 106, ExpandAllId = 107, CollapseAllId = 108; constexpr int SpeedHigh = 110, SpeedNormal = 111, SpeedLow = 112, SpeedPause = 113, DefaultTabId = 120, FullNameId = 130, AllHistoryId = 131, ColumnId = 300; @@ -30,6 +31,7 @@ constexpr int SpeedAdaptive = 114; constexpr int ReplaceDefaultId = 132; constexpr int ThemeLightId = 133, ThemeDarkId = 134, ThemeSystemId = 135, SearchId = 136; constexpr int EfficiencyId = 137; +constexpr int ExportId = 138; enum Command { EndTask = 200, EndTree, RestartExplorer, SwitchTo, BringToFront, MinimizeWindow, MaximizeWindow, Toggle, GoToDetails, GoToServices, OpenLocation, SearchOnline, Properties, CreateDump, WaitChainId, AffinityId, CopyId, PriorityRealtime = 220, PriorityHigh, PriorityAbove, PriorityNormal, PriorityBelow, PriorityLow, ServiceStart = 230, ServiceStop, ServiceRestart, OpenServices, StartupToggle = 240, Disconnect = 250, SignOff, ManageAccounts, MemoryValues = 260, MemoryPercents, NetworkValues, NetworkPercents, DeleteHistory, SummaryView = 400, OverallView, LogicalView, CopyPerformance, HideGraphs, SidebarSummary, ViewResource = 410, TrayRestore = 500 }; @@ -47,7 +49,7 @@ class Application { #ifdef TASKMGR_DIAGNOSTICS RunOptions options; #endif - HWND window = nullptr, tabs = nullptr, list = nullptr, header = nullptr, performance = nullptr, end = nullptr, fewer = nullptr, link = nullptr, historyLink = nullptr; + HWND window = nullptr, tabs = nullptr, list = nullptr, header = nullptr, performance = nullptr, end = nullptr, fewer = nullptr, link = nullptr, historyLink = nullptr, serviceStatus = nullptr; HMENU menuBar = nullptr; HFONT font = nullptr, percentFont = nullptr, headingFont = nullptr, titleFont = nullptr, subtitleFont = nullptr, labelFont = nullptr, valueFont = nullptr, sidebarFont = nullptr, linkFont = nullptr; HIMAGELIST rowSpacer = nullptr; @@ -95,7 +97,7 @@ class Application { std::atomic interval{250}; std::atomic displayCost{0}; std::atomic fastVisible{true}; - std::atomic minimized{false}, stopping{false}, notification{false}, startupVisible{false}; + std::atomic minimized{false}, stopping{false}, notification{false}, startupVisible{false}, samplingFailed{false}; std::mutex workerMutex; std::condition_variable wake; bool force = false, forceInventory = false; @@ -113,6 +115,9 @@ class Application { std::thread inputProbe; bool measured = false, keyboardPassed = false, sortingPassed = false, sortingChecked = false, verifyingSorting = false, benchmarkRunning = false; Time measurementStarted{}; + std::atomic samplingFailures{0}; + uint64_t samplingFailureBaseline = 0; + bool samplingHealthyAtStart = false; LONGLONG measurementCounter = 0; DWORD baselineHandles = 0, baselineGdi = 0, baselineUser = 0; std::vector deliveryTimes, updateTimes, paintTimes, samplerTimes, inputTimes, sortTimes; @@ -128,6 +133,9 @@ class Application { unsigned performancePaints = 0, performanceDetailPaints = 0; unsigned headerPaints = 0, columnGeometryCommits = 0, scrollbarPaints = 0; std::function trayNotification; + // Owned worker fixtures bypass system inventories without affecting release builds. + std::function samplingOverride; + std::function samplingStatusNotification; unsigned tabRequests = 0, tabCommits = 0; FILETIME cpuStartKernel{}, cpuStartUser{}; #else @@ -237,14 +245,22 @@ class Application { bool confirm(const std::wstring& title, const std::wstring& text, const std::wstring& button); void endSelected(bool tree); void runTask(); + bool canExportTable() const; + std::string tableCsv() const; + void exportTable(); + static void writeExportFile(const std::wstring& path, std::string_view contents); HWND appWindow(const Process& process) const; // diagnostics #ifdef TASKMGR_DIAGNOSTICS void benchmarkTick(); bool verifyHistoryPersistence(); bool verifyTrayLifecycle(); + bool verifyWorkerRecovery(); + bool verifyBenchmarkSamplingHealth(); + bool verifyServiceStatus(); bool verifyViewportRefresh(); bool verifySearch(); + bool verifyTableExport(); bool verifySorting(); bool verifyActionSnapshots(); bool verifyHeadingNavigation(); @@ -253,6 +269,8 @@ class Application { bool verifyTabSwitching(); bool verifyHighContrast(); void writeBenchmark(); + void beginSamplingMeasurement(); + bool samplingMeasurementHealthy(); void screenshotTick(); #endif }; diff --git a/native/commands.cpp b/native/commands.cpp index af0d3c9..75fdc59 100644 --- a/native/commands.cpp +++ b/native/commands.cpp @@ -1,4 +1,5 @@ #include "app.hpp" +#include "csv.hpp" #include #include #include @@ -89,12 +90,15 @@ void Application::asynchronous(std::function operation) { if (!persistent()) return; if (actionBusy.exchange(true)) { MessageBoxW(window, L"An action is already in progress. Try again when it finishes.", L"Task Manager", MB_OK); return; } if (actionWorker.joinable()) actionWorker.join(); - actionWorker = std::thread([this, operation = std::move(operation)] { + try { actionWorker = std::thread([this, operation = std::move(operation)] { ActionResult result; const auto initialized = CoInitializeEx(nullptr, COINIT_MULTITHREADED); try { result.report = operation(); } catch (const std::exception& error) { result.error = fromUtf8(error.what()); } if (SUCCEEDED(initialized)) CoUninitialize(); { std::lock_guard lock(actionMutex); actionResults.push_back(std::move(result)); } actionBusy = false; if (!stopping) PostMessageW(window, ActionMessage, 0, 0); - }); + }); } catch (const std::exception& error) { + actionBusy = false; + MessageBoxW(window, fromUtf8(error.what()).c_str(), L"Unable to start action", MB_OK | MB_ICONERROR); + } } HWND Application::appWindow(const Process& process) const { struct Search { DWORD pid; HWND found = nullptr; } search{process.id}; @@ -113,6 +117,72 @@ void Application::runTask() { SHELLEXECUTEINFOW execute{sizeof(execute)}; execute.fMask = SEE_MASK_FLAG_NO_UI; execute.hwnd = window; execute.lpVerb = input.elevated ? L"runas" : nullptr; execute.lpFile = launch.file.c_str(); execute.lpParameters = launch.parameters.empty() ? nullptr : launch.parameters.c_str(); execute.nShow = SW_SHOWNORMAL; if (!ShellExecuteExW(&execute)) { const DWORD error = GetLastError(); if (error != ERROR_CANCELLED) MessageBoxW(window, (L"Windows cannot find '" + launch.file + L"'. Make sure you typed the name correctly, and then try again.\n\n" + winerror(error)).c_str(), L"Create new task", MB_OK | MB_ICONERROR); } } +bool Application::canExportTable() const { + return !compact && !summary && selectedTab >= 0 && selectedTab < TabCount && selectedTab != PerformanceTab && + std::any_of(rows.begin(), rows.end(), [](const Row& row) { return row.kind != RowKind::Heading; }); +} +std::string Application::tableCsv() const { + if (!canExportTable()) return {}; + const auto defs = definitions(selectedTab); + const int count = Header_GetItemCount(header); + if (count <= 0 || count > 32) return {}; + std::vector order(size_t(count), 0), columns; + for (int index = 0; index < count; ++index) order[size_t(index)] = index; + if (!ListView_GetColumnOrderArray(list, count, order.data())) throw std::runtime_error("Unable to read the table's column order."); + columns.reserve(size_t(count)); + Csv csv; + for (const auto position : order) { + if (position < 0 || position >= count) throw std::runtime_error("The table's column order is invalid."); + const int column = columnAt(position); + if (column < 0 || size_t(column) >= defs.size()) throw std::runtime_error("The table's columns changed while preparing the export."); + columns.push_back(column); csv.field(utf8(defs[size_t(column)].title)); + } + csv.endRow(); + for (const auto& row : rows) { + if (row.kind == RowKind::Heading) continue; + for (const auto column : columns) csv.field(utf8(cell(row, column))); + csv.endRow(); + } + return csv.take(); +} +void Application::writeExportFile(const std::wstring& path, std::string_view contents) { + GUID guid{}; wchar_t identifier[40]{}; + if (FAILED(CoCreateGuid(&guid)) || !StringFromGUID2(guid, identifier, int(std::size(identifier)))) throw std::runtime_error("Unable to create a temporary export file name."); + const auto temporary = path + L".tmp-" + identifier; + struct Cleanup { const std::wstring& path; bool owned = false; ~Cleanup() { if (owned) DeleteFileW(path.c_str()); } } cleanup{temporary}; + { + Handle file(CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr)); + if (file.value == INVALID_HANDLE_VALUE) throw std::runtime_error(utf8(winerror())); + cleanup.owned = true; + while (!contents.empty()) { + const DWORD count = DWORD(std::min(contents.size(), 1024 * 1024)); DWORD written = 0; + if (!WriteFile(file.value, contents.data(), count, &written, nullptr)) throw std::runtime_error(utf8(winerror())); + if (!written || written > count) throw std::runtime_error("The export file could not be completely written."); + contents.remove_prefix(written); + } + if (!FlushFileBuffers(file.value)) throw std::runtime_error(utf8(winerror())); + } + if (!MoveFileExW(temporary.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) throw std::runtime_error(utf8(winerror())); + cleanup.owned = false; +} +void Application::exportTable() { + if (!persistent() || actionBusy) return; + try { + if (searchQueued) applySearch(); + if (!canExportTable()) return; + // The Save dialog runs a nested message loop. Freeze visible rows, values, + // and dragged column order before it can deliver another sampler update. + auto contents = tableCsv(); if (contents.empty()) return; + static constexpr const wchar_t* names[] = {L"Processes", L"Performance", L"App-history", L"Startup", L"Users", L"Details", L"Services"}; + const auto name = std::wstring(L"TaskManager-") + names[selectedTab] + L".csv"; + std::vector path(32768); std::copy(name.begin(), name.end(), path.begin()); + OPENFILENAMEW file{sizeof(file)}; file.hwndOwner = window; file.lpstrFile = path.data(); file.nMaxFile = DWORD(path.size()); + file.lpstrFilter = L"CSV files (*.csv)\0*.csv\0All files (*.*)\0*.*\0"; file.lpstrDefExt = L"csv"; file.lpstrTitle = L"Export current table"; + file.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR; + if (!GetSaveFileNameW(&file)) { const auto error = CommDlgExtendedError(); if (error) throw std::runtime_error("The export file dialog could not be opened (" + std::to_string(error) + ")."); return; } + asynchronous([path = std::wstring(path.data()), contents = std::move(contents)] { writeExportFile(path, contents); return std::wstring{}; }); + } catch (const std::exception& error) { MessageBoxW(window, fromUtf8(error.what()).c_str(), L"Export current table", MB_OK | MB_ICONERROR); } +} void Application::endSelected(bool tree) { const auto row = selectedRow(); if (!row || !current) return; const auto targets = selectedMembers(tree); if (targets.empty()) return; @@ -140,6 +210,7 @@ void Application::command(int id) { switch (id) { case ThemeLightId: case ThemeDarkId: case ThemeSystemId: if (theme == id - ThemeLightId) return; theme = id - ThemeLightId; applyTheme(); saveSettings(); return; case SearchId: toggleSearch(); return; + case ExportId: exportTable(); return; case ReplaceDefaultId: { const bool enable = !replacementEnabled(); if (enable && !confirm(L"Replace Windows Task Manager?", L"Ctrl+Shift+Esc and Windows Task Manager launch commands will open this app for all Windows accounts. Windows will ask for administrator approval and install one protected executable in Program Files. Turn this option off to restore Windows Task Manager.", L"Replace Task Manager")) return; diff --git a/native/core.hpp b/native/core.hpp index 4cf8da8..9601e59 100644 --- a/native/core.hpp +++ b/native/core.hpp @@ -135,9 +135,15 @@ class Counter { void collect(); std::vector> values(const std::wstring& key); }; +#ifdef TASKMGR_DIAGNOSTICS +struct NetworkSmokeResult; +#endif class NetworkTrace { struct State; std::unique_ptr state; +#ifdef TASKMGR_DIAGNOSTICS + explicit NetworkTrace(bool measure); +#endif public: NetworkTrace(); ~NetworkTrace(); @@ -145,6 +151,7 @@ class NetworkTrace { bool sample(std::span processes); #ifdef TASKMGR_DIAGNOSTICS DWORD status() const; + friend NetworkSmokeResult networkTraceSmoke(); #endif }; class Sampler { @@ -210,6 +217,15 @@ std::vector> counterSamplingTests(); std::vector> processSamplingTests(); std::vector> traceSamplingTests(); std::vector> traceLifecycleTests(); +struct NetworkSmokeResult { + bool passed = false, skipped = false; + std::string reason; + DWORD traceStatus = ERROR_SUCCESS, socketStatus = ERROR_SUCCESS; + uint64_t sentBytes = 0, receivedBytes = 0, deliveredEvents = 0, decodedEvents = 0, ownUdpEvents = 0; + unsigned sentDatagrams = 0, receivedDatagrams = 0, samples = 0; + double peakBytesPerSecond = 0, elapsedMilliseconds = 0; +}; +NetworkSmokeResult networkTraceSmoke(); std::vector> iconCacheTests(); std::vector> serviceInventoryTests(); std::vector> memoryHardwareTests(); diff --git a/native/csv.hpp b/native/csv.hpp new file mode 100644 index 0000000..95ee80a --- /dev/null +++ b/native/csv.hpp @@ -0,0 +1,36 @@ +#pragma once +#include +#include +#include + +namespace taskmgr { +// Display strings are exported as text. Prefix formula-like values so opening +// process-controlled metadata in a spreadsheet does not interpret a formula. +inline bool csvNeedsTextPrefix(std::string_view value) { + if (value.empty()) return false; + if (value.front() == '\t' || value.front() == '\r' || value.front() == '\n' || value.front() == '\0') return true; + const auto start = value.find_first_not_of(" \t\r\n\v\f"); + if (start == value.npos) return false; + value.remove_prefix(start); + return value.front() == '=' || value.front() == '+' || value.front() == '-' || value.front() == '@' || + value.starts_with("\xef\xbc\x9d") || value.starts_with("\xef\xbc\x8b") || value.starts_with("\xef\xbc\x8d") || value.starts_with("\xef\xbc\xa0"); +} +class Csv { + std::string output = "\xef\xbb\xbf"; + bool first = true; +public: + void field(std::string_view value) { + if (!first) output.push_back(','); + first = false; output.push_back('"'); + if (csvNeedsTextPrefix(value)) output.push_back('\''); + for (const auto character : value) { + if (character == '"') output.push_back('"'); + if (character == '\0') output += "\xef\xbf\xbd"; + else output.push_back(character); + } + output.push_back('"'); + } + void endRow() { output += "\r\n"; first = true; } + std::string take() { return std::move(output); } +}; +} diff --git a/native/diagnostics.cpp b/native/diagnostics.cpp index c6d6403..0be0a80 100644 --- a/native/diagnostics.cpp +++ b/native/diagnostics.cpp @@ -20,6 +20,18 @@ static bool flag(const std::vector& arguments, const std::wstring& static void jsonString(Report& stream, const std::wstring& value) { stream.jsonString(utf8(value)); } +static int networkSmokeReport(const std::wstring& output) { + const auto result = networkTraceSmoke(); + Report stream{output}; + stream << "{\"native\":true,\"passed\":" << (result.passed ? "true" : "false") << ",\"skipped\":" << (result.skipped ? "true" : "false") << ",\"reason\":"; + stream.jsonString(result.reason) << ",\"traceStatus\":" << result.traceStatus << ",\"socketStatus\":" << result.socketStatus + << ",\"sentBytes\":" << result.sentBytes << ",\"receivedBytes\":" << result.receivedBytes + << ",\"sentDatagrams\":" << result.sentDatagrams << ",\"receivedDatagrams\":" << result.receivedDatagrams + << ",\"samples\":" << result.samples << ",\"peakBytesPerSecond\":" << result.peakBytesPerSecond + << ",\"elapsedMilliseconds\":" << result.elapsedMilliseconds << ",\"deliveredEvents\":" << result.deliveredEvents + << ",\"decodedEvents\":" << result.decodedEvents << ",\"ownUdpEvents\":" << result.ownUdpEvents << '}'; + return !stream ? 1 : result.skipped && !result.passed ? 77 : result.passed && !result.skipped ? 0 : 1; +} static int startupReport(const std::wstring& output) { const auto began = Clock::now(); double firstEntries = -1; size_t snapshots = 0, previousCount = 0; bool progressive = true, completed = false; const auto inventory = readStartup([&](const StartupInventory& snapshot) { ++snapshots; progressive = progressive && snapshot.entries.size() >= previousCount && !completed; previousCount = snapshot.entries.size(); completed = snapshot.complete; if (firstEntries < 0 && !snapshot.entries.empty()) firstEntries = milliseconds(began); }); @@ -449,6 +461,7 @@ int diagnostics(const std::vector& arguments) { if (flag(arguments, L"--test-child")) { Sleep(60000); return 0; } const auto output = option(arguments, L"--output", L"native-report.json"); if (flag(arguments, L"--self-test")) return selfTest(output); + if (flag(arguments, L"--network-smoke")) return networkSmokeReport(output); if (flag(arguments, L"--startup-inventory")) return startupReport(output); if (flag(arguments, L"--component-benchmark")) return components(output); if (flag(arguments, L"--ui-benchmark")) { RunOptions options; options.hidden = true; options.theme = flag(arguments, L"--dark") ? 1 : 0; options.benchmarkSeconds = std::clamp(std::stoi(option(arguments, L"--seconds", L"15")), 5, 600); options.output = output; options.interval = std::clamp(std::stoi(option(arguments, L"--interval", L"1000")), 0, 4000); options.tab = std::clamp(std::stoi(option(arguments, L"--tab", L"-1")), -1, 6); options.minimized = flag(arguments, L"--minimized"); options.idle = flag(arguments, L"--idle"); options.sortSpam = flag(arguments, L"--sort-spam"); options.tabSpam = flag(arguments, L"--tab-spam"); options.warmup = std::clamp(std::stoi(option(arguments, L"--warmup", L"2")), 0, 60); return runApplication(options); } diff --git a/native/main.cpp b/native/main.cpp index 06078bf..17de2e4 100644 --- a/native/main.cpp +++ b/native/main.cpp @@ -15,7 +15,8 @@ static const wchar_t* tabNames[] = {L"Processes", L"Performance", L"App history" Application::Application(RunOptions value) : options(std::move(value)) {} #endif Application::~Application() { - stopping = true; wake.notify_all(); if (sampler.joinable()) sampler.join(); + { std::lock_guard lock(workerMutex); stopping = true; } + wake.notify_all(); if (sampler.joinable()) sampler.join(); #ifdef TASKMGR_DIAGNOSTICS if (inputProbe.joinable()) inputProbe.join(); #endif @@ -90,6 +91,7 @@ int Application::run() { if (message.message == WM_KEYDOWN && control && message.wParam == VK_TAB && !compact && !summary) { requestTab(((pendingTab >= 0 ? pendingTab : TabCtrl_GetCurSel(tabs)) + (shift ? TabCount - 1 : 1)) % TabCount); continue; } if (message.message == WM_SYSKEYDOWN && alt && message.wParam >= '1' && message.wParam <= '7' && !compact && !summary) { requestTab(int(message.wParam - '1')); continue; } if (message.message == WM_KEYDOWN && control && shift && message.wParam == 'N') { command(RunId); continue; } + if (message.message == WM_KEYDOWN && control && shift && message.wParam == 'S') { command(ExportId); continue; } if (message.message == WM_KEYDOWN && message.wParam == VK_ESCAPE && summary) { setSummary(0); continue; } if (!IsDialogMessageW(window, &message)) { TranslateMessage(&message); DispatchMessageW(&message); } } @@ -100,7 +102,7 @@ void Application::createFonts() { for (auto object : {font, percentFont, headingFont, titleFont, subtitleFont, labelFont, valueFont, sidebarFont, linkFont}) if (object) DeleteObject(object); auto make = [&](int size, bool underline = false) { return CreateFontW(-scale(size), 0, 0, 0, FW_NORMAL, FALSE, underline, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH, L"Segoe UI"); }; font = make(12); percentFont = make(16); headingFont = make(16); titleFont = make(28); subtitleFont = make(15); labelFont = make(12); valueFont = make(24); sidebarFont = make(16); linkFont = make(12, true); - for (const auto control : {tabs, list, end, fewer, link, historyLink, searchBox}) if (control) SendMessageW(control, WM_SETFONT, WPARAM(font), TRUE); + for (const auto control : {tabs, list, end, fewer, link, historyLink, serviceStatus, searchBox}) if (control) SendMessageW(control, WM_SETFONT, WPARAM(font), TRUE); } void Application::create() { createFonts(); @@ -119,6 +121,7 @@ void Application::create() { fewer = control(L"BUTTON", L"Fewer details", BS_OWNERDRAW | WS_TABSTOP, FewerId); link = control(L"BUTTON", L"Open Resource Monitor", BS_OWNERDRAW | WS_TABSTOP, LinkId); historyLink = control(L"BUTTON", L"Delete usage history", BS_OWNERDRAW | WS_TABSTOP, HistoryLinkId); + serviceStatus = control(L"STATIC", L"", SS_LEFT | SS_NOPREFIX, ServiceStatusId); for (const auto button : {fewer, link, historyLink}) SetWindowSubclass(button, linkProcedure, 3, reinterpret_cast(this)); end = control(L"BUTTON", L"End task", BS_PUSHBUTTON | WS_TABSTOP, EndId); SetWindowSubclass(end, linkProcedure, 3, reinterpret_cast(this)); @@ -132,7 +135,7 @@ void Application::create() { icons = std::make_unique(window, scale(16)); #endif menuBar = CreateMenu(); const auto file = CreatePopupMenu(), options_ = CreatePopupMenu(), view = CreatePopupMenu(), speed = CreatePopupMenu(), defaults = CreatePopupMenu(); - AppendMenuW(file, MF_STRING, RunId, L"&Run new task"); AppendMenuW(file, MF_SEPARATOR, 0, nullptr); AppendMenuW(file, MF_STRING, ExitId, L"E&xit"); + AppendMenuW(file, MF_STRING, RunId, L"&Run new task"); AppendMenuW(file, MF_STRING, ExportId, L"&Export current table...\tCtrl+Shift+S"); AppendMenuW(file, MF_SEPARATOR, 0, nullptr); AppendMenuW(file, MF_STRING, ExitId, L"E&xit"); AppendMenuW(options_, MF_STRING, TopmostId, L"&Always on top"); AppendMenuW(options_, MF_STRING, MinimizeOnUseId, L"&Minimize on use"); AppendMenuW(options_, MF_STRING, HideId, L"&Hide when minimized"); AppendMenuW(options_, MF_STRING, ReplaceDefaultId, L"&Replace default Task Manager"); const auto themes = CreatePopupMenu(); @@ -163,7 +166,7 @@ void Application::layout() { RECT area{}; GetClientRect(window, &area); const int width = area.right, height = area.bottom; const int footer = scale(50); if (summary) { - for (const auto control : {tabs, list, end, fewer, link, historyLink, searchBox}) show(control, false); + for (const auto control : {tabs, list, end, fewer, link, historyLink, serviceStatus, searchBox}) show(control, false); show(performance, true); place(performance, 0, 0, width, height); InvalidateRect(performance, nullptr, FALSE); return; } updateFooter(); @@ -179,16 +182,26 @@ void Application::layout() { place(perf ? performance : list, 0, top, width, bottom - top); if (compact) ListView_SetColumnWidth(list, 0, std::max(scale(100), width - GetSystemMetrics(SM_CXVSCROLL) - scale(4))); HDC dc = GetDC(window); SelectObject(dc, font); SIZE text{}; auto measure = [&](HWND control) { wchar_t value[128]{}; GetWindowTextW(control, value, 128); GetTextExtentPoint32W(dc, value, int(wcslen(value)), &text); return int(text.cx); }; - const int fewerWidth = measure(fewer) + scale(34), linkWidth = measure(link) + scale(8), historyWidth = measure(historyLink) + scale(4); - std::wstring since = L"Resource usage since " + [&] { SYSTEMTIME now{}; GetLocalTime(&now); FILETIME file{}; SystemTimeToFileTime(&now, &file); ULARGE_INTEGER value{file.dwLowDateTime, file.dwHighDateTime}; value.QuadPart -= ULONGLONG(std::chrono::duration_cast(Clock::now() - historySince).count()) * 10000000ULL; file = {value.LowPart, value.HighPart}; SYSTEMTIME local{}; FileTimeToSystemTime(&file, &local); wchar_t date[64]{}; GetDateFormatEx(LOCALE_NAME_USER_DEFAULT, DATE_SHORTDATE, &local, nullptr, date, 64, nullptr); return std::wstring(date); }() + L" for current user account."; - GetTextExtentPoint32W(dc, since.c_str(), int(since.size()), &text); const int sinceWidth = text.cx; ReleaseDC(window, dc); + const int fewerWidth = measure(fewer) + scale(34), linkWidth = measure(link) + scale(8); + const bool showHistory = selectedTab == HistoryTab && !compact; + int historyWidth = 0, sinceWidth = 0; + if (showHistory) { + historyWidth = measure(historyLink) + scale(4); + const std::wstring since = L"Resource usage since " + [&] { SYSTEMTIME now{}; GetLocalTime(&now); FILETIME file{}; SystemTimeToFileTime(&now, &file); ULARGE_INTEGER value{file.dwLowDateTime, file.dwHighDateTime}; value.QuadPart -= ULONGLONG(std::chrono::duration_cast(Clock::now() - historySince).count()) * 10000000ULL; file = {value.LowPart, value.HighPart}; SYSTEMTIME local{}; FileTimeToSystemTime(&file, &local); wchar_t date[64]{}; GetDateFormatEx(LOCALE_NAME_USER_DEFAULT, DATE_SHORTDATE, &local, nullptr, date, 64, nullptr); return std::wstring(date); }() + L" for current user account."; + GetTextExtentPoint32W(dc, since.c_str(), int(since.size()), &text); sinceWidth = text.cx; + } + ReleaseDC(window, dc); const int middle = height - footer / 2; place(fewer, scale(8), middle - scale(12), fewerWidth, scale(24)); const bool showFewer = compact || selectedTab == ProcessesTab; place(link, showFewer ? scale(8) + fewerWidth + scale(12) : scale(10), middle - scale(12), linkWidth, scale(24)); + if (selectedTab == ServicesTab && !compact) { + const int left = scale(10) + linkWidth + scale(16); + place(serviceStatus, left, middle - scale(16), std::max(1, width - left - scale(10)), scale(34)); + } place(end, width - scale(10) - scale(82), middle - scale(12), scale(82), scale(23)); - if (selectedTab == HistoryTab && !compact) place(historyLink, scale(9) + sinceWidth + scale(8), tabBottom + scale(8), historyWidth, scale(20)); - show(historyLink, selectedTab == HistoryTab && !compact); + if (showHistory) place(historyLink, scale(9) + sinceWidth + scale(8), tabBottom + scale(8), historyWidth, scale(20)); + show(historyLink, showHistory); show(fewer, showFewer); InvalidateRect(window, nullptr, TRUE); if (perf) InvalidateRect(performance, nullptr, FALSE); @@ -209,6 +222,17 @@ void Application::updateFooter() { if (linkText && !compact) { wchar_t current_[64]{}; GetWindowTextW(link, current_, 64); if (wcscmp(current_, linkText)) SetWindowTextW(link, linkText); ShowWindow(link, SW_SHOWNA); } else ShowWindow(link, SW_HIDE); const auto fewerText = compact ? L"More details" : L"Fewer details"; GetWindowTextW(fewer, existing, 64); if (wcscmp(existing, fewerText)) SetWindowTextW(fewer, fewerText); + if (serviceStatus) { + std::wstring status; + if (selectedTab == ServicesTab && !compact && !summary && current && current->inventory && current->inventory->servicesError != ERROR_SUCCESS) { + status = current->services.empty() ? L"Services unavailable" : L"Services may be outdated"; + status += L" (error " + std::to_wstring(current->inventory->servicesError) + L"). Press F5 to retry."; + } + wchar_t previous[128]{}; GetWindowTextW(serviceStatus, previous, 128); + if (status != previous) SetWindowTextW(serviceStatus, status.c_str()); + const bool statusVisible = !status.empty(); + if (((GetWindowLongPtrW(serviceStatus, GWL_STYLE) & WS_VISIBLE) != 0) != statusVisible) ShowWindow(serviceStatus, statusVisible ? SW_SHOWNA : SW_HIDE); + } } void Application::paintWindow(HDC dc) { RECT area{}; GetClientRect(window, &area); @@ -297,6 +321,7 @@ void Application::updateMenu(HMENU popup) { CheckMenuRadioItem(popup, ThemeLightId, ThemeSystemId, UINT(ThemeLightId + theme), MF_BYCOMMAND); check(SearchId, searchVisible); EnableMenuItem(popup, SearchId, MF_BYCOMMAND | ((compact || summary || selectedTab == PerformanceTab) ? MF_GRAYED : MF_ENABLED)); EnableMenuItem(popup, ReplaceDefaultId, MF_BYCOMMAND | (actionBusy ? MF_GRAYED : MF_ENABLED)); + EnableMenuItem(popup, ExportId, MF_BYCOMMAND | (canExportTable() && !actionBusy ? MF_ENABLED : MF_GRAYED)); const int speed = interval == 250 ? SpeedAdaptive : interval == 500 ? SpeedHigh : interval == 4000 ? SpeedLow : interval == 0 ? SpeedPause : SpeedNormal; CheckMenuRadioItem(popup, SpeedHigh, SpeedAdaptive, UINT(speed), MF_BYCOMMAND); CheckMenuRadioItem(popup, DefaultTabId, DefaultTabId + TabCount - 1, UINT(DefaultTabId + defaultTab), MF_BYCOMMAND); @@ -467,37 +492,204 @@ void Application::readHistoryFile(const std::wstring& path) { } void Application::saveHistory() { if (persistent() && !historyFile.empty()) writeHistoryFile(historyFile); } void Application::loadHistory() { readHistoryFile(historyFile); } +static int nextSamplingRetry(int previous) { return previous <= 0 ? 250 : previous >= 2000 ? 4000 : previous * 2; } void Application::worker() { - try { - const auto initialized = CoInitializeEx(nullptr, COINIT_MULTITHREADED); - Sampler source(!hidden(), [this] { wake.notify_all(); }); - double samplingCost = 0; - while (!stopping) { - const auto sampleStarted = Clock::now(); - auto sample = std::make_shared(source.sample(startupVisible.load())); - const double cost = milliseconds(sampleStarted); samplingCost = samplingCost == 0 ? cost : samplingCost * .8 + cost * .2; - { std::lock_guard lock(workerMutex); pending = std::move(sample); + struct Apartment { + HRESULT initialized = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + ~Apartment() { if (SUCCEEDED(initialized)) CoUninitialize(); } + } apartment; + // COM must outlive the sampler, including constructor failures and shutdown. + std::optional source; + double samplingCost = 0; + int retry = 0; + bool postedFailure = false; + const auto startupReady = [&] { return source && source->startupReady(); }; + const auto postStatus = [&](bool failed) { +#ifdef TASKMGR_DIAGNOSTICS + if (samplingStatusNotification) return samplingStatusNotification(failed); +#endif + return window && PostMessageW(window, SamplingStatusMessage, 0, 0) != FALSE; + }; + while (!stopping) { + Time sampleStarted{}; + std::shared_ptr sample; + try { #ifdef TASKMGR_DIAGNOSTICS - published = Clock::now(); + if (samplingOverride) { sampleStarted = Clock::now(); sample = std::make_shared(samplingOverride()); } + else #endif + { + if (!source) source.emplace(!hidden(), [this] { wake.notify_all(); }); + sampleStarted = Clock::now(); sample = std::make_shared(source->sample(startupVisible.load())); } - if (!notification.exchange(true)) PostMessageW(window, SampleMessage, 0, 0); - std::unique_lock lock(workerMutex); const int delay = interval.load(); + const double cost = milliseconds(sampleStarted); samplingCost = samplingCost == 0 ? cost : samplingCost * .8 + cost * .2; + } catch (...) { + // Keep the last published snapshot and the sampler's owned inventories. + // An interrupted sample never reaches the UI, and startup work can finish + // while this thread waits to retry instead of being destroyed on failure. + } + std::unique_lock lock(workerMutex); + if (stopping) break; + const bool failed = !sample; +#ifdef TASKMGR_DIAGNOSTICS + if (failed) ++samplingFailures; +#endif + if (!failed) { + pending = std::move(sample); retry = 0; +#ifdef TASKMGR_DIAGNOSTICS + published = Clock::now(); +#endif + if (!notification.exchange(true) && window && !PostMessageW(window, SampleMessage, 0, 0)) notification = false; + } + samplingFailed = failed; + // Advance only after Windows accepts the message. A full queue must not + // permanently lose a failure or recovery transition on later iterations. + if (failed != postedFailure && postStatus(failed)) postedFailure = failed; +#ifdef TASKMGR_DIAGNOSTICS + if (samplingOverride) wake.notify_all(); +#endif + const int delay = interval.load(); + if (failed) { + retry = nextSamplingRetry(retry); + // Startup-ready notifications cannot bypass the retry bound. Refresh, + // cadence changes and shutdown can always interrupt it, including pause. + wake.wait_for(lock, std::chrono::milliseconds(retry), [&] { return stopping || force || interval.load() != delay; }); + } else { if (!delay) { - while (source.startupPending() && !source.startupReady() && !stopping && !force && interval.load() == 0) wake.wait_for(lock, std::chrono::milliseconds(100)); - if (!source.startupReady()) wake.wait(lock, [&] { return stopping || force || interval.load() != 0; }); + while (source && source->startupPending() && !startupReady() && !stopping && !force && interval.load() == 0) wake.wait_for(lock, std::chrono::milliseconds(100)); + if (!startupReady()) wake.wait(lock, [&] { return stopping || force || interval.load() != 0; }); } else { const int requested = delay == 250 ? (fastVisible ? adaptiveRefreshInterval(samplingCost, displayCost.load()) : 1000) : delay; const auto cadence = std::chrono::milliseconds(minimized ? std::max(requested, 4000) : requested); const auto deadline = std::max(sampleStarted + cadence, Clock::now() + std::chrono::milliseconds(25)); - wake.wait_until(lock, deadline, [&] { return stopping || force || source.startupReady() || interval.load() != delay; }); + wake.wait_until(lock, deadline, [&] { return stopping || force || startupReady() || interval.load() != delay; }); } - if (forceInventory) source.invalidateInventory(); force = forceInventory = false; } - if (SUCCEEDED(initialized)) CoUninitialize(); - } catch (const std::exception& exception) { std::lock_guard lock(actionMutex); actionResults.push_back({fromUtf8(exception.what()), L""}); PostMessageW(window, ActionMessage, 0, 0); } + if (forceInventory && source) source->invalidateInventory(); + force = forceInventory = false; + } } +#ifdef TASKMGR_DIAGNOSTICS +bool Application::verifyWorkerRecovery() { + bool passed = true; + const auto check = [&](bool result, const char* name) { if (!result && sortingFailure.empty()) sortingFailure = name; passed = passed && result; }; + int retry = 0; + for (const int expected : {250, 500, 1000, 2000, 4000, 4000}) { retry = nextSamplingRetry(retry); check(retry == expected, "worker-retry-bounded-backoff"); } + check(nextSamplingRetry(0) == 250 && nextSamplingRetry(INT_MAX) == 4000, "worker-retry-reset-and-no-overflow"); + struct ApartmentState { + HRESULT result; APTTYPE type; APTTYPEQUALIFIER qualifier; + bool operator==(const ApartmentState&) const = default; + }; + const auto apartmentState = [] { + ApartmentState state{S_OK, APTTYPE_CURRENT, APTTYPEQUALIFIER_NONE}; + state.result = CoGetApartmentType(&state.type, &state.qualifier); return state; + }; + { + std::promise completed; auto completion = completed.get_future(); + std::atomic attempts{0}; std::atomic recover{false}, apartmentActive{true}; + Application fixture(RunOptions{}); fixture.interval = 0; + fixture.pending = std::make_shared(); fixture.pending->cpu = 99; + const auto retained = fixture.pending; + fixture.samplingOverride = [&] { + const auto apartment = apartmentState(); + if (FAILED(apartment.result) || apartment.type != APTTYPE_MTA || apartment.qualifier == APTTYPEQUALIFIER_IMPLICIT_MTA) apartmentActive = false; + const unsigned attempt = ++attempts; + if (!recover) { if (attempt == 1) throw std::runtime_error("owned transient sample failure"); throw 1; } + Sample result; result.cpu = double(attempt); return result; + }; + fixture.sampler = std::thread([&] { const auto before = apartmentState(); fixture.worker(); completed.set_value(apartmentState() == before); }); + const auto waitFor = [&](auto predicate) { std::unique_lock lock(fixture.workerMutex); return fixture.wake.wait_for(lock, std::chrono::seconds(3), predicate); }; + check(waitFor([&] { return fixture.samplingFailed.load(); }), "worker-exception-enters-retry-state"); + { std::lock_guard lock(fixture.workerMutex); check(fixture.pending == retained && fixture.actionResults.empty(), "worker-failure-retains-last-good-snapshot-without-dialogs"); } + check(waitFor([&] { return fixture.samplingFailures >= 2u && fixture.samplingFailed; }), "worker-retries-standard-and-unknown-exceptions"); + fixture.beginSamplingMeasurement(); + check(!fixture.samplingHealthyAtStart && !fixture.samplingMeasurementHealthy(), "worker-measurement-rejects-failure-at-start"); + recover = true; + check(waitFor([&] { return fixture.pending != retained && !fixture.samplingFailed; }), "worker-recovers-with-fresh-snapshot-and-clears-status"); + check(!fixture.samplingMeasurementHealthy(), "worker-recovery-cannot-repair-unhealthy-measurement-start"); + fixture.beginSamplingMeasurement(); + const auto failures = fixture.samplingFailureBaseline; + check(failures >= 2u && fixture.samplingMeasurementHealthy(), "worker-recovered-warmup-can-start-healthy-measurement"); + unsigned recovered = attempts.load(); + { + std::unique_lock lock(fixture.workerMutex); + check(fixture.pending && fixture.pending->cpu == double(recovered) && fixture.notification && apartmentActive, "worker-publishes-only-complete-sample-inside-apartment"); + check(!fixture.wake.wait_for(lock, std::chrono::milliseconds(50), [&] { return attempts.load() != recovered; }), "worker-recovery-respects-paused-cadence"); + } + fixture.refresh(false); + check(waitFor([&] { return fixture.pending && fixture.pending->cpu == double(recovered + 1); }), "worker-paused-manual-refresh-publishes-once"); + { + std::unique_lock lock(fixture.workerMutex); + check(!fixture.wake.wait_for(lock, std::chrono::milliseconds(50), [&] { return attempts.load() != recovered + 1; }), "worker-manual-refresh-remains-paused"); + fixture.stopping = true; + } + fixture.wake.notify_all(); + const bool stopped = completion.wait_for(std::chrono::seconds(2)) == std::future_status::ready; + check(stopped, "worker-shutdown-interrupts-paused-wait"); + if (!stopped) fixture.wake.notify_all(); + fixture.sampler.join(); + check(fixture.samplingFailures == failures && fixture.samplingMeasurementHealthy(), "worker-success-does-not-add-sampling-failures"); + check(completion.get(), "worker-balances-new-com-apartment-on-exit"); + check(fixture.actionResults.empty(), "worker-recovery-never-queues-modal-errors"); + + // Exercise the actual status-message route on an invisible owned window. + fixture.window = CreateWindowExW(0, L"STATIC", L"Task Manager", WS_POPUP, 0, 0, 0, 0, nullptr, nullptr, GetModuleHandleW(nullptr), nullptr); + check(fixture.window != nullptr, "worker-status-fixture-window"); + if (fixture.window) { + SetWindowLongPtrW(fixture.window, GWLP_USERDATA, LONG_PTR(&fixture)); + fixture.samplingFailed = true; procedure(fixture.window, SamplingStatusMessage, 0, 0); + wchar_t title[128]{}; GetWindowTextW(fixture.window, title, 128); + check(std::wstring_view(title).find(L"retrying") != std::wstring_view::npos, "worker-failure-title-is-nonmodal"); + fixture.samplingFailed = false; procedure(fixture.window, SamplingStatusMessage, 0, 0); GetWindowTextW(fixture.window, title, 128); + check(std::wstring_view(title) == L"Task Manager", "worker-recovery-clears-title-without-row-changes"); + DestroyWindow(fixture.window); fixture.window = nullptr; + } + } + for (const DWORD model : {DWORD(COINIT_MULTITHREADED), DWORD(COINIT_APARTMENTTHREADED)}) { + std::promise completed; auto completion = completed.get_future(); + Application fixture(RunOptions{}); fixture.interval = 0; + fixture.samplingOverride = []() -> Sample { throw std::runtime_error("owned persistent sample failure"); }; + fixture.sampler = std::thread([&] { + const auto baseline = apartmentState(); const auto initialized = CoInitializeEx(nullptr, model); const auto inherited = apartmentState(); + fixture.worker(); const bool retained = apartmentState() == inherited; + if (SUCCEEDED(initialized)) CoUninitialize(); + completed.set_value(SUCCEEDED(initialized) && retained && apartmentState() == baseline); + }); + { + std::unique_lock lock(fixture.workerMutex); + check(fixture.wake.wait_for(lock, std::chrono::seconds(3), [&] { return fixture.samplingFailed.load(); }), "worker-persistent-failure-reaches-backoff"); + fixture.stopping = true; + } + fixture.wake.notify_all(); + const bool stopped = completion.wait_for(std::chrono::seconds(2)) == std::future_status::ready; + check(stopped, "worker-shutdown-interrupts-error-backoff"); + if (!stopped) fixture.wake.notify_all(); + fixture.sampler.join(); + check(completion.get(), model == DWORD(COINIT_MULTITHREADED) ? "worker-balances-s-false-com-initialization" : "worker-preserves-apartment-after-failed-com-initialization"); + } + { + std::atomic recover{false}; std::vector> messages; + Application fixture(RunOptions{}); fixture.interval = 25; + fixture.samplingOverride = [&]() -> Sample { if (!recover) throw std::runtime_error("owned status notification failure"); return {}; }; + fixture.samplingStatusNotification = [&](bool failed) { const bool accepted = messages.size() % 2 != 0; messages.emplace_back(failed, accepted); return accepted; }; + fixture.beginSamplingMeasurement(); + fixture.sampler = std::thread([&] { fixture.worker(); }); + { + std::unique_lock lock(fixture.workerMutex); + check(fixture.wake.wait_for(lock, std::chrono::seconds(3), [&] { return messages.size() >= 2; }), "worker-retries-failed-error-status-post"); + recover = true; + check(fixture.wake.wait_for(lock, std::chrono::seconds(3), [&] { return messages.size() >= 4; }), "worker-retries-failed-recovery-status-post"); + check(messages == std::vector>{{true, false}, {true, true}, {false, false}, {false, true}}, "worker-status-advances-only-after-successful-post"); + check(!fixture.wake.wait_for(lock, std::chrono::milliseconds(50), [&] { return messages.size() > 4; }), "worker-unchanged-status-does-not-post-again"); + fixture.stopping = true; + } + fixture.wake.notify_all(); fixture.sampler.join(); + check(!fixture.samplingFailed && fixture.samplingFailures >= 2u && !fixture.samplingMeasurementHealthy(), "worker-recovered-failure-inside-measurement-remains-unhealthy"); + } + return passed; +} +#endif void Application::consume() { if (rebuilding || verifyingSorting) return; if (!hidden() && (GetKeyState(VK_CONTROL) & 0x8000)) return; @@ -573,7 +765,7 @@ LRESULT CALLBACK Application::procedure(HWND target, UINT message, WPARAM word, case WM_CREATE: app->create(); return 0; case WM_SETTINGCHANGE: case WM_THEMECHANGED: app->indexedServices.clear(); app->indexedInventory.reset(); app->applyTheme(); return 0; case WM_ERASEBKGND: { RECT bounds{}; GetClientRect(target, &bounds); FillRect(HDC(word), &bounds, app->backgroundBrush ? app->backgroundBrush : GetStockBrush(WHITE_BRUSH)); return 1; } - case WM_CTLCOLOREDIT: { const HDC dc = HDC(word); SetTextColor(dc, app->themeColor(RGB(0, 0, 0))); SetBkColor(dc, app->themeColor(RGB(255, 255, 255))); return LRESULT(app->backgroundBrush); } + case WM_CTLCOLORSTATIC: case WM_CTLCOLOREDIT: { const HDC dc = HDC(word); SetTextColor(dc, app->themeColor(RGB(0, 0, 0))); SetBkColor(dc, app->themeColor(RGB(255, 255, 255))); return LRESULT(app->backgroundBrush); } case WM_APP + 11: if (app->persistent() && !GetPrivateProfileIntW(L"Window", L"ReplacementPrompted", 0, app->preferences.c_str())) { const bool enabled = replacementEnabled(); @@ -594,6 +786,7 @@ LRESULT CALLBACK Application::procedure(HWND target, UINT message, WPARAM word, return 0; } case SampleMessage: app->consume(); return 0; + case SamplingStatusMessage: SetWindowTextW(target, app->samplingFailed ? L"Task Manager - Live updates unavailable; retrying..." : L"Task Manager"); return 0; case IconMessage: app->icons->consume(); if (!app->iconRefreshQueued) app->iconRefreshQueued = SetTimer(target, 2, app->hidden() ? 10 : 120, nullptr) != 0; return 0; case ActionMessage: { std::deque results; { std::lock_guard lock(app->actionMutex); results.swap(app->actionResults); } for (const auto& result : results) { if (!app->hidden() && !result.error.empty()) MessageBoxW(target, result.error.c_str(), L"Task Manager", MB_OK | MB_ICONERROR); if (!app->hidden() && !result.report.empty()) DialogBoxParamW(GetModuleHandleW(nullptr), MAKEINTRESOURCEW(103), target, [](HWND dialog, UINT code, WPARAM parameter, LPARAM value) -> INT_PTR { if (code == WM_INITDIALOG) { SetDlgItemTextW(dialog, 1001, reinterpret_cast(value)); return TRUE; } if (code == WM_COMMAND && (LOWORD(parameter) == IDOK || LOWORD(parameter) == IDCANCEL)) { EndDialog(dialog, IDOK); return TRUE; } return FALSE; }, LPARAM(result.report.c_str())); } app->refresh(); return 0; } case TrayMessage: @@ -724,7 +917,7 @@ LRESULT CALLBACK Application::procedure(HWND target, UINT message, WPARAM word, break; } case WM_PAINT: { PAINTSTRUCT paint{}; HDC dc = BeginPaint(target, &paint); app->paintWindow(dc); EndPaint(target, &paint); return 0; } - case WM_CLOSE: app->closing = true; app->saveSettings(); app->saveHistory(); app->stopping = true; app->wake.notify_all(); if (app->trayAdded) Shell_NotifyIconW(NIM_DELETE, &app->tray); DestroyWindow(target); return 0; + case WM_CLOSE: app->closing = true; app->saveSettings(); app->saveHistory(); { std::lock_guard lock(app->workerMutex); app->stopping = true; } app->wake.notify_all(); if (app->trayAdded) Shell_NotifyIconW(NIM_DELETE, &app->tray); DestroyWindow(target); return 0; case WM_DESTROY: PostQuitMessage(0); return 0; } return DefWindowProcW(target, message, word, data); diff --git a/native/performance.cpp b/native/performance.cpp index 5bc9b79..530831a 100644 --- a/native/performance.cpp +++ b/native/performance.cpp @@ -126,6 +126,12 @@ const std::vector& Application::perfItems() const { for (const auto& gpu : current->gpus) result.push_back({L"gpu/" + gpu.key, L"GPU " + std::to_wstring(gpu.index), gpu.name, number(gpu.usage, 0) + L"%" + (gpu.temperature >= 0 ? L" (" + number(gpu.temperature, 0) + L" °C)" : L""), gpu.name, GpuColor}); return result; } +static size_t selectedResourceIndex(const std::vector& items, std::wstring_view key) { + const auto found = std::find_if(items.begin(), items.end(), [&](const PerfItem& item) { return item.key == key; }); + // Hotplug can remove the selection before the next paint. Copy and keyboard + // navigation must resolve that case exactly as rendering does. + return found == items.end() ? 0 : size_t(found - items.begin()); +} void Application::recordHistories(double now) { histories[L"cpu"].add(now, current->cpu); for (size_t index = 0; index < current->cores.size(); ++index) histories[L"core/" + std::to_wstring(index)].add(now, current->cores[index]); @@ -147,8 +153,8 @@ void Application::paintPerformance(HDC dc, RECT bounds) { FillRect(dc, &bounds, backgroundBrush); SetBkMode(dc, TRANSPARENT); const auto& items = perfItems(); if (items.empty()) { text(dc, L"Collecting performance data…", font, themeColor(LabelColor), {scale(250), scale(30), bounds.right, scale(60)}); return; } - if (std::none_of(items.begin(), items.end(), [&](const PerfItem& item) { return item.key == selectedResource; })) selectedResource = items.front().key; - const auto& selected = *std::find_if(items.begin(), items.end(), [&](const PerfItem& item) { return item.key == selectedResource; }); + const auto& selected = items[selectedResourceIndex(items, selectedResource)]; + if (selectedResource != selected.key) { selectedResource = selected.key; sidebarScroll = 0; } if (summary == 1) { paintDetail(dc, {bounds.left + scale(8), bounds.top, bounds.right - scale(8), bounds.bottom - scale(8)}, selected); return; } if (summary == 2) { paintSidebar(dc, bounds, items); return; } const int sidebar = scale(222); @@ -322,9 +328,10 @@ void Application::paintDetail(HDC dc, RECT bounds, const PerfItem& item) { } std::wstring Application::performanceSummary() const { if (!current) return L""; - const auto& items = perfItems(); std::wstring result; - for (const auto& item : items) if (item.key == selectedResource) { result = item.title + L"\r\n\r\n\t" + item.description + L"\r\n\r\n\t" + item.value + L"\r\n"; break; } - if (selectedResource == L"cpu") result += L"\tProcesses:\t" + std::to_wstring(current->processes.size()) + L"\r\n\tThreads:\t" + std::to_wstring(current->threads) + L"\r\n\tHandles:\t" + std::to_wstring(current->handles) + L"\r\n\tUp time:\t" + duration(current->uptime) + L"\r\n"; + const auto& items = perfItems(); if (items.empty()) return L""; + const auto& item = items[selectedResourceIndex(items, selectedResource)]; + std::wstring result = item.title + L"\r\n\r\n\t" + item.description + L"\r\n\r\n\t" + item.value + L"\r\n"; + if (item.key == L"cpu") result += L"\tProcesses:\t" + std::to_wstring(current->processes.size()) + L"\r\n\tThreads:\t" + std::to_wstring(current->threads) + L"\r\n\tHandles:\t" + std::to_wstring(current->handles) + L"\r\n\tUp time:\t" + duration(current->uptime) + L"\r\n"; return result; } void Application::performanceClick(POINT point, bool right, bool twice) { @@ -343,11 +350,12 @@ void Application::performanceClick(POINT point, bool right, bool twice) { void Application::performanceKey(WPARAM key) { if ((GetKeyState(VK_CONTROL) & 0x8000) && key == 'C') { copy(); return; } const auto& items = perfItems(); if (items.empty()) return; - int index = int(std::find_if(items.begin(), items.end(), [&](const PerfItem& item) { return item.key == selectedResource; }) - items.begin()); + int index = int(selectedResourceIndex(items, selectedResource)); if (key == VK_UP) --index; else if (key == VK_DOWN) ++index; else if (key == VK_HOME) index = 0; else if (key == VK_END) index = int(items.size()) - 1; else return; index = std::clamp(index, 0, int(items.size()) - 1); selectedResource = items[size_t(index)].key; RECT client{}; GetClientRect(performance, &client); const int pitch = scale(hideGraphs ? 60 : 82); - if (index * pitch < sidebarScroll) sidebarScroll = index * pitch; if ((index + 1) * pitch + scale(24) > sidebarScroll + client.bottom) sidebarScroll = (index + 1) * pitch + scale(24) - client.bottom; + if (index * pitch < sidebarScroll) sidebarScroll = index * pitch; + if ((index + 1) * pitch + scale(24) > sidebarScroll + client.bottom) sidebarScroll = (index + 1) * pitch + scale(24) - client.bottom; InvalidateRect(performance, nullptr, FALSE); } RECT Application::performanceRowRect(int index) const { diff --git a/native/trace.cpp b/native/trace.cpp index 3c5f50c..8ce2968 100644 --- a/native/trace.cpp +++ b/native/trace.cpp @@ -19,13 +19,14 @@ struct TraceCounts { std::unordered_map counters; Time sampledAt{}; uint64_t generation = 0; - void record(const NetworkEvent& event) { + bool record(const NetworkEvent& event) { const auto found = counters.find(event.pid); // ProcessTrace converts QPC event timestamps to FILETIME unless RAW_TIMESTAMP is set. // Late delivery from an old PID must not count against its new owner's creation time. - if (found == counters.end() || !found->second.created || event.timestamp < found->second.created) return; + if (found == counters.end() || !found->second.created || event.timestamp < found->second.created) return false; auto& bytes = found->second.bytes; bytes += std::min(event.bytes, UINT64_MAX - bytes); + return true; } void sample(std::span processes, Time now) { const double elapsed = sampledAt == Time{} ? 0 : std::chrono::duration(now - sampledAt).count(); @@ -76,10 +77,10 @@ std::vector> traceSamplingTests() { checks.emplace_back("trace_reuses_counter_nodes_between_samples", &trace.counters.at(10) == retained && trace.counters.at(10).bytes == 0); trace.record({10, 200, 103}); processes[0].created = 1000; trace.sample(processes, initial + std::chrono::seconds(3)); checks.emplace_back("trace_reused_pid_discards_old_owner_traffic", processes[0].network == 0 && trace.counters.at(10).created == 1000); - trace.record({10, 300, 999}); trace.record({10, 400, 1000}); trace.sample(processes, initial + std::chrono::seconds(4)); - checks.emplace_back("trace_late_old_pid_events_are_rejected", processes[0].network == 400); - trace.record({20, 9999, 1001}); - checks.emplace_back("trace_unknown_process_events_do_not_allocate", trace.counters.size() == 1); + const bool rejected = !trace.record({10, 300, 999}), accepted = trace.record({10, 400, 1000}); trace.sample(processes, initial + std::chrono::seconds(4)); + checks.emplace_back("trace_late_old_pid_events_are_rejected", rejected && accepted && processes[0].network == 400); + const bool untracked = !trace.record({20, 9999, 1001}); + checks.emplace_back("trace_unknown_process_events_do_not_allocate", untracked && trace.counters.size() == 1); trace.sample({}, initial + std::chrono::seconds(5)); trace.record({10, 9999, 1001}); checks.emplace_back("trace_departed_process_counters_are_pruned", trace.counters.empty()); trace.sample(processes, initial + std::chrono::seconds(6)); @@ -99,6 +100,10 @@ struct NetworkTrace::State { std::thread worker; std::atomic consuming = false; std::atomic status = ERROR_SUCCESS; +#ifdef TASKMGR_DIAGNOSTICS + const DWORD diagnosticPid = GetCurrentProcessId(); + std::atomic deliveredEvents = 0, decodedEvents = 0, ownUdpEvents = 0; +#endif std::mutex gate; TraceCounts counts; EVENT_TRACE_PROPERTIES* header() { return reinterpret_cast(properties.data()); } @@ -118,17 +123,41 @@ struct NetworkTrace::State { if (consumer != INVALID_PROCESSTRACE_HANDLE) CloseTrace(consumer); if (worker.joinable()) worker.join(); } + template static void WINAPI record(EVENT_RECORD* event) { - const auto parsed = networkEvent(*event); if (!parsed || !event->UserContext) return; - auto state = static_cast(event->UserContext); std::lock_guard lock(state->gate); state->counts.record(*parsed); + if (!event->UserContext) return; + auto state = static_cast(event->UserContext); +#ifdef TASKMGR_DIAGNOSTICS + if constexpr (measure) state->deliveredEvents.fetch_add(1); +#endif + const auto parsed = networkEvent(*event); if (!parsed) return; +#ifdef TASKMGR_DIAGNOSTICS + if constexpr (measure) state->decodedEvents.fetch_add(1); +#endif + std::lock_guard lock(state->gate); + [[maybe_unused]] const bool attributed = state->counts.record(*parsed); +#ifdef TASKMGR_DIAGNOSTICS + if constexpr (measure) if (attributed && parsed->bytes && parsed->pid == state->diagnosticPid && event->EventHeader.ProviderId == UdpIp) state->ownUdpEvents.fetch_add(1); +#endif } }; +#ifdef TASKMGR_DIAGNOSTICS +NetworkTrace::NetworkTrace() : NetworkTrace(false) {} +NetworkTrace::NetworkTrace(bool measure) : state(std::make_unique()) { +#else NetworkTrace::NetworkTrace() : state(std::make_unique()) { +#endif // Never stop a session by a reusable PID-based name: another live sampler may own it. if (!state->initialize()) return; const auto started = StartTraceW(&state->session, state->name.c_str(), state->header()); state->status = started; if (started != ERROR_SUCCESS) { state->session = 0; return; } - EVENT_TRACE_LOGFILEW log{}; log.LoggerName = state->name.data(); log.ProcessTraceMode = PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD; log.EventRecordCallback = State::record; log.Context = state.get(); + EVENT_TRACE_LOGFILEW log{}; log.LoggerName = state->name.data(); log.ProcessTraceMode = PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD; log.Context = state.get(); +#ifdef TASKMGR_DIAGNOSTICS + // Compile out the counters from ordinary diagnostic traces used for performance comparisons. + log.EventRecordCallback = measure ? State::record : State::record; +#else + log.EventRecordCallback = State::record; +#endif state->consumer = OpenTraceW(&log); if (state->consumer == INVALID_PROCESSTRACE_HANDLE) { state->status = GetLastError(); ControlTraceW(state->session, nullptr, state->header(), EVENT_TRACE_CONTROL_STOP); state->session = 0; return; } state->consuming = true; @@ -144,6 +173,112 @@ bool NetworkTrace::sample(std::span processes) { } #ifdef TASKMGR_DIAGNOSTICS DWORD NetworkTrace::status() const { return state->status.load(); } +NetworkSmokeResult networkTraceSmoke() { + // Run separately from portable self-tests: successful sockets without attributed ETW events fail. + // Nothing is sent outside the two loopback endpoints owned by this process. + const auto began = Clock::now(); + NetworkSmokeResult result; + NetworkTrace trace(true); + auto finish = [&](std::string reason, bool passed = false, bool skipped = false) { + result.reason = std::move(reason); result.passed = passed; result.skipped = skipped; + if (result.traceStatus == ERROR_SUCCESS) result.traceStatus = trace.status(); + // Read in reverse pipeline order so these diagnostics remain consistent while callbacks finish. + result.ownUdpEvents = trace.state->ownUdpEvents.load(); + result.decodedEvents = trace.state->decodedEvents.load(); + result.deliveredEvents = trace.state->deliveredEvents.load(); + if (passed && (result.traceStatus != ERROR_SUCCESS || !trace.active())) { result.passed = false; result.reason = "trace_consumer_stopped"; } + result.elapsedMilliseconds = milliseconds(began); + return result; + }; + auto unavailable = [&] { + result.traceStatus = trace.status(); + if (result.traceStatus == ERROR_ACCESS_DENIED || result.traceStatus == ERROR_PRIVILEGE_NOT_HELD) return finish("trace_permission", false, true); + if (result.traceStatus == ERROR_NOT_SUPPORTED || result.traceStatus == ERROR_NO_SYSTEM_RESOURCES) return finish("trace_session_unavailable", false, true); + return finish("trace_initialization_failed"); + }; + if (!trace.active()) return unavailable(); + FILETIME created{}, exited{}, kernel{}, user{}; + if (!GetProcessTimes(GetCurrentProcess(), &created, &exited, &kernel, &user)) return finish("owned_process_identity_failed_" + std::to_string(GetLastError())); + if (!ticks(created)) return finish("owned_process_identity_missing"); + std::array processes; processes[0].id = GetCurrentProcessId(); processes[0].created = ticks(created); + if (!trace.sample(processes)) return unavailable(); + ++result.samples; + if (processes[0].network != 0) return finish("owned_process_baseline_not_zero"); + + WSADATA data{}; + const int initialized = WSAStartup(MAKEWORD(2, 2), &data); + if (initialized != 0) { result.socketStatus = DWORD(initialized); return finish("winsock_initialization_failed"); } + struct SocketRuntime { ~SocketRuntime() { WSACleanup(); } } runtime; + struct Socket { + SOCKET value = INVALID_SOCKET; + Socket() = default; + Socket(const Socket&) = delete; + Socket& operator=(const Socket&) = delete; + ~Socket() { if (value != INVALID_SOCKET) closesocket(value); } + } sender, receiver; + auto socketFailure = [&](std::string reason) { result.socketStatus = DWORD(WSAGetLastError()); return finish(std::move(reason)); }; + auto bindLoopback = [&](Socket& owned, sockaddr_in& address) { + owned.value = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (owned.value == INVALID_SOCKET) return false; + const BOOL exclusive = TRUE; u_long nonblocking = 1; + if (setsockopt(owned.value, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, reinterpret_cast(&exclusive), sizeof(exclusive)) == SOCKET_ERROR || + ioctlsocket(owned.value, FIONBIO, &nonblocking) == SOCKET_ERROR) return false; + address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (bind(owned.value, reinterpret_cast(&address), sizeof(address)) == SOCKET_ERROR) return false; + int size = sizeof(address); + if (getsockname(owned.value, reinterpret_cast(&address), &size) == SOCKET_ERROR) return false; + if (size != int(sizeof(address)) || address.sin_family != AF_INET || address.sin_addr.s_addr != htonl(INADDR_LOOPBACK) || !address.sin_port) { WSASetLastError(WSAEINVAL); return false; } + return true; + }; + sockaddr_in source{}, destination{}; + if (!bindLoopback(sender, source) || !bindLoopback(receiver, destination)) return socketFailure("udp_loopback_bind_failed"); + // Both sockets bind exclusively to ephemeral loopback ports; connect filters unrelated datagrams. + if (connect(sender.value, reinterpret_cast(&destination), sizeof(destination)) == SOCKET_ERROR || + connect(receiver.value, reinterpret_cast(&source), sizeof(source)) == SOCKET_ERROR) return socketFailure("udp_loopback_connect_failed"); + constexpr unsigned datagrams = 32; + std::array expected{}, received{}; + for (size_t index = 0; index < expected.size(); ++index) expected[index] = char(index % 113); + const auto transferDeadline = Clock::now() + std::chrono::seconds(1); + for (unsigned sequence = 0; sequence < datagrams; ++sequence) { + memcpy(expected.data(), &sequence, sizeof(sequence)); + for (;;) { + if (Clock::now() >= transferDeadline) { result.socketStatus = WSAETIMEDOUT; return finish("udp_loopback_send_timed_out"); } + const int sent = send(sender.value, expected.data(), int(expected.size()), 0); + if (sent == int(expected.size())) { ++result.sentDatagrams; result.sentBytes += unsigned(sent); break; } + if (sent != SOCKET_ERROR) return finish("udp_loopback_partial_datagram"); + const int error = WSAGetLastError(); + if (error != WSAEWOULDBLOCK) { result.socketStatus = DWORD(error); return finish("udp_loopback_send_failed"); } + Sleep(1); + } + for (;;) { + if (Clock::now() >= transferDeadline) { result.socketStatus = WSAETIMEDOUT; return finish("udp_loopback_receive_timed_out"); } + const int count = recv(receiver.value, received.data(), int(received.size()), 0); + if (count >= 0) { + if (count != int(expected.size()) || received != expected) return finish("udp_loopback_payload_mismatch"); + ++result.receivedDatagrams; result.receivedBytes += unsigned(count); break; + } + const int error = WSAGetLastError(); + if (error != WSAEWOULDBLOCK) { result.socketStatus = DWORD(error); return finish("udp_loopback_receive_failed"); } + Sleep(1); + } + } + // ControlTrace supersedes FlushTrace. Flush only the private session this diagnostic owns. + result.traceStatus = ControlTraceW(trace.state->session, nullptr, trace.state->header(), EVENT_TRACE_CONTROL_FLUSH); + if (result.traceStatus != ERROR_SUCCESS) return finish("trace_flush_failed"); + const auto deliveryDeadline = Clock::now() + std::chrono::seconds(3); + do { + if (!trace.sample(processes)) return finish("trace_consumer_stopped"); + ++result.samples; + if (!std::isfinite(processes[0].network) || processes[0].network < 0) return finish("owned_process_rate_invalid"); + result.peakBytesPerSecond = std::max(result.peakBytesPerSecond, processes[0].network); + if (result.peakBytesPerSecond > 0 && trace.state->ownUdpEvents.load() > 0) { + if (!trace.active() || trace.status() != ERROR_SUCCESS) return finish("trace_consumer_stopped"); + return finish("", true); + } + Sleep(25); + } while (Clock::now() < deliveryDeadline); + return finish("no_owned_udp_attribution"); +} std::vector> traceLifecycleTests() { std::vector> checks; NetworkTrace first; diff --git a/native/verify.cpp b/native/verify.cpp index c3354bc..99a2887 100644 --- a/native/verify.cpp +++ b/native/verify.cpp @@ -5,6 +5,130 @@ #include namespace taskmgr { +bool Application::verifyBenchmarkSamplingHealth() { + bool passed = true; + const auto check = [&](bool result, const char* name) { if (!result && sortingFailure.empty()) sortingFailure = name; passed = passed && result; }; + GUID guid{}; wchar_t identifier[40]{}; std::error_code error; + const auto temporary = std::filesystem::temp_directory_path(error); + check(!error && SUCCEEDED(CoCreateGuid(&guid)) && StringFromGUID2(guid, identifier, int(std::size(identifier))) > 0, "benchmark-health-fixture-temporary-name"); + if (error || !*identifier) return false; + const auto directory = temporary / (std::wstring(L"TaskManagerBenchmarkHealth-") + identifier); + const bool created = CreateDirectoryW(directory.c_str(), nullptr) != FALSE; + check(created, "benchmark-health-fixture-directory"); if (!created) return false; + struct Cleanup { std::filesystem::path path; ~Cleanup() { std::error_code error; std::filesystem::remove_all(path, error); } } cleanup{directory}; + RunOptions settings; settings.hidden = settings.idle = true; settings.output = (directory / L"health.json").wstring(); + Application fixture(settings); fixture.updates = 1; fixture.keyboardPassed = fixture.sortingPassed = true; + fixture.icons = std::make_unique(nullptr, scale(16), true, false); + const auto report = [&](bool expected, const char* name) { + fixture.writeBenchmark(); + std::ifstream file{std::filesystem::path(settings.output), std::ios::binary}; + const std::string contents{std::istreambuf_iterator(file), std::istreambuf_iterator()}; + const auto value = expected ? "true" : "false"; + check((fixture.exitCode == 0) == expected && contents.find(std::string("\"samplingHealthy\":") + value) != contents.npos && + contents.find(std::string("\"passed\":") + value) != contents.npos && contents.find("\"samplingFailure") == contents.npos, name); + }; + report(false, "benchmark-health-requires-measurement-boundary"); + fixture.samplingFailures = 3; fixture.beginSamplingMeasurement(); + report(true, "benchmark-health-excludes-recovered-warmup-failures"); + ++fixture.samplingFailures; fixture.samplingFailed = true; + report(false, "benchmark-health-rejects-current-measured-failure"); + fixture.samplingFailed = false; + report(false, "benchmark-health-rejects-recovered-measured-failure"); + fixture.samplingFailed = true; fixture.beginSamplingMeasurement(); fixture.samplingFailed = false; + report(false, "benchmark-health-rejects-unhealthy-measurement-start"); + fixture.beginSamplingMeasurement(); fixture.samplingFailed = true; + report(false, "benchmark-health-requires-healthy-measurement-end"); + fixture.samplingFailed = false; fixture.interval = 0; fixture.beginSamplingMeasurement(); + report(true, "benchmark-health-allows-intentional-pause-without-new-samples"); + fixture.interval = 1000; + for (const bool idle : {true, false}) { + fixture.options.idle = idle; fixture.beginSamplingMeasurement(); ++fixture.samplingFailures; + report(false, idle ? "benchmark-idle-rejects-sampling-failure" : "benchmark-interaction-rejects-sampling-failure"); + } + return passed; +} +bool Application::verifyServiceStatus() { + bool passed = true; + const auto check = [&](bool result, const char* name) { if (!result && sortingFailure.empty()) sortingFailure = name; passed = passed && result; }; + Application fixture(RunOptions{}); fixture.selectedTab = ServicesTab; + const HWND owner = CreateWindowExW(0, L"STATIC", L"", WS_POPUP, 0, 0, 420, 300, nullptr, nullptr, GetModuleHandleW(nullptr), nullptr); + check(owner != nullptr, "service-status-fixture-owner"); if (!owner) return false; + struct WindowCleanup { HWND value; ~WindowCleanup() { DestroyWindow(value); } } cleanup{owner}; + const auto control = [&](const wchar_t* type) { return CreateWindowExW(0, type, L"", WS_CHILD, 0, 0, 280, 34, owner, nullptr, GetModuleHandleW(nullptr), nullptr); }; + fixture.end = control(L"BUTTON"); fixture.fewer = control(L"BUTTON"); fixture.link = control(L"BUTTON"); fixture.serviceStatus = control(L"STATIC"); + check(fixture.end && fixture.fewer && fixture.link && fixture.serviceStatus, "service-status-fixture-controls"); + if (!fixture.end || !fixture.fewer || !fixture.link || !fixture.serviceStatus) return false; + const auto text = [&] { wchar_t value[128]{}; GetWindowTextW(fixture.serviceStatus, value, 128); return std::wstring(value); }; + const auto visible = [&] { return (GetWindowLongPtrW(fixture.serviceStatus, GWL_STYLE) & WS_VISIBLE) != 0; }; + auto inventory = std::make_shared(); fixture.current = std::make_shared(); fixture.current->inventory = inventory; + inventory->servicesError = ERROR_ACCESS_DENIED; fixture.updateFooter(); + check(visible() && text() == L"Services unavailable (error 5). Press F5 to retry.", "service-initial-failure-exposes-unavailable-status"); + inventory->services.push_back(Entry{}); fixture.current->services = inventory->services; fixture.updateFooter(); + check(visible() && text() == L"Services may be outdated (error 5). Press F5 to retry.", "service-retained-rows-expose-stale-status"); + const auto retained = fixture.current->services.data(); + inventory->servicesError = ERROR_SUCCESS; fixture.updateFooter(); + check(!visible() && text().empty() && fixture.current->services.data() == retained, "service-recovery-clears-status-with-unchanged-rows"); + inventory->servicesError = ERROR_INVALID_DATA; fixture.updateFooter(); + check(visible() && text().find(std::to_wstring(ERROR_INVALID_DATA)) != std::wstring::npos, "service-status-exposes-current-error-code"); + fixture.selectedTab = DetailsTab; fixture.updateFooter(); check(!visible() && text().empty(), "service-status-hidden-on-other-tabs"); + fixture.selectedTab = ServicesTab; fixture.compact = true; fixture.updateFooter(); check(!visible(), "service-status-hidden-in-compact-view"); + fixture.compact = false; fixture.summary = 1; fixture.updateFooter(); check(!visible(), "service-status-hidden-in-summary-view"); + fixture.summary = 0; fixture.current.reset(); fixture.updateFooter(); check(!visible() && text().empty(), "service-status-no-snapshot-is-inert"); + return passed; +} +bool Application::verifyTableExport() { + bool passed = true; + const auto check = [&](bool result, const char* name) { if (!result && sortingFailure.empty()) sortingFailure = name; passed = passed && result; }; + Application fixture(RunOptions{}); fixture.selectedTab = DetailsTab; + const HWND owner = CreateWindowExW(0, L"STATIC", L"", WS_POPUP, 0, 0, 0, 0, nullptr, nullptr, GetModuleHandleW(nullptr), nullptr); + check(owner != nullptr, "export-fixture-owner"); if (!owner) return false; + struct WindowCleanup { HWND value; ~WindowCleanup() { DestroyWindow(value); } } windowCleanup{owner}; + fixture.list = CreateWindowExW(0, WC_LISTVIEWW, L"", WS_CHILD | LVS_REPORT, 0, 0, 300, 200, owner, nullptr, GetModuleHandleW(nullptr), nullptr); + check(fixture.list != nullptr, "export-fixture-table"); if (!fixture.list) return false; + fixture.header = ListView_GetHeader(fixture.list); fixture.shown[DetailsTab] = {0, 1, 2}; + const auto defs = definitions(DetailsTab); + for (int index = 0; index < 3; ++index) { LVCOLUMNW column{}; column.mask = LVCF_TEXT | LVCF_WIDTH; column.pszText = const_cast(defs[size_t(index)].title); column.cx = 100; check(ListView_InsertColumn(fixture.list, index, &column) == index, "export-fixture-column"); } + const int order[] = {2, 0, 1}; check(ListView_SetColumnOrderArray(fixture.list, 3, order) != FALSE, "export-fixture-dragged-column-order"); + Row heading; heading.kind = RowKind::Heading; heading.cells = {{L"Category heading"}}; + Row first; first.key = L"first"; first.cells = {{L"alpha,\"quoted\"\n\u5de5\u5177 \U0001f680.exe"}, {L"42"}, {L"Running"}, {L"Hidden account"}}; + Row second; second.key = L"second"; second.cells = {{L"=1+1"}, {L"7"}, {L"Suspended"}}; + fixture.rows = {heading, second, first}; + const auto captured = fixture.tableCsv(); + const auto expected = std::string("\xef\xbb\xbf") + "\"Status\",\"Name\",\"PID\"\r\n\"Suspended\",\"'=1+1\",\"7\"\r\n\"Running\",\"alpha,\"\"quoted\"\"\n" + utf8(L"\u5de5\u5177 \U0001f680.exe") + "\",\"42\"\r\n"; + check(captured == expected, "export-visible-order-unicode-quotes-and-formula-text"); + check(captured.find("Category heading") == captured.npos && captured.find("Hidden account") == captured.npos, "export-omits-category-headings-and-hidden-columns"); + fixture.rows = {first}; fixture.shown[DetailsTab] = {2, 1, 0}; + check(captured == expected && fixture.tableCsv() != captured, "export-snapshot-survives-rows-and-columns-changing"); + fixture.compact = true; check(!fixture.canExportTable() && fixture.tableCsv().empty(), "export-unavailable-in-compact-view"); fixture.compact = false; + fixture.selectedTab = PerformanceTab; check(!fixture.canExportTable(), "export-unavailable-for-performance-graphs"); fixture.selectedTab = DetailsTab; + fixture.rows = {heading}; check(!fixture.canExportTable() && fixture.tableCsv().empty(), "export-unavailable-with-no-result-rows"); + GUID guid{}; wchar_t identifier[40]{}; std::error_code error; + const auto temporary = std::filesystem::temp_directory_path(error); + check(!error && SUCCEEDED(CoCreateGuid(&guid)) && StringFromGUID2(guid, identifier, int(std::size(identifier))) > 0, "export-fixture-temporary-name"); + if (error || !*identifier) return false; + const auto directory = temporary / (std::wstring(L"TaskManagerExport-") + identifier); + const bool created = CreateDirectoryW(directory.c_str(), nullptr) != FALSE; + check(created, "export-fixture-directory"); if (!created) return false; + struct DirectoryCleanup { std::filesystem::path path; ~DirectoryCleanup() { std::error_code error; std::filesystem::remove_all(path, error); } } directoryCleanup{directory}; + const auto path = (directory / L"\u5de5\u5177.csv").wstring(); + const auto read = [&]() { std::ifstream file{std::filesystem::path(path), std::ios::binary}; return std::string{std::istreambuf_iterator(file), std::istreambuf_iterator()}; }; + try { + writeExportFile(path, captured); check(read() == captured, "export-writes-exact-utf8-snapshot"); + const std::string large(1024 * 1024 + 17, 'x'); + writeExportFile(path, large); check(read() == large, "export-replaces-existing-file-across-write-chunks"); + bool failed = false; + { Handle lock(CreateFileW(path.c_str(), GENERIC_READ, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)); + check(lock.value != INVALID_HANDLE_VALUE, "export-fixture-locks-existing-destination"); + if (lock.value != INVALID_HANDLE_VALUE) { try { writeExportFile(path, "replacement"); } catch (const std::exception&) { failed = true; } } + } + check(failed && read() == large, "export-failed-replacement-preserves-existing-destination"); + failed = false; try { writeExportFile((directory / L"missing" / L"file.csv").wstring(), captured); } catch (const std::exception&) { failed = true; } + check(failed, "export-missing-parent-reported"); + size_t files = 0; for (const auto& entry : std::filesystem::directory_iterator(directory)) { static_cast(entry); ++files; } + check(files == 1, "export-failure-cleans-only-owned-temporary-files"); + } catch (const std::exception&) { check(false, "export-native-file-operations"); } + return passed; +} bool Application::verifyActionSnapshots() { const auto savedSample = current; const int savedTab = selectedTab, savedSelection = selectedIndex(), savedTop = ListView_GetTopIndex(list); const bool savedRebuilding = rebuilding, wasVisible = (GetWindowLongPtrW(list, GWL_STYLE) & WS_VISIBLE) != 0; @@ -300,8 +424,12 @@ bool Application::verifySorting() { benchmarkTick(); check(measured == priorMeasured && tests == priorTests, "benchmark-nested-tick-skipped"); check(verifyHistoryPersistence(), "history-persistence"); check(verifyTrayLifecycle(), "tray-lifecycle"); + check(verifyWorkerRecovery(), "sampler-worker-recovery"); + check(verifyBenchmarkSamplingHealth(), "benchmark-sampling-health"); + check(verifyServiceStatus(), "service-inventory-status"); check(verifyViewportRefresh(), "viewport-refresh"); check(verifySearch(), "scoped-search"); + check(verifyTableExport(), "table-export"); { Row previous; previous.key = L"identity"; previous.cells = {{L"1.0%", 1.01, 0}}; auto next = previous; next.cells[0].value = 1.04; next.process = 5; @@ -1125,6 +1253,33 @@ bool Application::verifyPerformanceHover() { if (fullBitmap) DeleteObject(fullBitmap); if (clippedBitmap) DeleteObject(clippedBitmap); if (full) DeleteDC(full); if (clipped) DeleteDC(clipped); if (screen) ReleaseDC(performance, screen); } + { + auto fixture = std::make_shared(*snapshot); + fixture->disks.clear(); fixture->networks.clear(); fixture->gpus.clear(); + DiskInfo disk; disk.key = L"removable"; disk.title = L"Removable test disk"; fixture->disks.push_back(disk); + NetworkInfo network; network.luid = 123; network.type = L"Test network"; fixture->networks.push_back(network); + current = fixture; performanceItemsDirty = true; selectedResource = L"disk/removable"; + check(performanceSummary().starts_with(L"Removable test disk\r\n"), "resource-copy-selected-device"); + current = std::make_shared(*fixture); current->disks.clear(); performanceItemsDirty = true; + const auto labelBuilds = performanceItemBuilds; + const auto copied = performanceSummary(); + check(copied.starts_with(L"CPU\r\n") && copied.find(L"\tProcesses:\t") != std::wstring::npos && copied.find(L"\tUp time:\t") != std::wstring::npos, "removed-resource-copy-resolves-before-paint"); + performanceKey(VK_DOWN); + check(selectedResource == L"memory", "removed-resource-down-resolves-before-paint"); + selectedResource = L"disk/removable"; performanceKey(VK_UP); + check(selectedResource == L"cpu", "removed-resource-up-resolves-before-paint"); + selectedResource = L"disk/removable"; performanceKey(VK_END); + check(selectedResource == L"net/123" && performanceSummary().starts_with(L"Test network\r\n"), "removed-resource-end-selects-last"); + performanceKey(VK_HOME); check(selectedResource == L"cpu", "resource-home-selects-first"); + selectedResource = L"disk/removable"; sidebarScroll = scale(820); + RedrawWindow(performance, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW); + check(selectedResource == L"cpu" && sidebarScroll == 0, "removed-resource-paint-reveals-fallback"); + check(performanceItemBuilds == labelBuilds + 1, "resource-fallback-reuses-cached-labels"); + current.reset(); performanceItemsDirty = true; selectedResource = L"disk/removable"; + performanceKey(VK_DOWN); + check(performanceSummary().empty() && selectedResource == L"disk/removable", "resource-no-sample-is-inert"); + current = snapshot; performanceItemsDirty = true; selectedResource = L"cpu"; + } setSummary(1); RedrawWindow(performance, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW); const auto paints = performancePaints; for (int repeat = 0; repeat < 1000; ++repeat) SendMessageW(performance, WM_MOUSEMOVE, 0, MAKELPARAM(scale(30), scale(30))); @@ -1150,7 +1305,7 @@ void Application::benchmarkTick() { if (keyboardPassed && options.tab >= 0 && !options.tabSpam) selectTab(options.tab); if (keyboardPassed && options.minimized && !IsIconic(window)) ShowWindow(window, SW_SHOWMINNOACTIVE); if (!measured && keyboardPassed && (!options.minimized || interval == 0 || (minimized && current && current->elapsed >= 3.5)) && std::chrono::duration(Clock::now() - started).count() >= options.warmup) { - measured = true; measurementStarted = Clock::now(); LARGE_INTEGER counter{}; QueryPerformanceCounter(&counter); measurementCounter = counter.QuadPart; FILETIME created{}, exited{}; GetProcessTimes(GetCurrentProcess(), &created, &exited, &cpuStartKernel, &cpuStartUser); GetProcessHandleCount(GetCurrentProcess(), &baselineHandles); + beginSamplingMeasurement(); LARGE_INTEGER counter{}; QueryPerformanceCounter(&counter); measurementCounter = counter.QuadPart; FILETIME created{}, exited{}; GetProcessTimes(GetCurrentProcess(), &created, &exited, &cpuStartKernel, &cpuStartUser); GetProcessHandleCount(GetCurrentProcess(), &baselineHandles); baselineGdi = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS); baselineUser = GetGuiResources(GetCurrentProcess(), GR_USEROBJECTS); deliveryTimes.clear(); updateTimes.clear(); paintTimes.clear(); samplerTimes.clear(); inputTimes.clear(); sortTimes.clear(); tests = sortRequests = fullRebuilds = sortPasses = 0; for (auto& times : stageTimes) times.clear(); sampleCadenceTimes.clear(); partialTableUpdates = invalidatedRows = resourceUpdates = 0; @@ -1194,15 +1349,27 @@ static void writeMetric(Report& stream, const char* name, std::vector va std::sort(values.begin(), values.end()); const double total = std::accumulate(values.begin(), values.end(), 0.0); stream << '"' << name << "\":{\"count\":" << values.size() << ",\"meanMilliseconds\":" << (values.empty() ? 0 : total / double(values.size())) << ",\"p95Milliseconds\":" << (values.empty() ? 0 : values[size_t(std::ceil(double(values.size()) * .95)) - 1]) << ",\"maxMilliseconds\":" << (values.empty() ? 0 : values.back()) << '}'; } +void Application::beginSamplingMeasurement() { + // Publish the boundary and capture failure state together with the worker's + // completed iteration, so recovered warmup failures do not taint measurement. + std::lock_guard lock(workerMutex); + samplingFailureBaseline = samplingFailures.load(); samplingHealthyAtStart = !samplingFailed; + measured = true; measurementStarted = Clock::now(); +} +bool Application::samplingMeasurementHealthy() { + std::lock_guard lock(workerMutex); + return measured && samplingHealthyAtStart && !samplingFailed && samplingFailures == samplingFailureBaseline; +} void Application::writeBenchmark() { const bool coalescingPassed = !options.sortSpam || (sortRequests >= 128 && !sortTimes.empty() && sortTimes.size() * 16 < sortRequests); const bool tabSpamPassed = !options.tabSpam || (tabRequests >= 128 && tabCommits > 0 && tabCommits * 16 < tabRequests); - exitCode = updates > 0 && keyboardPassed && sortingPassed && coalescingPassed && tabSpamPassed ? 0 : 1; + const bool samplingHealthy = samplingMeasurementHealthy(); + exitCode = updates > 0 && keyboardPassed && sortingPassed && coalescingPassed && tabSpamPassed && samplingHealthy ? 0 : 1; if (options.output.empty()) return; Report stream{options.output}; FILETIME created{}, exited{}, kernel{}, user{}; GetProcessTimes(GetCurrentProcess(), &created, &exited, &kernel, &user); PROCESS_MEMORY_COUNTERS_EX memory{sizeof(memory)}; GetProcessMemoryInfo(GetCurrentProcess(), reinterpret_cast(&memory), sizeof(memory)); DWORD handles = 0; GetProcessHandleCount(GetCurrentProcess(), &handles); const double elapsed = std::max(.001, std::chrono::duration(Clock::now() - (measured ? measurementStarted : started)).count()); const double cpu = double(ticks(kernel) + ticks(user) - ticks(cpuStartKernel) - ticks(cpuStartUser)) / 10000000; - stream << "{\"native\":true,\"passed\":" << (exitCode == 0 ? "true" : "false") << ",\"elapsedSeconds\":" << elapsed << ",\"firstSampleMilliseconds\":" << (firstSample == Time{} ? 0 : std::chrono::duration(firstSample - started).count()) << ",\"processCpuPercentOneCore\":" << cpu * 100 / elapsed << ",\"privateBytes\":" << memory.PrivateUsage << ",\"handles\":" << handles << ",\"updates\":" << updates << ",\"exercises\":" << tests << ",\"keyboardPassed\":" << (keyboardPassed ? "true" : "false") << ','; + stream << "{\"native\":true,\"passed\":" << (exitCode == 0 ? "true" : "false") << ",\"samplingHealthy\":" << (samplingHealthy ? "true" : "false") << ",\"elapsedSeconds\":" << elapsed << ",\"firstSampleMilliseconds\":" << (firstSample == Time{} ? 0 : std::chrono::duration(firstSample - started).count()) << ",\"processCpuPercentOneCore\":" << cpu * 100 / elapsed << ",\"privateBytes\":" << memory.PrivateUsage << ",\"handles\":" << handles << ",\"updates\":" << updates << ",\"exercises\":" << tests << ",\"keyboardPassed\":" << (keyboardPassed ? "true" : "false") << ','; stream << "\"workingSetBytes\":" << memory.WorkingSetSize << ",\"peakWorkingSetBytes\":" << memory.PeakWorkingSetSize << ','; stream << "\"warmupSeconds\":" << options.warmup << ",\"handleDelta\":" << int64_t(handles) - baselineHandles << ",\"gdiDelta\":" << int64_t(GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS)) - baselineGdi << ",\"userDelta\":" << int64_t(GetGuiResources(GetCurrentProcess(), GR_USEROBJECTS)) - baselineUser << ','; stream << "\"sortingFailure\":"; stream.jsonString(sortingFailure) << ','; diff --git a/tests/csv.cpp b/tests/csv.cpp new file mode 100644 index 0000000..90c99dd --- /dev/null +++ b/tests/csv.cpp @@ -0,0 +1,28 @@ +#include "../native/csv.hpp" +#include +#include + +int main() { + int checks = 0, failed = 0; + const auto check = [&](bool result, const char* name) { ++checks; if (!result) { ++failed; std::cerr << name << '\n'; } }; + const auto field = [](std::string_view value) { taskmgr::Csv csv; csv.field(value); csv.endRow(); return csv.take(); }; + const std::string bom = "\xef\xbb\xbf"; + check(field("") == bom + "\"\"\r\n", "empty field"); + check(field("normal.exe") == bom + "\"normal.exe\"\r\n", "ordinary text"); + check(field("1,234") == bom + "\"1,234\"\r\n", "embedded delimiter"); + check(field("a\"b\"c") == bom + "\"a\"\"b\"\"c\"\r\n", "embedded quotes"); + check(field("line one\r\nline two") == bom + "\"line one\r\nline two\"\r\n", "embedded newline"); + check(field("Caf\xc3\xa9 \xf0\x9f\x9a\x80.exe") == bom + "\"Caf\xc3\xa9 \xf0\x9f\x9a\x80.exe\"\r\n", "UTF-8 remains intact"); + for (const auto value : {"=1+1", "+SUM(A1)", "-1+2", "@SUM(A1)", " \t=1+1", "\ttext", "\rtext", "\ntext", "\xef\xbc\x9d" "1+1", "\xef\xbc\x8b" "1+1", "\xef\xbc\x8d" "1+1", "\xef\xbc\xa0" "SUM(A1)"}) { + check(field(value) == bom + "\"'" + value + "\"\r\n", "formula-like field remains text"); + } + check(field(" process-name + suffix") == bom + "\" process-name + suffix\"\r\n", "internal operators are literal"); + check(field(" ") == bom + "\" \"\r\n", "whitespace preserved"); + check(field(std::string("\0=1+1", 5)) == bom + "\"'\xef\xbf\xbd=1+1\"\r\n", "NUL replaced without hiding formula"); + taskmgr::Csv table; table.field("Name"); table.field("PID"); table.endRow(); table.field("first.exe"); table.field("42"); table.endRow(); table.field("second.exe"); table.field(""); table.endRow(); + const auto output = table.take(); + check(output == bom + "\"Name\",\"PID\"\r\n\"first.exe\",\"42\"\r\n\"second.exe\",\"\"\r\n", "multiple rows keep column boundaries"); + check(output.find(bom, bom.size()) == output.npos, "one encoding marker per file"); + std::cout << checks << " CSV checks, " << failed << " failed\n"; + return failed ? 1 : 0; +} diff --git a/tests/test_tooling.py b/tests/test_tooling.py index 7db3b9c..8cda3a0 100644 --- a/tests/test_tooling.py +++ b/tests/test_tooling.py @@ -5,6 +5,7 @@ import json import os from pathlib import Path +import struct import subprocess import sys import tempfile @@ -149,6 +150,138 @@ def start(*args, **options): self.assertIsNotNone(processes[0].poll()) self.assertFalse((self.directory / "timeout.json").exists()) + def test_screenshot_capture_requires_fresh_complete_light_and_dark_pngs(self): + arguments = taskmgr.parser().parse_args([ + "screenshots", "--executable", str(self.executable), "--output-dir", str(self.directory)]) + png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + struct.pack(">II", 800, 600) + b"\x00" * 9 + names = [f"{index:02d}-{view}.png" for index, view in enumerate(taskmgr.SCREENSHOT_VIEWS)] + + def capture(command, **options): + directory = Path(command[2]) + self.assertEqual(directory.name == "dark", "--dark" in command) + for name in names: + self.assertFalse((directory / name).exists()) + (directory / name).write_bytes(png) + self.assertEqual(options["timeout"], 120) + return subprocess.CompletedProcess(command, 0) + + with patch.object(taskmgr.subprocess, "run", side_effect=capture): + taskmgr.screenshots(arguments) + report = json.loads((self.directory / "screenshots.json").read_text(encoding="utf-8")) + self.assertEqual(len(report["images"]), 38) + self.assertEqual({item["theme"] for item in report["images"]}, {"light", "dark"}) + self.assertTrue(all(item["width"] == 800 and item["height"] == 600 for item in report["images"])) + with patch.object(taskmgr.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)): + with self.assertRaises(OSError): + taskmgr.screenshots(arguments) + self.assertFalse((self.directory / "screenshots.json").exists()) + self.assertEqual(list((self.directory / "dark").glob("*.png")), []) + + def test_invalid_screenshot_header_or_dimensions_are_rejected(self): + path = self.directory / "invalid.png" + for contents in (b"", b"not an image", b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + b"\x00" * 17): + with self.subTest(contents=contents): + path.write_bytes(contents) + with self.assertRaises(ValueError): + taskmgr.png_identity(path) + + def network_arguments(self): + return taskmgr.parser().parse_args([ + "network-smoke", "--executable", str(self.executable), "--output-dir", str(self.directory)]) + + def network_report(self, reason="", trace_status=0): + skipped = bool(reason) + return {"passed": not skipped, "skipped": skipped, "reason": reason, + "traceStatus": trace_status, "socketStatus": 0, + "sentBytes": 0 if skipped else 32768, "receivedBytes": 0 if skipped else 32768, + "sentDatagrams": 0 if skipped else 32, "receivedDatagrams": 0 if skipped else 32, + "samples": 0 if skipped else 2, "peakBytesPerSecond": 0 if skipped else 15000.5, + "deliveredEvents": 0 if skipped else 100, "decodedEvents": 0 if skipped else 80, + "ownUdpEvents": 0 if skipped else 64, "elapsedMilliseconds": 10.5} + + def test_network_smoke_retains_fresh_verified_evidence_and_bounded_timeout(self): + for name in ("network-smoke.json", "result.json"): + (self.directory / name).write_text('{"status":"stale"}', encoding="utf-8") + fake = self.fake_run(json.dumps(self.network_report())) + + def run(command, **options): + self.assertFalse((self.directory / "result.json").exists()) + self.assertEqual(options["timeout"], 30) + self.assertEqual(command[1], "--network-smoke") + return fake(command, **options) + + with patch.object(taskmgr.subprocess, "run", side_effect=run): + taskmgr.network_smoke(self.network_arguments()) + evidence = json.loads((self.directory / "result.json").read_text(encoding="utf-8")) + self.assertEqual(evidence["status"], "verified") + self.assertEqual(evidence["exitCode"], 0) + self.assertEqual(evidence["executable"]["sha256"], taskmgr.digest(self.executable)) + self.assertIs(taskmgr.read_json_report(evidence["report"])["passed"], True) + self.assertIn("32 owned UDP datagrams", self.output.getvalue()) + + def test_network_smoke_accepts_only_documented_pre_traffic_trace_skips(self): + for reason, trace_status in (("trace_permission", 5), ("trace_permission", 1314), + ("trace_session_unavailable", 50), ("trace_session_unavailable", 1450)): + with self.subTest(reason=reason, status=trace_status), \ + patch.object(taskmgr.subprocess, "run", side_effect=self.fake_run( + json.dumps(self.network_report(reason, trace_status)), returncode=77)): + taskmgr.network_smoke(self.network_arguments()) + evidence = json.loads((self.directory / "result.json").read_text(encoding="utf-8")) + self.assertEqual(evidence["status"], "skipped") + self.assertEqual(evidence["exitCode"], 77) + self.assertNotIn("error", evidence) + self.assertIn("Live network attribution remains unverified", self.output.getvalue()) + + def test_network_smoke_rejects_forged_skips_and_incomplete_or_nonfinite_traffic(self): + invalid = [] + for changes in ({"traceStatus": 0}, {"reason": "no_events"}, {"traceStatus": 50}, + {"socketStatus": 10013}, {"samples": 1}, {"sentBytes": 1}, + {"receivedDatagrams": 1}, {"ownUdpEvents": 1}, {"peakBytesPerSecond": 1}, + {"passed": True}, {"skipped": "true"}): + invalid.append(({**self.network_report("trace_permission", 5), **changes}, 77)) + for changes in ({"passed": 1}, {"skipped": True}, {"reason": "no_events"}, + {"sentDatagrams": 31}, {"receivedBytes": 32767}, {"samples": 1}, + {"samples": True}, {"socketStatus": 1}, {"traceStatus": 1}, + {"ownUdpEvents": 0}, {"decodedEvents": 101}, {"ownUdpEvents": 81}, + {"deliveredEvents": -1}, {"receivedBytes": "32768"}, + {"peakBytesPerSecond": 0}, {"peakBytesPerSecond": float("nan")}, + {"elapsedMilliseconds": float("inf")}, {"error": "failure"}): + invalid.append(({**self.network_report(), **changes}, 0)) + invalid += [(self.network_report(), 77), (self.network_report("trace_permission", 5), 0), + (self.network_report(), 1), (self.network_report(), True), ({}, 77)] + for report, code in invalid: + with self.subTest(report=report, code=code), self.assertRaises(ValueError): + taskmgr.validate_network_smoke(report, code) + + def test_network_smoke_failures_invalidate_old_success_and_retain_failure(self): + for options in ({"payload": None}, {"payload": "invalid JSON"}, + {"payload": '{"passed":false,"passed":true}'}, + {"payload": json.dumps(self.network_report()), "returncode": 1}, + {"payload": json.dumps(self.network_report()), "returncode": 77}, + {"payload": None, "timeout": True}): + with self.subTest(options=options): + for name in ("network-smoke.json", "result.json"): + (self.directory / name).write_text('{"status":"stale"}', encoding="utf-8") + with patch.object(taskmgr.subprocess, "run", side_effect=self.fake_run(**options)): + with self.assertRaises(ValueError): + taskmgr.network_smoke(self.network_arguments()) + evidence = json.loads((self.directory / "result.json").read_text(encoding="utf-8")) + self.assertEqual(evidence["status"], "failed") + self.assertIn("error", evidence) + self.assertNotIn("stale", json.dumps(evidence)) + self.assertFalse((self.directory / "network-smoke.json").exists()) + + def test_network_smoke_missing_executable_removes_stale_native_report(self): + (self.directory / "network-smoke.json").write_text(json.dumps(self.network_report()), encoding="utf-8") + self.executable.unlink() + with patch.object(taskmgr.subprocess, "run") as run, self.assertRaises(OSError): + taskmgr.network_smoke(self.network_arguments()) + run.assert_not_called() + self.assertFalse((self.directory / "network-smoke.json").exists()) + evidence = json.loads((self.directory / "result.json").read_text(encoding="utf-8")) + self.assertEqual(evidence["status"], "failed") + self.assertIsNone(evidence["exitCode"]) + def ui_report(self): report = {"passed": True, "firstSampleMilliseconds": 100} for name in ("uiUpdate", "cachedSort", "tabSwitch", "messageQueue", "letterNavigation"): diff --git a/tools/production.py b/tools/production.py index 7d069e4..fe556ec 100644 --- a/tools/production.py +++ b/tools/production.py @@ -12,14 +12,14 @@ DIAGNOSTIC_COMMANDS = ( "--self-test", "--test-child", "--component-benchmark", "--benchmark", - "--ui-benchmark", "--startup-inventory", "--screenshots", + "--ui-benchmark", "--startup-inventory", "--screenshots", "--network-smoke", ) DIAGNOSTIC_MARKERS = ( "startup_fixture_created", "cachedSort", "keyboardPassed", "tabSpamPassed", "columnResize", "scrollbar-rebuild-no-intermediate-paints", "scrollbar-rebuild-explicit-capture", "caption-immediate-theme-pixels", "--tab-spam", "--self-test", "--component-benchmark", "--ui-benchmark", - "--screenshots", "TaskManagerNativeTests", + "--screenshots", "--network-smoke", "TaskManagerNativeTests", ) diff --git a/tools/taskmgr.py b/tools/taskmgr.py index cf690e9..121e0af 100644 --- a/tools/taskmgr.py +++ b/tools/taskmgr.py @@ -13,6 +13,7 @@ import re import shutil import statistics +import struct import subprocess import sys import tempfile @@ -25,6 +26,16 @@ DEVELOPER = ARTIFACTS / "native-tests/Release/TaskManager.exe" PRODUCTION = ARTIFACTS / "native-build/Release/TaskManager.exe" RELEASE_FILES = ("release.json", "SHA256SUMS.txt", "RELEASE_NOTES.md") +SCREENSHOT_VIEWS = ( + "processes", "processes-expanded", "processes-background", "processes-ungrouped", + "performance-cpu", "performance-cpu-logical", "performance-memory", "performance-disk", + "performance-network", "performance-gpu", "app-history", "startup", "users", "details", + "services", "compact", "summary", "search-details", "search-no-results", +) +NETWORK_SMOKE_SKIP_STATUSES = { + "trace_permission": {5, 1314}, + "trace_session_unavailable": {50, 1450}, +} @dataclass(frozen=True) @@ -105,16 +116,21 @@ def finite_float(value): return result -def read_report(path): +def read_json_report(path): path = Path(path) if not path.is_file(): - raise ValueError(f"Benchmark produced no fresh report: {path}") + raise ValueError(f"Diagnostic produced no fresh report: {path}") try: report = json.loads(path.read_text(encoding="utf-8-sig"), object_pairs_hook=unique_object, parse_constant=reject_constant, parse_float=finite_float) except (ValueError, UnicodeError) as error: - raise ValueError(f"Benchmark returned invalid JSON: {path}: {error}") from error + raise ValueError(f"Diagnostic returned invalid JSON: {path}: {error}") from error + return report + + +def read_report(path): + report = read_json_report(path) if type(report) is not dict or report.get("passed") is not True or report.get("error") is not None: raise ValueError(f"Benchmark did not report success: {path}") return report @@ -203,6 +219,102 @@ def benchmark(arguments): write_json(directory / "suite.json", results) +def png_identity(path): + contents = Path(path).read_bytes() + if len(contents) < 33 or contents[:16] != b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR": + raise ValueError(f"Screenshot has no valid PNG header: {path}") + width, height = struct.unpack(">II", contents[16:24]) + if not 0 < width <= 32768 or not 0 < height <= 32768: + raise ValueError(f"Screenshot has invalid dimensions: {path}") + return {**identity(path), "width": width, "height": height} + + +def screenshots(arguments): + names = [f"{index:02d}-{name}.png" for index, name in enumerate(SCREENSHOT_VIEWS)] + directory = clear_outputs(arguments.output_dir, ["screenshots.json"]) + # Clear both themes before starting, so a failed first capture cannot leave + # old dark-mode images that appear to belong to this attempt. + for theme in ("light", "dark"): + clear_outputs(directory / theme, names) + executable = identity(arguments.executable)["path"] + captures = [] + for theme in ("light", "dark"): + target = directory / theme + flags = ["--dark"] if theme == "dark" else [] + result = subprocess.run([executable, "--screenshots", str(target), *flags], + timeout=120, check=False, **production.process_options()) + if result.returncode: + raise ValueError(f"{theme} screenshot capture failed with exit code {result.returncode}.") + for name in names: + captures.append({"theme": theme, "view": Path(name).stem, **png_identity(target / name)}) + print(f"Captured {len(names)} {theme} screenshots; visual review is still required.", flush=True) + write_json(directory / "screenshots.json", {"executable": identity(executable), "images": captures}) + + +def validate_network_smoke(report, returncode): + """Accept verified owned traffic or the native smoke test's exact skip contract.""" + if type(report) is not dict or report.get("error") is not None: + raise ValueError("Network smoke must provide an outcome object without an explicit error.") + if type(report.get("passed")) is not bool or type(report.get("skipped")) is not bool \ + or not isinstance(report.get("reason"), str): + raise ValueError("Network smoke requires boolean outcomes and a reason string.") + counts = ("sentBytes", "receivedBytes", "sentDatagrams", "receivedDatagrams", "samples", + "deliveredEvents", "decodedEvents", "ownUdpEvents") + for name in ("traceStatus", "socketStatus", *counts): + value = report.get(name) + limit = 0xffffffff if name in ("traceStatus", "socketStatus") else 0xffffffffffffffff + if type(value) is not int or not 0 <= value <= limit: + raise ValueError(f"Network smoke requires a nonnegative integer {name} within its native range.") + peak = number(report.get("peakBytesPerSecond"), "peakBytesPerSecond") + number(report.get("elapsedMilliseconds"), "elapsedMilliseconds") + if not report["deliveredEvents"] >= report["decodedEvents"] >= report["ownUdpEvents"]: + raise ValueError("Network smoke event counts are inconsistent.") + if type(returncode) is not int: + raise ValueError("Network smoke requires the actual native process exit code.") + if returncode == 0: + if not (report["passed"] and not report["skipped"] and report["reason"] == "" and + report["traceStatus"] == report["socketStatus"] == 0 and + report["sentDatagrams"] == report["receivedDatagrams"] == 32 and + report["sentBytes"] == report["receivedBytes"] == 32768 and + report["samples"] >= 2 and report["ownUdpEvents"] > 0 and peak > 0): + raise ValueError("Network smoke did not verify the complete owned UDP transfer and attribution.") + return "verified" + if returncode == 77: + allowed = NETWORK_SMOKE_SKIP_STATUSES.get(report["reason"], set()) + if not (not report["passed"] and report["skipped"] and report["traceStatus"] in allowed and + report["socketStatus"] == 0 and peak == 0 and + all(report[name] == 0 for name in counts if name not in ("deliveredEvents", "decodedEvents"))): + raise ValueError("Network smoke exit 77 did not match a recognized pre-traffic trace limitation.") + return "skipped" + raise ValueError(f"Network smoke failed with exit code {returncode}; inspect the retained native report.") + + +def network_smoke(arguments): + directory = clear_outputs(arguments.output_dir, ["network-smoke.json", "result.json"]) + path = directory / "network-smoke.json" + evidence = {"status": "failed", "exitCode": None, "report": str(path)} + try: + evidence["executable"] = identity(arguments.executable) + try: + result = subprocess.run([evidence["executable"]["path"], "--network-smoke", "--output", str(path)], + timeout=arguments.timeout, check=False, **production.process_options()) + except subprocess.TimeoutExpired as error: + raise ValueError("Network smoke exceeded its deadline; only its owned diagnostic process was stopped.") from error + evidence["exitCode"] = result.returncode + report = read_json_report(path) + evidence["status"] = validate_network_smoke(report, result.returncode) + except (OSError, ValueError, subprocess.SubprocessError) as error: + evidence["error"] = str(error) + write_json(directory / "result.json", evidence) + raise + write_json(directory / "result.json", evidence) + if evidence["status"] == "verified": + print("Network smoke verified 32 owned UDP datagrams (32,768 bytes) and positive process attribution.", flush=True) + else: + print(f"Network smoke explicitly skipped: {report['reason']} (trace status {report['traceStatus']}). " + "Live network attribution remains unverified.", flush=True) + + def comparison_cases(arguments): ui = ("--ui-benchmark", "--idle", "--warmup", str(arguments.warmup_seconds), "--seconds", str(arguments.seconds)) @@ -440,6 +552,15 @@ def parser(): benchmark_parser.add_argument("--output-dir", type=Path, default=ARTIFACTS / "benchmark-suite") benchmark_parser.add_argument("--enforce-budgets", action="store_true") benchmark_parser.set_defaults(function=benchmark) + screenshot_parser = sub.add_parser("screenshots", help="capture all native views in light and dark themes for visual review") + screenshot_parser.add_argument("--executable", type=Path, default=DEVELOPER) + screenshot_parser.add_argument("--output-dir", type=Path, default=ARTIFACTS / "screenshots") + screenshot_parser.set_defaults(function=screenshots) + network_parser = sub.add_parser("network-smoke", help="verify owned UDP traffic or report a recognized trace limitation") + network_parser.add_argument("--executable", type=Path, default=DEVELOPER) + network_parser.add_argument("--output-dir", type=Path, default=ARTIFACTS / "network-smoke") + network_parser.add_argument("--timeout", type=bounded_integer(5, 120), default=30) + network_parser.set_defaults(function=network_smoke) comparison = sub.add_parser("compare", help="run alternating baseline/candidate measurements and summarize medians and ranges") comparison.add_argument("--baseline-executable", type=Path, required=True) comparison.add_argument("--executable", type=Path, default=DEVELOPER) From 874e46d9d2ed4d92677313ace67c6bd000989e56 Mon Sep 17 00:00:00 2001 From: Proton <72932384+ProtonDev-sys@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:17:44 +0100 Subject: [PATCH 2/2] Keep production recovery notification builds warning-free --- native/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/main.cpp b/native/main.cpp index 17de2e4..3e958e8 100644 --- a/native/main.cpp +++ b/native/main.cpp @@ -504,7 +504,7 @@ void Application::worker() { int retry = 0; bool postedFailure = false; const auto startupReady = [&] { return source && source->startupReady(); }; - const auto postStatus = [&](bool failed) { + const auto postStatus = [&]([[maybe_unused]] bool failed) { #ifdef TASKMGR_DIAGNOSTICS if (samplingStatusNotification) return samplingStatusNotification(failed); #endif