Skip to content

Commit 8faaabc

Browse files
karthiknadigCopilot
andcommitted
test: validate session resource and cache invariants
Allow ambient cache additions while preserving original entries, validate derived resource metrics exactly, and surface Linux descriptor enumeration errors. Address PR #563 feedback and native CI failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c5155fd commit 8faaabc

4 files changed

Lines changed: 209 additions & 24 deletions

File tree

‎crates/pet/tests/session_performance.rs‎

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33

44
use pet_fs::path::norm_case;
55
use serde_json::json;
6+
use std::collections::BTreeMap;
67
use std::fs;
8+
use std::io;
79
use std::path::{Path, PathBuf};
810
use std::process::Command;
911
use std::thread;
@@ -420,6 +422,55 @@ fn directory_usage(root: &Path) -> (usize, u64) {
420422
(files, bytes)
421423
}
422424

425+
fn cache_contents(root: &Path) -> io::Result<BTreeMap<PathBuf, Vec<u8>>> {
426+
let mut pending = vec![(PathBuf::new(), root.to_path_buf())];
427+
let mut contents = BTreeMap::new();
428+
while let Some((relative_directory, directory)) = pending.pop() {
429+
for entry in fs::read_dir(directory)? {
430+
let entry = entry?;
431+
let file_type = entry.file_type()?;
432+
let relative_path = relative_directory.join(entry.file_name());
433+
if file_type.is_dir() {
434+
pending.push((relative_path, entry.path()));
435+
} else if file_type.is_file() {
436+
contents.insert(relative_path, fs::read(entry.path())?);
437+
}
438+
}
439+
}
440+
Ok(contents)
441+
}
442+
443+
fn original_cache_entries_unchanged(
444+
original: &BTreeMap<PathBuf, Vec<u8>>,
445+
current: &BTreeMap<PathBuf, Vec<u8>>,
446+
) -> bool {
447+
original
448+
.iter()
449+
.all(|(path, bytes)| current.get(path) == Some(bytes))
450+
}
451+
452+
#[test]
453+
fn cache_preservation_allows_additions_but_rejects_original_entry_changes() {
454+
let original = BTreeMap::from([
455+
(PathBuf::from("flat.json"), b"original".to_vec()),
456+
(
457+
PathBuf::from("nested").join("entry.json"),
458+
b"nested".to_vec(),
459+
),
460+
]);
461+
let mut with_addition = original.clone();
462+
with_addition.insert(PathBuf::from("ambient.json"), b"ambient".to_vec());
463+
assert!(original_cache_entries_unchanged(&original, &with_addition));
464+
465+
let mut changed = with_addition.clone();
466+
changed.insert(PathBuf::from("flat.json"), b"modified".to_vec());
467+
assert!(!original_cache_entries_unchanged(&original, &changed));
468+
469+
let mut deleted = with_addition;
470+
deleted.remove(&PathBuf::from("nested").join("entry.json"));
471+
assert!(!original_cache_entries_unchanged(&original, &deleted));
472+
}
473+
423474
fn observe_resources(pid: u32) -> ResourceSample {
424475
let samples = (0..3)
425476
.map(|_| {
@@ -534,8 +585,8 @@ fn sample_process(pid: u32) -> ResourceSample {
534585
descriptors: Some(
535586
fs::read_dir(format!("/proc/{pid}/fd"))
536587
.expect("failed to read process-specific descriptor directory")
537-
.map(|entry| entry.expect("failed to read process descriptor entry"))
538-
.count(),
588+
.try_fold(0, |count, entry| entry.map(|_| count + 1))
589+
.expect("failed to read process descriptor entry"),
539590
),
540591
}
541592
}
@@ -901,9 +952,10 @@ fn long_lived_session_benchmark() {
901952

902953
fixture.clear_barrier();
903954
let pre_resolve_resources = observe_resources(client.process_id());
904-
let original_cache_before_overlap = directory_usage(&fixture.cache);
955+
let original_cache_before_overlap =
956+
cache_contents(&fixture.cache).expect("failed to capture pre-overlap cache contents");
905957
assert!(
906-
original_cache_before_overlap.0 > 0 && original_cache_before_overlap.1 > 0,
958+
!original_cache_before_overlap.is_empty(),
907959
"warm-up resolve did not populate the original process cache"
908960
);
909961
let overlap_workspace = fixture._root.path().join("overlap-workspace");
@@ -972,10 +1024,11 @@ fn long_lived_session_benchmark() {
9721024
max_ambient_environment_count =
9731025
max_ambient_environment_count.max(overlap_ambient_environment_count);
9741026
max_ambient_manager_count = max_ambient_manager_count.max(overlap_ambient_manager_count);
975-
assert_eq!(
976-
directory_usage(&fixture.cache),
977-
original_cache_before_overlap,
978-
"overlap reconfiguration did not retain the original cache contents"
1027+
let cache_after_overlap =
1028+
cache_contents(&fixture.cache).expect("failed to capture post-overlap cache contents");
1029+
assert!(
1030+
original_cache_entries_unchanged(&original_cache_before_overlap, &cache_after_overlap),
1031+
"overlap reconfiguration modified or deleted an original cache entry"
9791032
);
9801033
assert_eq!(
9811034
fixture.released_count(),

‎docs/SESSION_BENCHMARKS.md‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,11 @@ The workflow keeps Cargo output in a target directory inside its checkout and
5353
uploads only the privacy-safe `session-metrics.json` artifact, never fixture
5454
paths or raw benchmark output. A dedicated parser rejects missing, malformed,
5555
duplicate, mode-inconsistent, count-inconsistent, or shape-inconsistent
56-
payloads. Artifact writes are atomic, and the timeout fallback replaces corrupt
57-
artifacts rather than uploading invalid JSON. Failed runs retain validated
56+
payloads. It also recomputes the observed resource peak from every reported
57+
snapshot and the RSS delta from the pre-resolve and final snapshots, rejecting
58+
either derived value unless it matches exactly. Artifact writes are atomic, and
59+
the timeout fallback replaces corrupt artifacts rather than uploading invalid
60+
JSON. Failed runs retain validated
5861
measurements when available; failures without usable metrics have explicit failed
5962
status and zero counts. The workflow preserves the original benchmark failure.
6063

@@ -64,8 +67,10 @@ real paths keep macOS temporary-directory aliases equivalent, while ambient
6467
interpreters bypass it. During the proof pass,
6568
every distinct interpreter process records entry before the client issues
6669
`info`, reconfigures to a new workspace while retaining the process's original
67-
cache directory, and refreshes that known inventory. The barrier remains held
68-
until those responsiveness checks complete.
70+
cache directory, and refreshes that known inventory. Every cache file captured
71+
before reconfiguration must still exist with identical bytes afterward; cache
72+
files added for unrelated global discoveries are allowed. The barrier remains
73+
held until those responsiveness checks complete.
6974
Platform-global locators may also report host installations and managers. The
7075
benchmark converts only configured workspace entries to strict fixture
7176
identities, validates that fixture-scoped managers remain empty, and records

‎scripts/session_metrics.py‎

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,27 @@ def require_integer_list(value: Any, name: str, expected_length: int) -> list[in
9191
return [require_integer(item, f"{name}[{index}]") for index, item in enumerate(value)]
9292

9393

94-
def validate_resource(value: Any, name: str) -> None:
94+
def validate_resource(value: Any, name: str) -> dict[str, Any]:
9595
resource = require_object(
9696
value, name, {"residentBytes", "threads", "handlesOrDescriptors"}
9797
)
9898
require_integer(resource["residentBytes"], f"{name}.residentBytes")
9999
for key in ("threads", "handlesOrDescriptors"):
100100
if resource[key] is not None:
101101
require_integer(resource[key], f"{name}.{key}")
102+
return resource
103+
104+
105+
def observed_resource_peak(samples: Sequence[dict[str, Any]]) -> dict[str, Any]:
106+
def optional_max(key: str) -> int | None:
107+
known = [sample[key] for sample in samples if sample[key] is not None]
108+
return max(known) if known else None
109+
110+
return {
111+
"residentBytes": max(sample["residentBytes"] for sample in samples),
112+
"threads": optional_max("threads"),
113+
"handlesOrDescriptors": optional_max("handlesOrDescriptors"),
114+
}
102115

103116

104117
def validate_cache_value(value: Any, name: str) -> dict[str, Any]:
@@ -299,6 +312,7 @@ def validate_success_metrics(value: Any, expected_mode: str) -> dict[str, Any]:
299312
if not isinstance(inventory_resources, list) or len(inventory_resources) != len(expected_sizes):
300313
raise MetricsError("inventoryResourceSamples must contain one entry per size")
301314
seen_resource_sizes = set()
315+
resource_samples = []
302316
for index, value in enumerate(inventory_resources):
303317
sample = require_object(
304318
value,
@@ -311,7 +325,11 @@ def validate_success_metrics(value: Any, expected_mode: str) -> dict[str, Any]:
311325
f"inventoryResourceSamples[{index}] has an invalid or duplicate size"
312326
)
313327
seen_resource_sizes.add(size)
314-
validate_resource(sample["resources"], f"inventoryResourceSamples[{index}].resources")
328+
resource_samples.append(
329+
validate_resource(
330+
sample["resources"], f"inventoryResourceSamples[{index}].resources"
331+
)
332+
)
315333

316334
batch_resources = metrics["resolveBatchResourceSamples"]
317335
if not isinstance(batch_resources, list) or len(batch_resources) != expected_batches:
@@ -329,17 +347,42 @@ def validate_success_metrics(value: Any, expected_mode: str) -> dict[str, Any]:
329347
f"resolveBatchResourceSamples[{index}] has an invalid or duplicate batch"
330348
)
331349
seen_batches.add(batch)
332-
validate_resource(sample["resources"], f"resolveBatchResourceSamples[{index}].resources")
350+
resource_samples.append(
351+
validate_resource(
352+
sample["resources"], f"resolveBatchResourceSamples[{index}].resources"
353+
)
354+
)
333355

334356
for name in (
335357
"preResolveResources",
336358
"barrierObservedResources",
337359
"postOverlapResources",
338-
"observedResourcePeak",
339360
"resourceAfter",
340361
):
341-
validate_resource(metrics[name], name)
342-
require_integer(metrics["rssDeltaFromPreResolveBytes"], "rssDeltaFromPreResolveBytes", minimum=-(2**63))
362+
resource_samples.append(validate_resource(metrics[name], name))
363+
reported_peak = validate_resource(
364+
metrics["observedResourcePeak"], "observedResourcePeak"
365+
)
366+
expected_peak = observed_resource_peak(resource_samples)
367+
if reported_peak != expected_peak:
368+
raise MetricsError(
369+
f"observedResourcePeak must equal {expected_peak}, got {reported_peak}"
370+
)
371+
372+
reported_delta = require_integer(
373+
metrics["rssDeltaFromPreResolveBytes"],
374+
"rssDeltaFromPreResolveBytes",
375+
minimum=-(2**63),
376+
)
377+
expected_delta = (
378+
metrics["resourceAfter"]["residentBytes"]
379+
- metrics["preResolveResources"]["residentBytes"]
380+
)
381+
if reported_delta != expected_delta:
382+
raise MetricsError(
383+
"rssDeltaFromPreResolveBytes must equal "
384+
f"{expected_delta}, got {reported_delta}"
385+
)
343386
return metrics
344387

345388

‎scripts/tests/test_session_metrics.py‎

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@
1919
failed_metrics, write_json) # noqa: E402
2020

2121

22-
def resource():
22+
def resource(resident=100, threads=2, descriptors=3):
2323
return {
24-
"residentBytes": 100,
25-
"threads": 2,
26-
"handlesOrDescriptors": 3,
24+
"residentBytes": resident,
25+
"threads": threads,
26+
"handlesOrDescriptors": descriptors,
2727
}
2828

2929

@@ -100,11 +100,11 @@ def valid_metrics():
100100
"resolveBatchResourceSamples": [
101101
{"batch": batch, "resources": resource()} for batch in range(2)
102102
],
103-
"preResolveResources": resource(),
103+
"preResolveResources": resource(resident=100),
104104
"barrierObservedResources": resource(),
105105
"postOverlapResources": resource(),
106106
"observedResourcePeak": resource(),
107-
"resourceAfter": resource(),
107+
"resourceAfter": resource(resident=90),
108108
"rssDeltaFromPreResolveBytes": -10,
109109
}
110110

@@ -163,6 +163,13 @@ def write_payloads(self, *payloads):
163163
def artifact(self):
164164
return json.loads(self.output.read_text(encoding="utf-8"))
165165

166+
def assert_metrics_rejected(self, metrics, message):
167+
self.write_payloads(json.dumps(metrics))
168+
with self.assertRaisesRegex(MetricsError, message):
169+
extract_metrics(self.input, self.output, 0, "fast")
170+
self.assertEqual(self.artifact()["status"], "failed")
171+
self.assertFalse(self.artifact()["metricsProduced"])
172+
166173
def test_no_metrics_fails_closed_and_persists_failure(self):
167174
self.input.write_text("benchmark output only\n", encoding="utf-8")
168175
with self.assertRaisesRegex(MetricsError, "exactly one"):
@@ -224,6 +231,83 @@ def test_success_preserves_validated_metrics(self):
224231
self.assertTrue(metrics["metricsProduced"])
225232
self.assertEqual(len(metrics["refreshScenarioSamples"]), 9)
226233

234+
def test_resource_peak_rejects_low_and_high_values_for_every_field(self):
235+
for field in ("residentBytes", "threads", "handlesOrDescriptors"):
236+
for difference in (-1, 1):
237+
with self.subTest(field=field, difference=difference):
238+
metrics = valid_metrics()
239+
metrics["observedResourcePeak"][field] += difference
240+
self.assert_metrics_rejected(metrics, "observedResourcePeak")
241+
242+
def test_resource_peak_includes_every_sample_category(self):
243+
categories = (
244+
("inventory", lambda metrics: metrics["inventoryResourceSamples"][0]["resources"]),
245+
("pre-resolve", lambda metrics: metrics["preResolveResources"]),
246+
("barrier", lambda metrics: metrics["barrierObservedResources"]),
247+
("post-overlap", lambda metrics: metrics["postOverlapResources"]),
248+
("resolve-batch", lambda metrics: metrics["resolveBatchResourceSamples"][0]["resources"]),
249+
("after", lambda metrics: metrics["resourceAfter"]),
250+
)
251+
for name, select_sample in categories:
252+
with self.subTest(category=name):
253+
metrics = valid_metrics()
254+
sample = select_sample(metrics)
255+
sample.update(
256+
residentBytes=1000,
257+
threads=1001,
258+
handlesOrDescriptors=1002,
259+
)
260+
metrics["observedResourcePeak"] = resource(1000, 1001, 1002)
261+
metrics["rssDeltaFromPreResolveBytes"] = (
262+
metrics["resourceAfter"]["residentBytes"]
263+
- metrics["preResolveResources"]["residentBytes"]
264+
)
265+
self.write_payloads(json.dumps(metrics))
266+
extract_metrics(self.input, self.output, 0, "fast")
267+
268+
def test_resource_peak_optional_fields_support_all_null_and_mixed_samples(self):
269+
metrics = valid_metrics()
270+
samples = [
271+
*(entry["resources"] for entry in metrics["inventoryResourceSamples"]),
272+
metrics["preResolveResources"],
273+
metrics["barrierObservedResources"],
274+
metrics["postOverlapResources"],
275+
*(entry["resources"] for entry in metrics["resolveBatchResourceSamples"]),
276+
metrics["resourceAfter"],
277+
]
278+
for sample in samples:
279+
sample["threads"] = None
280+
sample["handlesOrDescriptors"] = None
281+
metrics["observedResourcePeak"]["threads"] = None
282+
metrics["observedResourcePeak"]["handlesOrDescriptors"] = None
283+
self.write_payloads(json.dumps(metrics))
284+
extract_metrics(self.input, self.output, 0, "fast")
285+
286+
samples[0]["threads"] = 7
287+
samples[-1]["threads"] = 5
288+
samples[1]["handlesOrDescriptors"] = 8
289+
samples[-2]["handlesOrDescriptors"] = 6
290+
metrics["observedResourcePeak"]["threads"] = 7
291+
metrics["observedResourcePeak"]["handlesOrDescriptors"] = 8
292+
self.write_payloads(json.dumps(metrics))
293+
extract_metrics(self.input, self.output, 0, "fast")
294+
295+
def test_rss_delta_accepts_positive_negative_and_zero_values(self):
296+
for after, expected_delta in ((110, 10), (90, -10), (100, 0)):
297+
with self.subTest(expected_delta=expected_delta):
298+
metrics = valid_metrics()
299+
metrics["resourceAfter"]["residentBytes"] = after
300+
metrics["rssDeltaFromPreResolveBytes"] = expected_delta
301+
if after > metrics["observedResourcePeak"]["residentBytes"]:
302+
metrics["observedResourcePeak"]["residentBytes"] = after
303+
self.write_payloads(json.dumps(metrics))
304+
extract_metrics(self.input, self.output, 0, "fast")
305+
306+
def test_inconsistent_rss_delta_fails_closed_with_failure_artifact(self):
307+
metrics = valid_metrics()
308+
metrics["rssDeltaFromPreResolveBytes"] = -9
309+
self.assert_metrics_rejected(metrics, "rssDeltaFromPreResolveBytes")
310+
227311
def test_malformed_nested_types_fail_closed_with_failure_artifacts(self):
228312
fields = [
229313
("sizes", 0), ("samplesPerSize",), ("resolveConcurrency",),

0 commit comments

Comments
 (0)