Skip to content

Commit 3aeb8cb

Browse files
committed
Isolate benchmark registry fixtures with owned ephemeral ports
1 parent 88e2cec commit 3aeb8cb

5 files changed

Lines changed: 118 additions & 9 deletions

File tree

‎docs/ADR/ADR-080-benchmark-failure-isolation.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ upload; cancellation/timeouts that prevent artifacts remain explicit blockers.
4141
Run these actual loopback fixture tests before `prepare-images.mjs` owns the
4242
same registry port; restore/build the native test runners first. Keep the
4343
actual pinned image export/import checks after image construction.
44+
The readiness helper accepts an optional numeric loopback port with default
45+
registry.port; validate integer1..65535 before URL construction, HTTP or
46+
evidence I/O. Both production callers retain their no-argument default5000.
47+
Actual HTTP fixtures listen on kernel-assigned port0, retain that listener
48+
until cleanup and pass only its observed port. Extend the existing bounded
49+
evidence case with invalid-number/type checks, zero HTTP requests and absent
50+
evidence assertions; do not change OS settings/processes or accept arbitrary
51+
hosts/URLs/environment overrides. Root integrates this port-isolation follow-up,
52+
reviews the unchanged production callers and reruns the10 original scenarios
53+
through freshly built Aspire/TUnit before the next scoped checkpoint.
4454
Under FAIL-PREP-KURRENT, `KurrentTarget.InitializeAsync` constructs the actual
4555
SDK writer only after `KurrentClusterVerifier.VerifyAsync` proves membership.
4656
Three SDK/resource-model tests belong to ComparisonTests and run in common

‎docs/Features/BenchmarkComparisons.md‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -688,7 +688,7 @@ KeyLoad engine repair and concurrent series-codec work are outside this task.
688688
| REQ-BC-FAIL-003 authenticated partial results | AC-BC-FAIL-003 failed job accepted only with failed workload, successful result upload and matching failed envelope; missing/malformed/expired/mixed evidence rejected | producer/aggregate/site negative and positive TUnit regressions |
689689
| REQ-BC-FAIL-004 honest site | AC-BC-FAIL-004 successful competitors retain values; failed cells have no numeric values and expose actual job link | independent numeric oracle, projection validation and real Chrome |
690690
| REQ-BC-FAIL-005 repair shared preparation | AC-BC-FAIL-005 diagnose exact failed logs, repair benchmark setup/build invocation, retain native isolated topology and Aspire ownership | exact failed-source log, focused regression, delivered-source GitHub rerun |
691-
| REQ-BC-FAIL-006 bounded registry readiness | AC-BC-FAIL-006 each native HTTP probe has at most2s within the unchanged30s total; only settled non-aborted HTTP200 succeeds; private no-follow diagnostics remain at most121 records/64KiB and evidence-write failures propagate | `ImageRegistryReadinessTests`:10 actual loopback HTTP/error/bounds cases, plus genuine pinned Docker image export/import in GitHub |
691+
| REQ-BC-FAIL-006 bounded registry readiness | AC-BC-FAIL-006 each native HTTP probe has at most2s within the unchanged30s total; only settled non-aborted HTTP200 succeeds; private no-follow diagnostics remain at most121 records/64KiB and evidence-write failures propagate. Actual fixture listeners use kernel-assigned loopback ports; optional numeric port accepts only integers1..65535 before HTTP/evidence, while both production callers keep the default5000 | `ImageRegistryReadinessTests`:10 actual loopback HTTP/error/bounds cases, including rejected port inputs before I/O, plus genuine pinned Docker image export/import in GitHub |
692692
| REQ-BC-FAIL-007 Kurrent writer starts after membership | AC-BC-FAIL-007 verify all native1/2/3-member views before constructing the SDK writer; retain native DNS seeds, TLS verification, leader preference, NoStream semantics, acknowledgements, replica-copy oracle and cleanup | `IsolatedKurrentDiscoverySettingsTests`:3 actual SDK/resource-model cases; genuine Aspire-owned StreamAppend preflights for1/2/3 nodes |
693693
| REQ-BC-FAIL-008 explicit cancellation stops owned work | AC-BC-FAIL-008 workload, finalization and result upload use `!cancelled()` so ordinary failure still finalizes while cancellation stops execution/publication; `always()` cleanup retains bounded diagnostics and safely removes owned registries whose setup was cancelled | `WorkflowBenchmarkFailureTests`, unchanged canceled-job/producer rejection regressions and actual GitHub lifecycle |
694694

@@ -775,6 +775,31 @@ Full Release, scoped Unit build and formatter were blocked by concurrent
775775
KeyCodec/CRUD source/test diagnostics. New workflow C# tests and actual complete
776776
publication remain pending; none of that engine work is staged in this repair.
777777

778+
The clean88e2cec snapshot passed full Release build, formatter and governance.
779+
Its actual full unit report has2821/2867 passes,46 failures, no skips/cancellation:
780+
workflow cancellation3/3 and all selected workflow-layout groups passed. One
781+
registry case failed with original `EADDRINUSE`; concurrent local tests and an
782+
observed system ControlCenter listener share port5000. FAIL-PREP-REGISTRY therefore
783+
uses an OS-owned ephemeral port for each actual HTTP fixture. Only a numeric port
784+
on the fixed loopback URL is configurable in the readiness helper; preparation,
785+
bundle publication, Docker mapping, manifest fetch and cleanup keep their existing
786+
default5000. The evidence-bounds case verifies invalid numbers/types produce no
787+
requests or evidence directory. Other full-unit failures include temp-path link
788+
rejection and a native allocation assertion; those are not repaired here. Reruns
789+
use an owned canonical temporary directory and retain the original failed report.
790+
Clean-snapshot failed-envelope producer/finalizer2/2 passed through Aspire. The
791+
chunk development child failed because that archive has no Git metadata; its
792+
original stderr proves the failed `git check-ignore`, rather than a codec defect.
793+
794+
The subsequent benchmark-only port-isolation candidate (archive of88e2cec plus
795+
its scoped patch) passed full Release build with0warnings/errors, formatter and
796+
governance. Its freshly built actual Aspire/TUnit registry scenarios passed10/10
797+
normal and10/10 scalar, no skips/cancellation/timeouts, using an owned canonical
798+
temporary directory. The production callers still omit the optional port.
799+
Source/module/fixture/runner and original report hashes are retained in the local
800+
development receipt and status. Complete delivered-source GitHub publication is
801+
pending; these development results do not qualify a full cohort or website.
802+
778803
```mermaid
779804
flowchart LR
780805
Plan[Complete native cell plan] --> Jobs[Independent Aspire workloads]

‎docs/implementation/status.json‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3190,15 +3190,15 @@
31903190
"scope": "preparation and native preflight only;4096-record development workload; no scale, full cohort, winner or power-loss qualification"
31913191
}
31923192
},
3193-
"localDevelopmentVerification": "Earlier integrated Release build/formatter and Aspire registry10/10, SDK settings3/3, producer/finalizer2/2, GitHub evidence84/84 and original report probes48/48 passed. Delivered6ec Linux clean build, formatter, workflow inventory and native image checks passed. Follow-up current JS registry fixtures passed10/10 normal and10/10 scalar through the existing Aspire/TUnit bridge (not a newly compiled C# runner). Governance and diff checks pass. Current full Release, scoped Unit build and formatter are blocked by concurrent KeyCodec/CRUD source/test diagnostics outside benchmark scope; new workflow C# tests and complete delivered-source native/site/Pages qualification remain pending.",
3193+
"localDevelopmentVerification": "Delivered6ec Linux clean build, formatter, workflow inventory, registry10/10 and SDK/resource settings3/3 passed. Clean88e2cec snapshot passed full Release build, formatter and governance; actual full unit2821/2867 passed with46 failures, no skips/cancellation (including a registry port conflict, macOS temp-link rejections, missing Git metadata for the chunk child and an allocation assertion). Workflow cancellation3/3 and failed producer/finalizer2/2 passed through Aspire. Benchmark-only ephemeral-port follow-up freshly built clean with0warnings/errors; formatter and governance passed; actual registry10/10 normal and10/10 scalar passed through Aspire/TUnit with canonical owned TMPDIR. Complete delivered-source native/site/Pages qualification remains pending; no KeyLoad engine repair in this stage.",
31943194
"runtimeQualified": false,
31953195
"publicationQualified": false,
31963196
"keyLoadEngineChanges": false,
31973197
"cancellationRepair": {
31983198
"requirement": "REQ-BC-FAIL-008",
31993199
"ordinaryFailure": "preserves original failed workload and authenticated null-report upload",
32003200
"explicitCancellation": "stops workload/finalization/result upload; bounded always cleanup validates owned partial setup",
3201-
"qualification": "source reviewed; focused Aspire regression and genuine delivered-source lifecycle pending"
3201+
"qualification": "Clean88e2cec actual Aspire/TUnit workflow cancellation3/3 passed; genuine delivered-source lifecycle pending."
32023202
},
32033203
"registryDeadlineRegression": {
32043204
"ciRunId": 37161833095,
@@ -3230,6 +3230,53 @@
32303230
"normalReportSha256": "fbd9c1894275e594821a547fbf8a3278a504cc7b1ea2dbacc4a25f41c9dd99a4",
32313231
"scalarReportSha256": "723d3a6c7211b30fe09e45a962e2a82274bed38fb1573c00eff43003b455c8de"
32323232
}
3233+
},
3234+
"registryPortIsolation": {
3235+
"requirement": "REQ-BC-FAIL-006",
3236+
"production": "both no-argument callers retain fixed loopback port5000",
3237+
"fixture": "actual listeners own kernel-assigned ports through cleanup; invalid integer/type inputs rejected before HTTP or evidence I/O",
3238+
"developmentSource": "88e2cec05d31008e9887fe8e970756b7691b7734",
3239+
"candidatePatchSha256": "f46d5eadfdf74aa61e785383e610454925ccc08c2afdcae3af93eb4b65539c89",
3240+
"fullReleaseBuild": "passed;0warnings;0errors",
3241+
"formatter": "passed",
3242+
"governance": "passed",
3243+
"invoker": "Aspire-owned freshly built TUnit; owned canonical TMPDIR; actual ephemeral HTTP listeners",
3244+
"sourceFiles": {
3245+
"scripts/Features/BenchmarkComparisons/image-manifest.mjs": "3d15894b8427e7190ecd494548ae59edb9151b565a5dba74ea6be474a97202a7",
3246+
"tests/KeyLoad.UnitTests/Features/BenchmarkComparisons/ImageRegistryReadinessFixture.mjs": "dac1b3b67da8dc48dc1b3c82baaf133f278e126a19e2f5e147022287e6fec076",
3247+
"tests/KeyLoad.UnitTests/bin/Release/net10.0/KeyLoad.UnitTests.dll": "eb86c57f759baf08567200f318f63b97d5c10bd8bd191a1bd0ccb4efaea1477a"
3248+
},
3249+
"reports": {
3250+
"normal": {
3251+
"summary": {
3252+
"total": 10,
3253+
"passed": 10,
3254+
"failed": 0,
3255+
"skipped": 0,
3256+
"cancelled": 0,
3257+
"timedOut": 0,
3258+
"flaky": 0
3259+
},
3260+
"sha256": "a211bc3a675c2c5a4b89134c080496962cfd42effefe48bd86741c5b6377e543",
3261+
"operatingSystem": "macOS 27.0.1",
3262+
"runtimeVersion": ".NET 10.0.12"
3263+
},
3264+
"scalar": {
3265+
"summary": {
3266+
"total": 10,
3267+
"passed": 10,
3268+
"failed": 0,
3269+
"skipped": 0,
3270+
"cancelled": 0,
3271+
"timedOut": 0,
3272+
"flaky": 0
3273+
},
3274+
"sha256": "749ed17a17a1318fd216e822ca478976dfd49c182720c3aba1f24a2250e3b6da",
3275+
"operatingSystem": "macOS 27.0.1",
3276+
"runtimeVersion": ".NET 10.0.12"
3277+
}
3278+
},
3279+
"githubQualified": false
32333280
}
32343281
}
32353282
}

‎scripts/Features/BenchmarkComparisons/image-manifest.mjs‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const metadataSeparator = '|';
88
const mediaTypeParameterSeparator = ';';
99
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
1010
const invalidDeadline = 'The owned image HTTP deadline is invalid.';
11+
const invalidRegistryPort = 'The owned loopback registry port is invalid.';
12+
const maxRegistryPort = 65535;
1113
const timeoutMessage = 'The owned image HTTP operation exceeded its time bound.';
1214
const cleanupErrorGroups = new WeakMap();
1315

@@ -90,22 +92,27 @@ export function parseManifestEvidence(bytes, digestHeader, contentTypeHeader, ex
9092
});
9193
}
9294

93-
export async function waitForRegistry(context) {
95+
export async function waitForRegistry(context, registryPort = registry.port) {
96+
if (!Number.isInteger(registryPort) || registryPort <= 0 || registryPort > maxRegistryPort) {
97+
throw new RangeError(invalidRegistryPort);
98+
}
99+
const url = new URL(registryProtocol.path, registry.url);
100+
url.port = String(registryPort);
94101
const deadline = Date.now() + processLimit.readinessTimeoutMs;
95102
let sequence = 0;
96103
while (Date.now() < deadline) {
97104
const remaining = deadline - Date.now();
98105
if (remaining <= 0) break;
99106
sequence++;
100-
const result = await probeRegistry(context, sequence, Math.min(processLimit.readinessProbeTimeoutMs, remaining));
107+
const result = await probeRegistry(context, sequence, Math.min(processLimit.readinessProbeTimeoutMs, remaining), url);
101108
if (result.outcome === registryReadinessTokens.outcome.ready && Date.now() < deadline) return;
102109
const backoff = Math.min(processLimit.readinessIntervalMs, deadline - Date.now());
103110
if (backoff > 0) await wait(backoff);
104111
}
105112
throw new Error(message.registryTimeout);
106113
}
107114

108-
async function probeRegistry(context, sequence, timeoutMs) {
115+
async function probeRegistry(context, sequence, timeoutMs, url) {
109116
const started = Date.now();
110117
let signal;
111118
let status = null;
@@ -115,7 +122,7 @@ async function probeRegistry(context, sequence, timeoutMs) {
115122
try {
116123
await withHttpDeadline(timeoutMs, async value => {
117124
signal = value;
118-
const response = await fetch(`${registry.url}${registryProtocol.path}`, { signal, redirect: 'error' });
125+
const response = await fetch(url, { signal, redirect: 'error' });
119126
status = response.status;
120127
phase = registryReadinessTokens.phase.bodyCancel;
121128
await response.body?.cancel();

‎tests/KeyLoad.UnitTests/Features/BenchmarkComparisons/ImageRegistryReadinessFixture.mjs‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ async function runReadinessScenario() {
2424
if (scenario === 'evidence-unsafe') await prepareUnsafeEvidence();
2525
const started = Date.now();
2626
let failure;
27-
try { await waitForRegistry(context); } catch (error) { failure = error; }
27+
try { await waitForRegistry(context, fixture.port); } catch (error) { failure = error; }
2828
const elapsedMs = Date.now() - started;
2929
if (scenario === 'evidence-unsafe') {
3030
assert.equal(failure?.message, message.unsafeEvidencePath);
@@ -77,8 +77,10 @@ function serveRequest(fixture, request, response) {
7777
async function listenFixture(fixture) {
7878
await new Promise((resolve, reject) => {
7979
fixture.server.once('error', reject);
80-
fixture.server.listen(registry.port, registry.host, () => {
80+
fixture.server.listen(0, registry.host, () => {
8181
fixture.server.removeListener('error', reject);
82+
fixture.port = fixture.server.address().port;
83+
assert(Number.isInteger(fixture.port) && fixture.port > 0 && fixture.port <= 65535);
8284
fixture.listening = true;
8385
resolve();
8486
});
@@ -153,6 +155,7 @@ function assertSuccessfulReadiness(failure, elapsedMs, rows, fixture) {
153155
}
154156

155157
async function assertEvidenceBounds() {
158+
await assertRejectedRegistryPorts();
156159
const valid = Object.freeze({ sequence: 1, startedAt: '2026-10-04T00:00:00.000Z', durationMs: 12,
157160
timeoutMs: 2000, status: 200, aborted: false, phase: 'body-cancel', outcome: 'ready', errorCode: null });
158161
for (const input of invalidEvidence(valid)) {
@@ -171,6 +174,23 @@ async function assertEvidenceBounds() {
171174
await assertEvidenceByteLimit(valid);
172175
}
173176

177+
async function assertRejectedRegistryPorts() {
178+
const fixture = makeFixture();
179+
try {
180+
await listenFixture(fixture);
181+
const invalid = [0, -1, 65536, 1.5, NaN, Infinity, null, {}, String(fixture.port)];
182+
for (const port of invalid) {
183+
await assert.rejects(waitForRegistry(context, port), {
184+
name: 'RangeError', message: 'The owned loopback registry port is invalid.',
185+
});
186+
}
187+
assert.equal(fixture.requests, 0);
188+
await assert.rejects(stat(context.evidenceDirectory), { code: 'ENOENT' });
189+
} finally {
190+
await closeFixture(fixture);
191+
}
192+
}
193+
174194
async function assertEvidenceByteLimit(valid) {
175195
const overflowTemp = path.join(context.runnerTemp, 'evidence-bytes');
176196
await mkdir(overflowTemp, { recursive: true, mode: 0o700 });

0 commit comments

Comments
 (0)