mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:05:20 +00:00
Resurrect dead Noise vector tests; add CI perf floors; make tests hermetic
Package.swift's .process("Noise") resource claim silently excluded all
of bitchatTests/Noise/ from compilation since Oct 2025 - including a
complete official-vector runner (cacophony + snow XX transcripts,
transport messages, handshake hash, byte-identical to upstream).
Narrowing the resource to the JSON file and loading via Bundle.module
brings 51 Noise tests back to life, with a guard asserting each
vector's protocol name matches the app's.
CI gains a performance floor gate: perf-floors.json carries deliberately
generous floors (~25% of measured throughput) that catch algorithmic
regressions without flaking on runner variance; PERF lines reach the
gate via an O_APPEND side-channel file since swift test --parallel
swallows passing tests' stdout.
Tests are now hermetic: FavoritesPersistenceService uses an in-memory
keychain under test (fixes the securityd hang that blocked pipeline
benchmarks locally) and read-receipt persistence uses a wiped scratch
UserDefaults suite instead of .standard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,8 +39,22 @@ jobs:
|
|||||||
${{ runner.os }}-${{ matrix.name }}-
|
${{ runner.os }}-${{ matrix.name }}-
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
|
# BITCHAT_PERF_LOG captures the PERF[...] lines that
|
||||||
|
# PerformanceBaselineTests reports (swift test --parallel swallows
|
||||||
|
# stdout of passing tests, so the floor gate reads this file instead).
|
||||||
|
env:
|
||||||
|
BITCHAT_PERF_LOG: ${{ github.workspace }}/perf-output.log
|
||||||
run: swift test --parallel --quiet --enable-code-coverage --package-path ${{ matrix.path }}
|
run: swift test --parallel --quiet --enable-code-coverage --package-path ${{ matrix.path }}
|
||||||
|
|
||||||
|
# Order-of-magnitude performance regression gate (app tests only — the
|
||||||
|
# package matrix entries write no PERF lines and the gate would skip
|
||||||
|
# anyway). Floors are deliberately generous (~25% of healthy local
|
||||||
|
# throughput, see bitchatTests/Performance/perf-floors.json) so this
|
||||||
|
# catches algorithmic regressions, never runner variance.
|
||||||
|
- name: Performance floor gate
|
||||||
|
if: matrix.name == 'app'
|
||||||
|
run: ./scripts/check-perf-floors.sh perf-output.log
|
||||||
|
|
||||||
# Informational only: surfaces per-file and total line coverage in the
|
# Informational only: surfaces per-file and total line coverage in the
|
||||||
# job log so coverage trends are visible on every PR. No thresholds —
|
# job log so coverage trends are visible on every PR. No thresholds —
|
||||||
# this must never be the reason a build goes red.
|
# this must never be the reason a build goes red.
|
||||||
|
|||||||
+8
-2
@@ -53,11 +53,17 @@ let package = Package(
|
|||||||
path: "bitchatTests",
|
path: "bitchatTests",
|
||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"Info.plist",
|
||||||
"README.md"
|
"README.md",
|
||||||
|
// CI perf gate data (read by scripts/check-perf-floors.sh),
|
||||||
|
// not a test resource.
|
||||||
|
"Performance/perf-floors.json"
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.process("Localization"),
|
.process("Localization"),
|
||||||
.process("Noise")
|
// Only the vector fixture: declaring the whole "Noise"
|
||||||
|
// directory would claim its .swift test files as resources
|
||||||
|
// and silently drop them from compilation.
|
||||||
|
.process("Noise/NoiseTestVectors.json")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -34,7 +34,17 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
static let shared = FavoritesPersistenceService()
|
static let shared = FavoritesPersistenceService()
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
/// Default keychain for the `shared` singleton. Under test this is an
|
||||||
|
/// in-memory keychain so touching `shared` never blocks on securityd
|
||||||
|
/// (`SecItemCopyMatching` can hang in test environments) and never reads
|
||||||
|
/// or writes the developer's real keychain. Production behavior is
|
||||||
|
/// unchanged. Tests that need their own instance keep injecting a mock
|
||||||
|
/// via `init(keychain:)`.
|
||||||
|
private nonisolated static func makeDefaultKeychain() -> KeychainManagerProtocol {
|
||||||
|
TestEnvironment.isRunningTests ? PreviewKeychainManager() : KeychainManager()
|
||||||
|
}
|
||||||
|
|
||||||
|
init(keychain: KeychainManagerProtocol = FavoritesPersistenceService.makeDefaultKeychain()) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// TestEnvironment.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Process-level test-environment detection for singletons that must swap a
|
||||||
|
/// real OS-backed dependency (keychain, persistent defaults, notifications)
|
||||||
|
/// for an in-memory one under test. Mirrors the detection already used by
|
||||||
|
/// `NotificationService` and `LocationStateManager`.
|
||||||
|
enum TestEnvironment {
|
||||||
|
/// True when running under XCTest / Swift Testing or in CI.
|
||||||
|
static let isRunningTests: Bool = {
|
||||||
|
let env = ProcessInfo.processInfo.environment
|
||||||
|
return NSClassFromString("XCTestCase") != nil ||
|
||||||
|
env["XCTestConfigurationFilePath"] != nil ||
|
||||||
|
env["XCTestBundlePath"] != nil ||
|
||||||
|
env["GITHUB_ACTIONS"] != nil ||
|
||||||
|
env["CI"] != nil
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -406,6 +406,23 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// Single-writer: mutate only via `setPublicBatching(_:)` below.
|
// Single-writer: mutate only via `setPublicBatching(_:)` below.
|
||||||
@Published private(set) var isBatchingPublic: Bool = false
|
@Published private(set) var isBatchingPublic: Bool = false
|
||||||
|
|
||||||
|
// Backing store for `sentReadReceipts` persistence. `.standard` in
|
||||||
|
// production; injectable so tests can use a scratch suite that does not
|
||||||
|
// leak state between runs.
|
||||||
|
let readReceiptsDefaults: UserDefaults
|
||||||
|
|
||||||
|
/// Default read-receipt persistence store. Production uses `.standard`.
|
||||||
|
/// Under test, a dedicated scratch suite is used instead — wiped at first
|
||||||
|
/// use per process — so back-to-back local test runs never see each
|
||||||
|
/// other's persisted receipts (and tests never pollute `.standard`).
|
||||||
|
static let defaultReadReceiptsDefaults: UserDefaults = {
|
||||||
|
guard TestEnvironment.isRunningTests else { return .standard }
|
||||||
|
let suiteName = "chat.bitchat.tests.readReceipts"
|
||||||
|
guard let scratch = UserDefaults(suiteName: suiteName) else { return .standard }
|
||||||
|
scratch.removePersistentDomain(forName: suiteName)
|
||||||
|
return scratch
|
||||||
|
}()
|
||||||
|
|
||||||
// Track sent read receipts to avoid duplicates (persisted across launches)
|
// Track sent read receipts to avoid duplicates (persisted across launches)
|
||||||
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
|
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
|
||||||
var sentReadReceipts: Set<String> = [] { // messageID set
|
var sentReadReceipts: Set<String> = [] { // messageID set
|
||||||
@@ -413,9 +430,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// Only persist if there are changes
|
// Only persist if there are changes
|
||||||
guard oldValue != sentReadReceipts else { return }
|
guard oldValue != sentReadReceipts else { return }
|
||||||
|
|
||||||
// Persist to UserDefaults whenever it changes (no manual synchronize/verify re-read)
|
// Persist whenever it changes (no manual synchronize/verify re-read)
|
||||||
if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) {
|
if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) {
|
||||||
UserDefaults.standard.set(data, forKey: "sentReadReceipts")
|
readReceiptsDefaults.set(data, forKey: "sentReadReceipts")
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.error("❌ Failed to encode read receipts for persistence", category: .session)
|
SecureLogger.error("❌ Failed to encode read receipts for persistence", category: .session)
|
||||||
}
|
}
|
||||||
@@ -763,7 +780,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
conversations: ConversationStore? = nil,
|
conversations: ConversationStore? = nil,
|
||||||
peerIdentityStore: PeerIdentityStore? = nil,
|
peerIdentityStore: PeerIdentityStore? = nil,
|
||||||
locationPresenceStore: LocationPresenceStore? = nil,
|
locationPresenceStore: LocationPresenceStore? = nil,
|
||||||
locationManager: LocationChannelManager = .shared
|
locationManager: LocationChannelManager = .shared,
|
||||||
|
readReceiptsDefaults: UserDefaults? = nil
|
||||||
) {
|
) {
|
||||||
let conversations = conversations ?? ConversationStore()
|
let conversations = conversations ?? ConversationStore()
|
||||||
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
||||||
@@ -790,7 +808,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
self.autocompleteService = services.autocompleteService
|
self.autocompleteService = services.autocompleteService
|
||||||
self.deduplicationService = services.deduplicationService
|
self.deduplicationService = services.deduplicationService
|
||||||
self.publicMessagePipeline = services.publicMessagePipeline
|
self.publicMessagePipeline = services.publicMessagePipeline
|
||||||
self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts()
|
let readReceiptsDefaults = readReceiptsDefaults ?? Self.defaultReadReceiptsDefaults
|
||||||
|
self.readReceiptsDefaults = readReceiptsDefaults
|
||||||
|
self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts(userDefaults: readReceiptsDefaults)
|
||||||
|
|
||||||
// Republish on every store change so SwiftUI observers of the
|
// Republish on every store change so SwiftUI observers of the
|
||||||
// view model refresh. This replaces the UI-update role of the old
|
// view model refresh. This replaces the UI-update role of the old
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ import BitFoundation
|
|||||||
|
|
||||||
// MARK: - Test Vector Support
|
// MARK: - Test Vector Support
|
||||||
|
|
||||||
|
/// Official Noise test vectors (NoiseTestVectors.json) for
|
||||||
|
/// `Noise_XX_25519_ChaChaPoly_SHA256` — the exact protocol this app speaks
|
||||||
|
/// (see `NoiseProtocolName` / `NoisePattern.XX`). Embedded byte-for-byte from
|
||||||
|
/// the two canonical community vector suites:
|
||||||
|
/// - cacophony: https://raw.githubusercontent.com/haskell-cryptography/cacophony/master/vectors/cacophony.txt
|
||||||
|
/// (6 messages: full XX handshake transcript + 3 transport messages, with
|
||||||
|
/// `handshake_hash`)
|
||||||
|
/// - snow: https://raw.githubusercontent.com/mcginty/snow/main/tests/vectors/snow.txt
|
||||||
|
/// (5 messages: full XX handshake transcript + 2 transport messages)
|
||||||
|
/// Plain XX has no PSKs and no pre-message keys; prologue is part of both
|
||||||
|
/// vectors and is mixed via `NoiseHandshakeState(prologue:)`. Fixed ephemerals
|
||||||
|
/// come in through the `predeterminedEphemeralKey` test seam.
|
||||||
struct NoiseTestVector: Codable {
|
struct NoiseTestVector: Codable {
|
||||||
let protocol_name: String
|
let protocol_name: String
|
||||||
let init_prologue: String
|
let init_prologue: String
|
||||||
@@ -586,9 +598,16 @@ struct NoiseProtocolTests {
|
|||||||
@Test func noiseTestVectors() throws {
|
@Test func noiseTestVectors() throws {
|
||||||
// Load test vectors from bundle
|
// Load test vectors from bundle
|
||||||
let testVectors = try loadTestVectors()
|
let testVectors = try loadTestVectors()
|
||||||
|
#expect(!testVectors.isEmpty, "No Noise test vectors loaded from fixture")
|
||||||
|
|
||||||
|
// Every embedded vector must target the exact protocol the app uses.
|
||||||
|
let appProtocolName = NoiseProtocolName(pattern: NoisePattern.XX.patternName).fullName
|
||||||
|
|
||||||
for (index, testVector) in testVectors.enumerated() {
|
for (index, testVector) in testVectors.enumerated() {
|
||||||
print("Running test vector \(index + 1): \(testVector.protocol_name)")
|
print("Running test vector \(index + 1): \(testVector.protocol_name)")
|
||||||
|
#expect(
|
||||||
|
testVector.protocol_name == appProtocolName,
|
||||||
|
"Vector \(index + 1) targets \(testVector.protocol_name), app speaks \(appProtocolName)")
|
||||||
try runTestVector(testVector)
|
try runTestVector(testVector)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -612,8 +631,13 @@ struct NoiseProtocolTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadTestVectors() throws -> [NoiseTestVector] {
|
private func loadTestVectors() throws -> [NoiseTestVector] {
|
||||||
// Try to load from test bundle
|
// SwiftPM puts processed resources in the module bundle; the Xcode
|
||||||
|
// test target puts them in the test bundle itself.
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
let testBundle = Bundle.module
|
||||||
|
#else
|
||||||
let testBundle = Bundle(for: MockKeychain.self)
|
let testBundle = Bundle(for: MockKeychain.self)
|
||||||
|
#endif
|
||||||
guard let url = testBundle.url(forResource: "NoiseTestVectors", withExtension: "json")
|
guard let url = testBundle.url(forResource: "NoiseTestVectors", withExtension: "json")
|
||||||
else {
|
else {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
|
|||||||
@@ -38,15 +38,40 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Reports one human-readable throughput line per benchmark so CI logs
|
/// Reports one human-readable throughput line per benchmark so CI logs
|
||||||
/// are readable without parsing XCTest's measure output.
|
/// are readable without parsing XCTest's measure output. The same line is
|
||||||
|
/// appended to the file named by `BITCHAT_PERF_LOG` (if set): under
|
||||||
|
/// `swift test --parallel` the runner swallows stdout of passing tests,
|
||||||
|
/// so the CI floor gate (scripts/check-perf-floors.sh) reads the file.
|
||||||
private func reportThroughput(_ name: String, samples: [TimeInterval], operations: Int, unit: String) {
|
private func reportThroughput(_ name: String, samples: [TimeInterval], operations: Int, unit: String) {
|
||||||
guard !samples.isEmpty else { return }
|
guard !samples.isEmpty else { return }
|
||||||
let avg = samples.reduce(0, +) / Double(samples.count)
|
let avg = samples.reduce(0, +) / Double(samples.count)
|
||||||
let opsPerSec = avg > 0 ? Double(operations) / avg : .infinity
|
let opsPerSec = avg > 0 ? Double(operations) / avg : .infinity
|
||||||
print(String(
|
let line = String(
|
||||||
format: "PERF[%@]: %.0f %@/sec (avg %.3f ms per pass of %d, %d passes)",
|
format: "PERF[%@]: %.0f %@/sec (avg %.3f ms per pass of %d, %d passes)",
|
||||||
name, opsPerSec, unit, avg * 1000, operations, samples.count
|
name, opsPerSec, unit, avg * 1000, operations, samples.count
|
||||||
))
|
)
|
||||||
|
print(line)
|
||||||
|
Self.appendToPerfLog(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var perfLogPath: String? {
|
||||||
|
let path = ProcessInfo.processInfo.environment["BITCHAT_PERF_LOG"]
|
||||||
|
return (path?.isEmpty ?? true) ? nil : path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends with `O_APPEND` because `swift test --parallel` may split this
|
||||||
|
/// class across worker processes that write concurrently. The file is
|
||||||
|
/// append-only (CI workspaces start fresh); delete it between local runs
|
||||||
|
/// if you reuse a path.
|
||||||
|
private static func appendToPerfLog(_ line: String) {
|
||||||
|
guard let path = perfLogPath else { return }
|
||||||
|
let fd = open(path, O_WRONLY | O_APPEND | O_CREAT, 0o644)
|
||||||
|
guard fd >= 0 else { return }
|
||||||
|
defer { close(fd) }
|
||||||
|
let bytes = Array((line + "\n").utf8)
|
||||||
|
bytes.withUnsafeBufferPointer { buffer in
|
||||||
|
_ = write(fd, buffer.baseAddress, buffer.count)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 1a. Nostr inbound event handling (fresh events)
|
// MARK: - 1a. Nostr inbound event handling (fresh events)
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"_philosophy": [
|
||||||
|
"Floor throughputs for the PERF[...] lines printed by PerformanceBaselineTests.",
|
||||||
|
"Floors catch algorithmic regressions (O(n) -> O(n^2), accidental sync I/O,",
|
||||||
|
"quadratic re-scans), NOT tuning noise: each floor is deliberately set at",
|
||||||
|
"~25% of the throughput measured on a local dev machine (2026-06, Apple",
|
||||||
|
"Silicon), leaving ~4x headroom for CI runner variance so the gate never",
|
||||||
|
"flakes on a slow runner while still failing loudly on order-of-magnitude",
|
||||||
|
"regressions.",
|
||||||
|
"Raise floors deliberately after intentional performance improvements;",
|
||||||
|
"lower them only with a written justification in the PR. If a benchmark is",
|
||||||
|
"renamed or removed, update this file in the same change - the gate fails",
|
||||||
|
"when a floored benchmark stops reporting.",
|
||||||
|
"Checked by scripts/check-perf-floors.sh against captured swift-test output."
|
||||||
|
],
|
||||||
|
"_units": "operations per second, matching each benchmark's PERF line",
|
||||||
|
"_reference_local_numbers_2026_06": {
|
||||||
|
"nostrInbound.fresh": 2132,
|
||||||
|
"nostrInbound.duplicate": 2558907,
|
||||||
|
"bleInbound.roundTripAndDedup": 38063,
|
||||||
|
"gcs.buildAndDecode": 776,
|
||||||
|
"delivery.incrementalUpdate": 172615,
|
||||||
|
"delivery.storeUpdate": 158862,
|
||||||
|
"formatting.formatMessage": 12261,
|
||||||
|
"pipeline.privateIngest": 24848,
|
||||||
|
"pipeline.publicIngest": 13102,
|
||||||
|
"store.append": 213201
|
||||||
|
},
|
||||||
|
"floors": {
|
||||||
|
"nostrInbound.fresh": 530,
|
||||||
|
"nostrInbound.duplicate": 600000,
|
||||||
|
"bleInbound.roundTripAndDedup": 9500,
|
||||||
|
"gcs.buildAndDecode": 190,
|
||||||
|
"delivery.incrementalUpdate": 43000,
|
||||||
|
"delivery.storeUpdate": 39000,
|
||||||
|
"formatting.formatMessage": 3000,
|
||||||
|
"pipeline.privateIngest": 6000,
|
||||||
|
"pipeline.publicIngest": 3200,
|
||||||
|
"store.append": 53000
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+108
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# check-perf-floors.sh — order-of-magnitude performance regression gate.
|
||||||
|
#
|
||||||
|
# Parses the `PERF[name]: N unit/sec ...` lines that PerformanceBaselineTests
|
||||||
|
# prints into captured test output and fails if any benchmark's throughput is
|
||||||
|
# below its floor from bitchatTests/Performance/perf-floors.json.
|
||||||
|
#
|
||||||
|
# Floor philosophy (see the floors file): floors sit at ~25% of locally
|
||||||
|
# measured throughput, so they catch algorithmic regressions (O(n) -> O(n^2)),
|
||||||
|
# never runner variance. Raise floors deliberately after intentional
|
||||||
|
# improvements; never tune them to chase noise.
|
||||||
|
#
|
||||||
|
# Usage: scripts/check-perf-floors.sh <test-output-file> [floors-file]
|
||||||
|
#
|
||||||
|
# Skips gracefully (exit 0) when:
|
||||||
|
# - BITCHAT_SKIP_PERF_BASELINES=1 (perf tests were skipped), or
|
||||||
|
# - the output contains no PERF lines (e.g. package-only matrix entries).
|
||||||
|
#
|
||||||
|
# Fails (exit 1) when:
|
||||||
|
# - any benchmark reports throughput below its floor, or
|
||||||
|
# - PERF lines are present but a floored benchmark is missing
|
||||||
|
# (a silently-dropped benchmark must be an explicit floors-file change).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -lt 1 ]]; then
|
||||||
|
echo "usage: $0 <test-output-file> [floors-file]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUTPUT_FILE="$1"
|
||||||
|
FLOORS_FILE="${2:-$(cd "$(dirname "$0")/.." && pwd)/bitchatTests/Performance/perf-floors.json}"
|
||||||
|
|
||||||
|
if [[ "${BITCHAT_SKIP_PERF_BASELINES:-}" == "1" ]]; then
|
||||||
|
echo "perf-floors: BITCHAT_SKIP_PERF_BASELINES=1 — skipping gate."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$OUTPUT_FILE" ]]; then
|
||||||
|
echo "perf-floors: output file '$OUTPUT_FILE' not found — skipping gate." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$FLOORS_FILE" ]]; then
|
||||||
|
echo "perf-floors: floors file '$FLOORS_FILE' not found." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q 'PERF\[' "$OUTPUT_FILE"; then
|
||||||
|
echo "perf-floors: no PERF lines in '$OUTPUT_FILE' — skipping gate."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
output_file = os.environ["OUTPUT_FILE"]
|
||||||
|
floors_file = os.environ["FLOORS_FILE"]
|
||||||
|
|
||||||
|
with open(floors_file) as f:
|
||||||
|
floors = json.load(f)["floors"]
|
||||||
|
|
||||||
|
# PERF[delivery.storeUpdate]: 158862 updates/sec (avg 3.147 ms per pass of 500, 10 passes)
|
||||||
|
pattern = re.compile(r"PERF\[([^\]]+)\]:\s*([0-9]+(?:\.[0-9]+)?)\s*(\S+)/sec")
|
||||||
|
|
||||||
|
measured = {}
|
||||||
|
with open(output_file, errors="replace") as f:
|
||||||
|
for line in f:
|
||||||
|
m = pattern.search(line)
|
||||||
|
if m:
|
||||||
|
# Keep the last reported value if a benchmark prints twice.
|
||||||
|
measured[m.group(1)] = (float(m.group(2)), m.group(3))
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
print(f"perf-floors: checking {len(measured)} benchmark(s) against {len(floors)} floor(s)")
|
||||||
|
for name in sorted(set(floors) | set(measured)):
|
||||||
|
floor = floors.get(name)
|
||||||
|
if name not in measured:
|
||||||
|
failures.append(
|
||||||
|
f" MISSING {name}: floored benchmark reported no PERF line "
|
||||||
|
f"(removed/renamed? update perf-floors.json in the same change)")
|
||||||
|
continue
|
||||||
|
value, unit = measured[name]
|
||||||
|
if floor is None:
|
||||||
|
print(f" NO-FLOOR {name}: {value:.0f} {unit}/sec (consider adding a floor)")
|
||||||
|
continue
|
||||||
|
status = "OK" if value >= floor else "BELOW"
|
||||||
|
line = f" {status:8} {name}: {value:.0f} {unit}/sec (floor {floor})"
|
||||||
|
print(line)
|
||||||
|
if value < floor:
|
||||||
|
failures.append(
|
||||||
|
f" BELOW {name}: {value:.0f} {unit}/sec is under floor {floor} "
|
||||||
|
f"({value / floor * 100:.0f}% of floor)")
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print("\nperf-floors: FAILED — order-of-magnitude-class regression suspected:")
|
||||||
|
print("\n".join(failures))
|
||||||
|
print("\nFloors are ~25% of healthy local throughput; falling below one means an")
|
||||||
|
print("algorithmic regression, not runner noise. If the change is intentional,")
|
||||||
|
print("update bitchatTests/Performance/perf-floors.json deliberately.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("perf-floors: all benchmarks at or above their floors.")
|
||||||
|
PYEOF
|
||||||
Reference in New Issue
Block a user