release: 0.17.0 - #320
Merged
Merged
Conversation
The test asserted an exact callback-count equality after only a fixed 50ms grace following StopTimer(). A tick already dispatched when StopTimer was called (at most one) can land after that grace on a slow/loaded runner, incrementing the count and failing the exact-equality assertion. Replaces the fixed grace with a WaitUntilStable poll that returns once the count has been unchanged for a full window — so the last in-flight tick is absorbed regardless of runner speed — then still asserts the count stays put across several further intervals (a running timer would fire ~4 more). Bounded by an overall deadline so a genuinely-broken StopTimer fails the assertion instead of hanging. Verified: 5/5 green in Release. Closes #250 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds tests/…Tests.Unit/Performance/AllocationFreeTests.cs. Some members are read or invoked once per record / progress tick and must not allocate — a change that "looks identical but allocates" silently regresses throughput and GC pressure for high-volume consumers. Each test measures managed allocation on the current thread across a tight loop (GC.GetAllocatedBytesForCurrentThread, available on every TFM net462+/netcoreapp3.1+) and asserts it rounds to zero per call. Documented allocation-free allowlist (asserted): Report.CurrentItemCount / ItemsPerSecond / PercentComplete (double?) / EstimatedRemaining (TimeSpan?), and ExtractorBase.IncrementCurrentItemCount / IncrementCurrentSkippedItemCount (Interlocked). The per-call threshold is a sub-byte epsilon to absorb one-time tiered-JIT recompilation amortised across the loop; a real per-call allocation (boxing, closure) is >=24 bytes and fails cleanly with the actual byte count. Verified: 6/6 green on net10.0 and net48. Closes #217 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an actions/attest-build-provenance step to the release's update-release-artifacts job: each packed .nupkg gets a signed, verifiable SLSA provenance statement (built by this repo's release.yaml, from a specific commit), stored in the repo's attestation store. Grants the job id-token:write + attestations:write for it. Complements the CycloneDX SBOM already shipped. SECURITY.md gains a "Verifying the supply chain" section documenting how a consumer verifies the SBOM + attestation (`gh attestation verify <pkg> --repo Chris-Wolfgang/ETL-Abstractions`), and explicitly flags that Authenticode/NuGet package signing is NOT yet enabled. Partial for #208: SBOM (already) + SLSA attestation are done; package signing (`nuget verify`-able signatures) still needs a code-signing certificate and stays open on #208. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turns the existing (report-only) Stryker setup into an enforced release-gate quality bar. stryker-config.json `break` goes 0 -> 70: dotnet stryker now exits non-zero (failing the job) if the mutation score drops below the floor. The floor sits a few points under the measured full-project baseline (74.4% on Stryker 4.16.0, 2026-07-22) so CI-runner timeout variance doesn't flake it, while a real test-suite regression trips it. stryker.yaml gains a path-scoped pull_request trigger (src/** changes) so a PR that would drop the score below the floor is caught BEFORE merge — scoped to source because a full run takes tens of minutes; docs/test-only PRs skip it. The Stryker install is pinned to 4.16.0 so the gate is reproducible. (Note: this repo is on BannedApi 4.14 / PublicApi 3.3.4, not the 5.6 analyzers, so it is unaffected by the Stryker/CodeAnalysis-5.6 crash that blocks some fleet repos.) docs/mutation-testing.md documents the baseline, the floor, the ratchet-up-only policy, and how to run locally. Partial for #205: the enforced floor + PR gate (the load-bearing "release-gate quality bar") are done. The score-history chart and auto-filing of kind:mutation-survives issues are deferred; reports upload as a workflow artifact meanwhile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes #205's remaining acceptance criteria on top of the enforced floor: - Score history: non-PR (schedule/dispatch) runs parse the final mutation score from the Stryker log and publish it to gh-pages /dev/stryker via github-action-benchmark (customBiggerIsBetter — same action/pin as the BDN charts), one data point per run. Trend visibility only; fail-on-alert:false so it never double-fails the run (the `break` floor is the gate). - Survivor tracking: non-PR runs write the survivor worklist (file:line + mutator, from mutation-report.json) to the job summary and keep ONE rolling kind:mutation-survives issue up to date — deliberately not one issue per survivor (dozens today = noise). Best-effort; never fails the run. The Run step now tees Stryker output with `set -o pipefail` so the gate's non-zero exit is preserved through the pipe. Publish/track steps run only on non-PR events, so the PR gate path is unchanged. Closes #205 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Flips the reproducible-build story to the consumer side: #216 proves our build is reproducible internally; this documents how a third party verifies it independently. - docs/REPRODUCIBLE-BUILD.md — the exact tooling (SDK 10.0.x, ContinuousIntegrationBuild=true), the rebuild command, how to hash the shipped assembly (the deterministic artifact — NOT the .nupkg envelope, whose zip timestamps are non-deterministic), how to compare against the release manifest, how to file a discrepancy, and the third-party attestation conventions (reproducible-builds.org / vouchsafe.io / attest-build-provenance over a rebuild). - release.yaml now generates reproducible-build-manifest.json — the expected sha256 of every shipped TFM assembly, extracted from the .nupkg — and attaches it to the GitHub Release. Verified the extraction/hash logic against the live 0.16.1 package (all 11 TFM assemblies). - README gains a "Verify the build" section linking the procedure. Closes #225 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds tests/Wolfgang.Etl.Abstractions.Tests.Concurrency + .github/workflows/coyote.yaml. Microsoft Coyote rewrites the assemblies to control the task scheduler and replays each test under thousands of distinct interleavings, surfacing races/deadlocks a normal single-schedule run never hits. `coyote test` exits non-zero on a found bug, so it is an enforced gate. Two systematic tests over the base-class concurrency: - concurrent per-item counter increments never lose an update (the counter is Interlocked — a non-atomic ++ would surface as a lost update on some schedule); - DisposeAsync racing an in-flight enumeration never deadlocks. Both explore ~1600 unique execution paths locally with 0 bugs found. The project targets net8.0 because the Coyote 1.7.x CLI cannot load net10.0 assemblies (it predates that runtime); the concurrency explored is runtime-agnostic. coyote.yaml gates PRs touching src/** (1000 iterations/test) and runs the deep exploration weekly + on dispatch (10000 iterations/test). The optional 24h soak (leak hunting via dotnet-counters) is NOT implemented — it needs a persistent self-hosted runner this project lacks; documented in the workflow header for when one exists. Closes #207 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Proves the debugger half of SourceLink end-to-end: sourcelink.yaml already checks that every PDB document resolves to real GitHub content; this drives an actual debugger step-into and asserts it lands in the library's real source, not a decompiled placeholder. .github/workflows/sourcelink-stepinto.yaml (scheduled weekly + workflow_dispatch, not a PR gate) builds a fixture consumer, installs netcoredbg, and runs verify_stepinto.py — which drives netcoredbg over its MI interface to break in the consumer, step into Report's constructor, and assert the frame is the library's SourceLink-mapped source (/_/src/Wolfgang.Etl.Abstractions/Report.cs) with symbols loaded. Uses a ProjectReference built with ContinuousIntegrationBuild=true (byte-identical SourceLink PDB to the shipped package; netcoredbg can't pair a package assembly with its .snupkg PDB) and Debug/Optimize=false so the target isn't inlined. An empty Directory.Build.props/.targets isolates the fixture from repo analyzers. Validated locally end to end (netcoredbg 3.1.2-1054, net10.0): step-into resolved /_/src/Wolfgang.Etl.Abstractions/Report.cs, symbols-loaded=1, RESULT=PASS. Closes #214 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test: de-flake StopTimer_prevents_further_callbacks (#250)
Adds a scheduled GC / allocation profile — the sustained-load metrics BDN micro-benchmarks don't capture (gen0/1/2 promotion, LOH pressure, finalizer-queue depth, thread-pool starvation under continuous ETL traffic). - tools/GcProfileWorkload — a workload that runs an in-memory extract -> transform -> load Pipeline (ExtractorBase -> TransformerBase -> LoaderBase, per-stage progress + stage disposal) in a loop for a configurable duration, under ServerGC. - .github/workflows/gc-profile.yaml (weekly Sunday 07:00 UTC + workflow_dispatch) runs the workload for 10 minutes, attaches dotnet-counters by PID to sample the System.Runtime counter set to a CSV, summarises into the step summary, and uploads reports/ as an artifact. - docs/GC-PROFILE.md documents what runs, how to read the report, and the ratchet policy. Gate mode: informational (no regression gate yet — that needs a stable baseline the first several runs establish; same land-informational-harden-later pattern as reproducible-build/semgrep/stryker). Actions SHA-pinned per the repo convention. Validated locally: the workload builds and runs — ~90M records in 8s with only 2 gen0 collections, 0 gen1/gen2, ~6MB allocated (the streaming pipeline is near-zero-alloc per record, consistent with #217). The regression gate is the documented follow-on once baseline runs accumulate. Closes #212 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test: allocation-free hot-path verification (#217)
…tion ci: SLSA build-provenance attestation at release (#208, partial)
ci: enforce mutation-score floor as a release gate (#205)
…coyote test: concurrency / race-condition stress testing with Coyote (#207)
GC.GetAllocatedBytesForCurrentThread was added to .NET Framework in 4.8, so AllocationFreeTests failed to compile on the net462/net472 targets (CS0117), breaking the Debug solution build. The original PR only validated net10.0 and net48 and missed the older framework targets. Guards the suite with `#if !NET462 && !NET472` — it still runs on net48+ / netcoreapp3.1+ / net5.0+, which is sufficient since the allocation behaviour under test is TFM-agnostic. Verified building net462/net472/net48/net10.0. Refs #217 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tepinto test: SourceLink F11 step-into verification (#214)
ci: sustained-load GC / allocation profiling (#212)
fix: guard allocation-free tests for net462/net472 (#217 regression)
…p (SC2010) actionlint/shellcheck flagged SC2010 (Don't use ls | grep) in the Generate reproducible-build manifest step added for #225. Select the first non-symbols .nupkg with a glob loop + case guard instead, and fail explicitly if none is found. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds behavioural coverage that kills previously-surviving mutants across the base classes, the pipeline, and Report, then locks in the gain by raising the Stryker break threshold from 0 to 80. Base classes (LoaderBase/ExtractorBase/TransformerBase): - per-run reset of the skipped counter and the run-start timestamp - StartedAt is a real, recent capture (not default(DateTimeOffset)) - StartedAt is set on the skip-only path - Dispose()/DisposeAsync() delegate to Dispose(bool) Pipeline / Report: - exact exception-message assertions (one-shot run, WithProgress-once, aggregate disposal, Report argument guards) - EstimatedRemaining "remaining <= 0" boundary with a zero rate - dispose-vs-not branch on the load-progress pipeline path - per-item progress report in the sink Result: mutation score 74.40% -> ~86.6%, survivors 86 -> ~43. The remaining survivors are equivalent mutants (ConfigureAwait(false) with no SynchronizationContext, GC.SuppressFinalize with no finalizer, the self-guarding _disposed flag) and timing-only SystemProgressTimer paths that would require the flaky tests removed in #250. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion-score # Conflicts: # stryker-config.json
…-consumer docs: consumer-side reproducible-build verification (#225)
…core test: raise Stryker mutation score to ~87% and ratchet the gate (#205)
Two more real kills plus documentation of the provably-equivalent mutants that no test can kill. Real kills (tests): - StageList aggregates EVERY stage disposal failure (two throwing stages) — kills the errors ??= List accumulation mutant. - Extraction honours a pre-cancelled token even when the source ignores it — kills the explicit ThrowIfCancellationRequested in the pipeline head. Documented equivalent mutants (// Stryker disable once, base classes): - GC.SuppressFinalize — the types declare no finalizer, so suppressing (or not) finalization is unobservable. - the _disposed guard — the flag has no other reader (nothing throws ObjectDisposedException), so it guards an inert base body. - the EnsureStarted early return — the CompareExchange already guards the single assignment, so skipping the fast-path return changes nothing. Score 86.61% -> 90.12% (over the 90% "high" band). Remaining survivors are ConfigureAwait(false) flips (differ only under a SynchronizationContext, absent in the test host), timing-only SystemProgressTimer paths (see #250), and a few defensive double-guards — left as honest survivors rather than masked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Kills the remaining reachable survivors and documents the rest as provable equivalents. Real kills (tests): - Dispose-stages coverage for the cancellation, progress, and progress+cancellation Extract() factory overloads — each has its own stages array (kills the three array-initializer mutants that the plain IExtractAsync overload's test never reached). Documented equivalent mutants (// Stryker disable, with inline reasons): - base-class Dispose(bool)/EnsureStarted blocks — the bodies are inert (_disposed has no other reader; CompareExchange already guards the single assignment), so removing a block/guard/assignment is unobservable. - ConfigureAwait(false) flips — no SynchronizationContext in the test host. - the paired cancellation ThrowIfCancellationRequested checks in the pipeline head and sink — mutually redundant defence-in-depth. - Report.EstimatedRemaining's >= overflow boundary — unreachable exact equality. Score 86.61% -> 96.68%. The 10 remaining survivors are all real, timing-only behaviour in SystemProgressTimer and the base timer.Start call — deliberately left as honest survivors rather than masked, since a deterministic unit assertion would reintroduce the flakiness removed in #250. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…205) Replaces the ~10 `// Stryker disable` annotations on the ConfigureAwait(false) awaits with a real test that proves the behaviour they protect: the library never marshals a continuation back onto the caller's SynchronizationContext (the property that keeps WPF / WinForms / legacy-ASP.NET consumers safe from the sync-over-async deadlock). ConfigureAwait(false)->(true) is invisible to a headless test host (no context), which is why those mutants survived — but it is NOT equivalent, so disabling them was the weakest part of the previous pass. New ConfigureAwaitContextTests installs a counting SynchronizationContext, starts each operation while it is current, then asserts nothing was posted back. Six tests cover the streaming and disposal awaits across the EtlPipeline sink, the DisposingOwned wrapper, the fluent Pipeline/StageList, and LoaderBase's progress path. A ConfigureAwait(true) mutation posts a continuation to the context and fails the assertion. Result: all ConfigureAwait mutants are now Killed by real tests (16 killed, 1 timeout, 0 survived); the 10 annotations are gone. Score 96.68% -> 97.11%. The remaining 9 survivors are the timing-only SystemProgressTimer / timer.Start behaviour, still left as honest survivors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… tests (#205) Continues turning "disabled" mutants into genuinely tested ones. Removes 7 more // Stryker disable annotations, leaving only mutants that are provably equivalent in every environment. - GC.SuppressFinalize (6 mutants, Dispose + DisposeAsync across the 3 base classes): FinalizationSuppressionTests derives a finalizer-bearing double, disposes it, forces a GC, and asserts the finalizer did NOT run. Dropping SuppressFinalize lets it run and fails. (The base types ship no finalizer, so the normal cross-TFM run always passes deterministically — finalization is suppressed and never runs; the mutant is only exercised under Stryker.) - Pipeline-head cancellation (EtlPipelineImpl.CountExtracted): AsAsyncEnumerable exposes the head with no sink downstream, so its ThrowIfCancellationRequested is the sole guard — AsAsyncEnumerable_honours_cancellation_at_the_head kills the mutant that the sink's paired guard was masking. Still documented as equivalent (no test can kill them in any environment): the base Dispose(bool)/EnsureStarted inert blocks (_disposed has no reader; the CompareExchange already guards the write), the sink's redundant cancellation check (the head guard is always upstream), and Report's unreachable >= overflow boundary. Score ~97% (96.86%; the small delta vs the prior run is timeout variance from the GC tests, not a regression — 7 mutants moved from disabled to killed). 362 tests pass. 10 survivors remain, all timing-only SystemProgressTimer/timer.Start. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…core test: raise mutation score to ~97% — real tests over disables (#205)
…ard (#205) Adds a real dispose contract to the three base classes: once disposed, every public entry point (ExtractAsync / LoadAsync / TransformAsync, all overloads) throws ObjectDisposedException via a new private ThrowIfDisposed(). This gives the Dispose(bool) idempotency flag a real reader, so its guard is no longer "equivalent because _disposed is never read": DisposedGuardTests now kills the guard's negation, the _disposed assignment, and the whole-method-body removal by asserting use-after-dispose throws (every overload, after both Dispose and DisposeAsync) and that Dispose stays idempotent. The 12 ThrowIfDisposed call sites are each covered. Only the guard's `{ return; }` block/return removal stays annotated — dropping it just re-runs the idempotent `_disposed = true`, which is genuinely unobservable. Score ~97% (97.06%), 370 tests. Remaining survivors: SystemProgressTimer/timer.Start timing only. Remaining // Stryker disable: the EnsureStarted early-return, this guard's redundant block/return, the sink's redundant cancellation check, and Report's unreachable overflow boundary — all provable equivalents. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…core feat: throw ObjectDisposedException on use-after-dispose; test the guard (#205)
…205) Audit of the remaining // Stryker disable comments found one that was too broad: the EnsureStarted annotation wrapped the whole `if`, which also disabled the condition's Equality mutant — and that one is genuinely killable. Flipping `Volatile.Read(ref _startTimestamp) != 0` to `== 0` makes the very first call return early, so the start timestamp is never recorded and StartedAt stays null; the existing StartedAt/Elapsed tests catch it. Narrowed the annotation to cover only the fast-path block and its return (which are equivalent — the CompareExchange below assigns only on the winning exchange, so a re-entrant caller changes nothing either way). Stryker confirms the condition mutants are now Killed rather than Ignored. The other remaining disables were re-audited and are genuinely unkillable: the Dispose(bool) redundant early-return, the sink's cancellation check (the CountExtracted head guard is always upstream of CountLoaded via To()), and Report's overflow boundary (>= vs > differ only at an exact-equality point that isn't constructible through the division-of-a-division with int-bounded inputs). Score 97.08%, 370 tests, 9 survivors — all SystemProgressTimer/timer.Start timing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…core test: narrow the EnsureStarted disable so its condition stays tested (#205)
Bumps <Version> 0.16.1 -> 0.17.0 (MINOR: new use-after-dispose behavioural contract) and moves PackageValidationBaselineVersion to 0.16.1, the last published release. Highlights since 0.16.1: - Using a disposed component now throws ObjectDisposedException (the only behavioural change; no public signatures added/removed/altered). - Mutation testing is a release gate at ~97%; the library is verified context-agnostic via a counting SynchronizationContext test. - Concurrency stress (Coyote), allocation-free hot-path verification, GC / allocation profiling, SourceLink step-into verification, and consumer-side reproducible-build verification. - SLSA build-provenance attestation (package signing still open — #208). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
BenchmarkDotNet
Details
| Benchmark suite | Current: 049e61c | Previous: 732efdd | Ratio |
|---|---|---|---|
Wolfgang.Etl.Abstractions.Benchmarks.ExtractorBenchmarks.Extract_NoProgress(RecordCount: 1000) |
34116.18528238932 ns (± 151.90675148325366) |
32688.512502034504 ns (± 213.01288290577438) |
1.04 |
Wolfgang.Etl.Abstractions.Benchmarks.ExtractorBenchmarks.Extract_WithProgress(RecordCount: 1000) |
34949.084309895836 ns (± 247.13274941480623) |
34913.1728108724 ns (± 92.90601829761779) |
1.00 |
Wolfgang.Etl.Abstractions.Benchmarks.ExtractorBenchmarks.Extract_NoProgress(RecordCount: 100000) |
3357865.2057291665 ns (± 2840.119769792778) |
3227835.359375 ns (± 5331.793855491133) |
1.04 |
Wolfgang.Etl.Abstractions.Benchmarks.ExtractorBenchmarks.Extract_WithProgress(RecordCount: 100000) |
3437242.5104166665 ns (± 11841.267991002493) |
3403880.4401041665 ns (± 29793.796946240178) |
1.01 |
Wolfgang.Etl.Abstractions.Benchmarks.PipelineBenchmarks.FluentPipeline(RecordCount: 1000) |
31741.46962483724 ns (± 102.65272885646539) |
31655.328531901043 ns (± 117.35331683022335) |
1.00 |
Wolfgang.Etl.Abstractions.Benchmarks.PipelineBenchmarks.ManualComposition(RecordCount: 1000) |
32482.84462483724 ns (± 145.51635402879006) |
32588.058044433594 ns (± 149.83187729728172) |
1.00 |
Wolfgang.Etl.Abstractions.Benchmarks.PipelineBenchmarks.BaseClassComposition(RecordCount: 1000) |
72131.45817057292 ns (± 261.5144950646714) |
70625.31201171875 ns (± 1269.4931495812575) |
1.02 |
Wolfgang.Etl.Abstractions.Benchmarks.PipelineBenchmarks.FluentPipeline(RecordCount: 100000) |
3945016.59375 ns (± 60435.82406087109) |
3210122.2981770835 ns (± 297.86708111041185) |
1.23 |
Wolfgang.Etl.Abstractions.Benchmarks.PipelineBenchmarks.ManualComposition(RecordCount: 100000) |
3205289.40625 ns (± 940.718313510232) |
3240046.2122395835 ns (± 4670.818271378057) |
0.99 |
Wolfgang.Etl.Abstractions.Benchmarks.PipelineBenchmarks.BaseClassComposition(RecordCount: 100000) |
6818775.815104167 ns (± 15473.015289696179) |
6896450.057291667 ns (± 10313.052399783468) |
0.99 |
This comment was automatically generated by workflow using github-action-benchmark.
Chris-Wolfgang
added a commit
that referenced
this pull request
Jul 24, 2026
Protected-file split for the 0.17.0 release PR (#320), kept to the minimum that must go in under admin bypass. Protected (these are what trip `Detect .NET Projects`): - .github/workflows/{coyote,gc-profile,release,sourcelink-stepinto,stryker}.yaml - .github/sourcelink/consumer/Directory.Build.{props,targets} Supporting non-protected files — ONLY for the two workflows that self-trigger on this PR and would otherwise fail against a main that lacks them: - stryker-config.json (stryker.yaml: pull_request on itself + this file) - tests/...Tests.Concurrency/** (coyote.yaml: pull_request on itself + this dir) Deliberately NOT included: tools/GcProfileWorkload/** and the .github/sourcelink consumer sources. gc-profile.yaml and sourcelink-stepinto.yaml are schedule / workflow_dispatch only, so they do not self-trigger here and need nothing extra; those files ride along in #320 under full CI instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chris-Wolfgang
added a commit
that referenced
this pull request
Jul 24, 2026
Protected-file split for the 0.17.0 release PR (#320). Contains ONLY the files that trip `Detect .NET Projects`, since admin bypass waives every ruleset rule and anything in here lands without review or CI: - .github/workflows/{coyote,gc-profile,release,sourcelink-stepinto,stryker}.yaml - .github/sourcelink/consumer/Directory.Build.{props,targets} No test code, no configs, no source. In particular: - tests/...Tests.Concurrency/** stays in #320 — test code must go through review and CI, not a bypass. - stryker-config.json stays in #320 — it carries the break threshold 0 -> 80, and merging that ahead of #320's test improvements would point an 80% gate at main's ~74% codebase and fail every subsequent PR until the release lands. Consequence: coyote.yaml self-triggers on this PR and on src-touching PRs to main until #320 merges, and fails because the Concurrency project isn't there yet. That check is waived here by the bypass, and #320's own run passes because it carries the project. Merge #320 promptly to close the window. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…250) Stage 3 (macOS) failed on the 0.17.0 release PR: countAfterStop != countAfterWait. StopTimer cannot un-dispatch a callback the thread pool has already queued, and the test's own comment says so ("at most one" late tick) — but it then asserted strict equality, relying entirely on the stability window to absorb that tick. On a starved macOS runner the in-flight callback landed after the 150 ms window closed, so the equality broke. - Assert the contract the comment describes: at most one late in-flight callback. A timer that never actually stopped fires ~6 more over the 300 ms observation window, so a real StopTimer bug still fails loudly. - Widen the stability window 150 -> 400 ms (maxWait 3000 -> 5000) so the common case still absorbs the late tick without needing the grace at all. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This was referenced Jul 24, 2026
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release 0.17.0
Merges
vNextintomainwith the version bump. MINOR — the release introduces one behavioural change (use-after-dispose now throws); no public signatures were added, removed, or altered, so Package Validation passes against the 0.16.1 baseline and no binding redirect is needed (AssemblyVersionstays1.0.0.0).Version
<Version>0.16.1 → 0.17.0<PackageValidationBaselineVersion>0.16.0 → 0.16.1 (last published)The behavioural change
Using a disposed component now throws
ObjectDisposedException. Every public entry point onExtractorBase/LoaderBase/TransformerBase(allExtractAsync/LoadAsync/TransformAsyncoverloads) rejects calls made afterDispose()/DisposeAsync()instead of silently running. Code that reused a component after disposing it previously "worked" and will now throw — construct a new instance per run.Disposeremains idempotent.What else is in it (PRs #307–#319)
breakthreshold; suite at ~97%.SynchronizationContexttest proves no internal await captures the caller's context, so.Result/.Wait()from a UI thread can't deadlock on it.StopTimertiming test (Flaky timing test: SystemProgressTimerTests.StopTimer_prevents_further_callbacks (exact callback count fails on slow runners) #250).Note on the protected-file guard
This diff touches 5 workflows plus
.github/sourcelink/consumer/Directory.Build.*, soDetect .NET Projectswill fail here by design. Same as the 0.16.1 cycle, that needs a separate protected-files PR merged tomainfirst (admin bypass), after which mergingmainback into this branch clears the guard and this PR gets full CI.Closes
Closes #205, closes #207, closes #212, closes #214, closes #217, closes #225, closes #250.
(#208 stays open — package signing deferred.)