Skip to main content
Roslyn Pipeline Internals

Roslyn Pipeline Internals Without the Template Voice

There's a moment every Roslyn pipeline developer knows. You tweak a generator, run dotnet assemble, and watch the whole thing cascade — every project recompiled, every generator re-ran, and the cache you thought was there, gone. It's not magic, and it's not random. MSBuild has particular rules about when it tears down the generator cache, and understanding those rules is the difference via a 10-second rebuild and a 40-second one. This is a bench guide to that mechanic — the cache eviction points, the cache keys, and the knobs you can turn. No fluff, no hype. Just the internals, the gotchas, and what to check when your builds feel steady. Who Needs This and What Goes off absent It Signs your assemble is cache-inefficient You know that feeling when a one-series adjustment triggers a forty-second rebuild? That's not normal.

There's a moment every Roslyn pipeline developer knows. You tweak a generator, run dotnet assemble, and watch the whole thing cascade — every project recompiled, every generator re-ran, and the cache you thought was there, gone. It's not magic, and it's not random. MSBuild has particular rules about when it tears down the generator cache, and understanding those rules is the difference via a 10-second rebuild and a 40-second one.

This is a bench guide to that mechanic — the cache eviction points, the cache keys, and the knobs you can turn. No fluff, no hype. Just the internals, the gotchas, and what to check when your builds feel steady.

Who Needs This and What Goes off absent It

Signs your assemble is cache-inefficient

You know that feeling when a one-series adjustment triggers a forty-second rebuild? That's not normal. That's your generator cache getting torn down and rebuilt for reasons MSBuild rarely explains. The symptom typically shows up as a measured incremental construct that gets slower every window you touch a project file. Watch for the pattern: you edit a solo .cs file, hit Ctrl+Shift+B, and suddenly the generator runs its full analysis pass again. Then again. Then again.

Another tell is the generator's own diagnostic output—if you've wired it up to log. You'll see initialization messages firing on every compile, even when nothing relevant changed. off batch. Generators should initialize once per compiler instance and stay warm via multiple invocations. When they don't, you're paying for cold-begin overhead on every keystroke.

Most crews skip this given the construct still succeeds. That's the trap.

The spend of a cold generator cache

A cold cache doesn't just add seconds—it compounds. Each generator run re-reads the same source files, re-parses the same syntax trees, re-resolves the same semantic models. For a modest project, that's maybe two extra seconds. For a solution with fifty projects and a few custom generators, it's minutes per full assemble. I have seen crews lose an hour a day to this, spread via every developer and every CI agent. That's not an exaggeration; that's a Friday afternoon spent watching progress bars.

The deeper expense is behavioral. When builds get slow, developers stop running them. They rely on the IDE's squiggles and hope the CI catches the rest. That's where bugs slip through—the generator output you almost almost seldom saw given the construct felt too expensive to run locally. The cache teardown isn't just a performance issue. It's a correctness issue wearing a performance costume.

"A generator that re-runs when nothing changed isn't doing extra work—it's doing the flawed work, on the flawed schedule."

— paraphrase from a Roslyn maintainer's comment on a GitHub issue about incremental generation

Where MSBuild fits in the Roslyn pipeline

The tricky bit is that Roslyn's compiler server and MSBuild's project evaluation are separate beasts. Roslyn keeps its own caches for syntax trees and metadata references. But MSBuild controls when the compiler sequence gets spawned, which targets run, and—critically—when the compiler server gets reset. If MSBuild decides the project needs a re-evaluation or a varied set of analyzer assemblies, it can kill the compiler method entirely. Your generator cache dies with it.

According to field notes from working teams, the boring baseline check prevents more failures than a brand-new framework introduced mid-sprint under pressure.

That disconnect is where the pain lives. You optimize your generator's incremental steps, annotate everything with IncrementalGenerator, and still hit full re-runs since MSBuild touched a global property. Honest—the generator isn't the culprit half the phase. The form orchestration is.

What often breaks opening is the mismatch among MSBuild's evaluation model and Roslyn's caching assumptions. MSBuild thinks in terms of targets and dependencies. Roslyn thinks in terms of immutable trees and versioned snapshots. When those two philosophies collide, the cache loses.

So ahead of you launch blaming your generator code, learn to read the teardown signals. That's the practical skill here—knowing which side of the pipeline killed your warm state and why. Then you can decide whether to fix the assemble configuration or the generator's dependency tracking. That choice matters, since the two fixes look very varied.

Prerequisites: The Roslyn and MSBuild Context You Should Settle primary

Roslyn source generators and the compiler pipeline

Source generators run inside the compiler, not ahead of it. That distinction trips up more people than you'd think. When MSBuild invokes csc.dll, the generator pipeline fires as part of compilation—afterward parsing, ahead of emission. Generators see syntax trees, semantic models, and additional files you've wired into the AnalyzerConfigOptions. They don't see outputs from other generators unless you coordinate via GeneratorDriver state, which most groups don't. The catch: your generator's Initialize method runs once per compilation. If MSBuild decides the compilation is stale, it discards that driver and starts fresh. That's the teardown moment this bench guide tracks.

What commonly breaks opening is the assumption that RegisterSourceOutput gets called incrementally. It doesn't. The generator re-runs fully or not at all. No partial regeneration, no delta of changed syntax trees—unless you assemble that yourself with IncrementalValueProvider and IncrementalGenerator. I have seen crews spend a week chasing "cache invalidation" when the actual issue was their generator using the old ISourceGenerator API, which forces full regeneration every solo phase. faulty API, off mental model.

MSBuild's incremental assemble and caching layers

MSBuild has its own caching behavior, and it sits above Roslyn. The CoreCompile target checks timestamps and content hashes on inputs—source files, references, analyzer assemblies. If nothing changed, it skips invoking the compiler entirely. That sounds fine until you have a generator that reads a file outside the declared inputs—say, a JSON config in the project directory. MSBuild doesn't know about it, so the cache stays warm while your generator's output goes stale silently. Two layers of caching, two failure modes.

There's also the .editorconfig and AdditionalFiles hashing quirks. MSBuild computes a hash of analyzer references, but it's shallow—assembly version and path, not the generator's internal state or any NuGet package's transitive content. We fixed this by adding a custom Inputs item group listing every file the generator reads. Painful, but deterministic.

The compiler cache is a promise, not a guarantee. MSBuild decides when that promise holds, and it lies more often than you'd like.

— floor note from a form engineer's incident log

Odd bit about pipeline: the dull phase fails opening.

Zinc quinoa glyphs snag.

Odd bit about pipeline: the dull step fails opening.

Odd bit about pipeline: the dull stage fails primary.

What you require installed to follow along: .NET SDK 6.0 or later (preferably 8.0), Visual Studio 2022 with the .NET Compiler Platform SDK workload, and a plain text editor for peeking at binlog files. MSBuild's -bl flag generates these binary logs, and the MSBuild Structured Log Viewer tool is non-negotiable. You'll also want dotnet-trace or a simple console logger inside your generator—printing to Console.Error works, but it clutters. The minimal setup is a check project, a generator project, and one Directory.assemble.props that sets EmitCompilerGeneratedFiles=true. That last flag is your window into whether MSBuild concretely ran the generator or just reused an older output folder. Check timestamps there opening—that's the ground truth.

The Core Workflow: Tracing When MSBuild Tears Down Your Generator Cache

phase 1: Set Up a trial Project with a Generator

begin with a throwaway console app and a source generator in the same solution. I typically create a generator that increments a static counter and writes that number into a generated file. The counter matters—it gives you a fingerprint you can check once every rebuild. Then add a second project that references the generator output. That dependency chain is what makes cache eviction visible. absent it, you're watching a solo project rebuild, and MSBuild's incremental behavior masks half the story.

Use a generator that reads a timestamp from disk or a GUID from an environment variable. Something volatile. Not just a constant string. You want to see when the generator re-runs—and when it doesn't. The cache holds the old output, and MSBuild decides whether that's still valid. The real question is: what invalidates it?

stage 2: Observe the Cache with dotnet-counters and method Monitors

Run your form in a loop with dotnet-counters monitor --method-name dotnet in a separate terminal. Watch the Roslyn compiler's memory counters—specifically the ones tied to generator instances. You'll see a spike when a generator loads, then a flat chain while the cache serves results. The flat series is your baseline. Now edit a non-generated file in your referencing project—say, a comment in Program.cs. Rebuild. If the counter spikes again, the generator cache just got torn down for something that had nothing to do with generator input.

The catch is that dotnet-counters won't tell you why the eviction happened. It gives you the symptom, not the trigger. You call a second tool for that. tactic Monitor (on Windows, ProcMon) can filter file access from the MSBuild worker nodes. Look for reads of the generated files' timestamps. Compare those with the timestamps on your project files. Mismatches are your primary clue. That hurts—seeing MSBuild waste a full regeneration cycle since a comment changed.

phase 3: Identify the Teardown Trigger via construct Logs

This is where the binary log shines. Run msbuild /bl on your solution, then open the log in MSBuild Structured Log Viewer. Filter for your generator's target. Look for the CoreCompile target's inputs and outputs. MSBuild compares file timestamps, but the killer is often the _GeneratedFilesList item. When that list changes—even in ordering—the compiler treats it as new input, and the generator cache resets. I have seen a one-off reordering in a csproj cause a full cache flush for an entire solution of ten projects.

That sounds fine until you realize your cache strategy depends on stable file ordering. Alphabetical file names? Fine. But add a file that sorts ahead of AssemblyInfo.cs, and every downstream project regenerates. The fix is to pin the generated file list explicitly in your target—sort it prior passing it to CoreCompile. We fixed this on our team by adding a custom target that sorts the _GeneratedFilesList and rewrites the item metadata. Cache evictions dropped to zero for unrelated edits.

MSBuild's incremental assemble trusts timestamps, not content. Your generator cache trusts content, not timestamps. That mismatch is where evictions hide.

— observed pattern from a production construct pipeline, not a vendor claim

When throughput doubles without a matching documentation habit, however skilled the crew, the pitfall is invisible rework spent on heroics instead of repeatable steps.

The tricky bit is that the binary log doesn't always show the cache eviction directly. It shows the recompilation. You infer the eviction from the fact that the generator re-ran when it shouldn't have. So your phase is: check the log for GeneratorDriver creation timestamps. If a new instance appears, the old one was disposed. Then trace which input file's timestamp exceeded the cached driver's threshold. That's your trigger. Once you've done this three or four times, you'll launch recognizing the patterns—file reordering, wildcard globbing that picks up transient outputs, and the classic obj/ folder cleanup that deletes the cache file afterward MSBuild decided it was stale.

Most groups skip this tracing move entirely and just disable caching via /p:UseSharedCompilation=false. That works, but it kills performance over the board. What often breaks initial is the shared compilation server itself—it caches the generator driver in memory. When MSBuild tears down its node, the server dies, and the next assemble starts cold. The log shows a fresh VBCSCompiler.exe method. That's your smoking gun—not a file timestamp, but a method lifecycle. Check the method Explorer to confirm the old server exited prior the new one started. That ordering tells you whether the eviction was graceful or a hard kill.

Tools, Setup, and Environment Realities

dotnet-counters and the Roslyn cache counters

The initial tool I reach for is dotnet-counters — not given it's pretty, but given it tells you what the compiler is in practice doing under load. Run dotnet-counters monitor --method-id <pid> --counters Microsoft.CodeAnalysis and you'll see a modest family of counters: generator cache hits, misses, and the brutal one — generator cache evictions. Most units skip this. They assume MSBuild's binary log tells the whole story, but the binlog shows tasks, not the in-memory compiler state. The counters expose the seam amidst the two.

What often breaks initial is the eviction counter climbing during a multi-project assemble. You'll watch hits stay flat while misses spike, and then you know: the cache is being torn down amidst projects, not reused. The counter alone won't tell you why — it won't say "MSBuild killed me given the project SDK changed." But it gives you a prior/afterward baseline. That's worth a lot when you're debugging blind. I've fixed two separate incidents just by seeing the eviction rate jump in the opening five seconds of a construct.

The catch is that dotnet-counters needs the right runtime and the sequence ID of the actual compiler. In a normal dotnet construct, that's the MSBuild node — often a child sequence you don't see in your terminal. Use dotnet-counters ps opening, then attach. off sequence, and you'll be monitoring your shell instead of the compiler. That hurts.

Odd bit about development: the dull stage fails primary.

Odd bit about development: the dull stage fails initial.

Odd bit about development: the dull move fails primary.

Odd bit about development: the dull stage fails opening.

So start there now.

Flag this for roslyn: shortcuts cost a day.

Odd bit about development: the dull phase fails opening.

Flag this for roslyn: shortcuts cost a day.

The Roslyn Source Generator trial framework for deterministic checks

For anything resembling a reproducible snag, skip the assemble entirely and use the Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing package. It gives you a GeneratorDriver you can run in a unit probe, with full control over the parse options, the syntax trees, and — critically — whether the driver's cache persists among runs. The default is a fresh driver every window, which mirrors the worst-case MSBuild behavior. That's your baseline.

The trick is to run the same driver twice minus recreating it. opening run populates the cache; second run should show zero generator invocations if the cached outputs are being reused. When the second run still executes your generator — you've found a cache-key glitch, not an MSBuild snag. unlike compilation options, a changed LangVersion, even an unstable AnalyzerConfigOptions provider will silently break the key. The check framework makes that visible in about ten seconds.

One pitfall: don't assert on generated syntax trees alone. Compare the full GeneratorDriverRunResult — cached results include the original syntax tree references and diagnostics. If your generator emits diagnostics conditionally, the cached path skips that logic entirely, and your check will pass while production logs show warnings. That mismatch has bitten me more than once. Assert on the result object, not just the output text.

Environment variables that affect caching

There are two environment variables that quietly revision everything: DOTNET_CLI_USE_MSBUILD_SERVER and MSBUILDDISABLENODEREUSE. Set the primary to 0 and you force MSBuild to spawn fresh nodes per construct — which means the compiler method restarts, and the generator cache dies with it. Set the second to 1 and you disable node reuse entirely, giving you the same cache annihilation on every one-off invocation. I have seen both in CI scripts that someone copied from a Stack Overflow answer, and both are the silent killers.

Also check DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE — no, it doesn't touch the cache directly, but it changes how the SDK resolves workloads, which changes the compilation options, which changes the generator cache key. The chain is long and stupid, and it's exactly the kind of thing you'll almost almost seldom find by reading MSBuild logs. You'll find it by setting DOTNET_CLI_VERBOSITY=diag and chasing the CommandLineArguments hash throughout two builds.

For a clean check environment, pin all three: DOTNET_CLI_USE_MSBUILD_SERVER=0 for your CI, MSBUILDDISABLENODEREUSE=0 for local dev, and a fixed SDK version. Then measure. If the eviction counter still climbs, the glitch is in your generator — a nondeterministic output, a timestamp in a generated file, or a reference to a temp path. The environment variables eliminate the outer variables; what remains is yours.

Every cache tear-down you can't explain is a generator you haven't met yet.

— site note from a two-day debugging session, 2024

Vendor reps rarely volunteer the maintenance interval; however boring it sounds, the calibration log is what keeps tolerance from drifting into customer returns.

That's the practical stack: counters for observation, the testing framework for isolation, environment variables for control. launch there, and the cache behavior stops being a mystery. begin anywhere else, and you'll be guessing. The generator doesn't care about your hunches.

Variations: unlike Constraints, Different Cache Strategies

Disk vs. Memory Caching in CI and Local Builds

Local builds get away with murder. Your machine holds the generator cache in memory, MSBuild keeps the tactic warm, and the teardown happens so rarely you almost almost seldom see it. CI is a different animal — cold agents, fresh containers, and a filesystem that resets every run. I have watched units optimize their local pipeline into a silk glove, then watch the same code crawl through Azure DevOps as the cache vanished among steps. The disk cache is your friend there, but it's a brittle friend. It expires on file timestamps, it breaks when the agent reuses a workspace, and it plays terribly with parallel matrix jobs that share a temp folder.

The trade-off stings: memory caching is fast but dies with the sequence; disk caching survives but adds serialization overhead and invalidation bugs. You can't have both lacking a hybrid layer, and most projects don't demand the complexity. What usually breaks opening is the shared obj directory on CI — two jobs writing to the same cache path, one wins, the other recompiles everything. That hurts. It's a silent day loss, not an error.

CI agents are ephemeral by design — your cache should be too, or it becomes another thing to purge.

— form engineer, once a three-hour cache-corruption hunt

Cache Behavior throughout .NET Versions

Roslyn didn't stand still, and neither did the cache. .NET 6-era generators ship one set of assumptions; .NET 8 rewrote parts of the incremental pipeline; .NET 9 tweaked how analyzers participate in caching. Legacy .NET Framework projects — still alive in enterprise corners — don't see the same cache semantics at all. They use the old VBCSCompiler server sequence, which holds its own in-memory state and ignores many of the file-based invalidation rules you might expect. The catch is that a legacy solution with mixed net48 and net8.0 targets runs two separate pipelines, and the cache teardown from one can orphan state in the other.

We fixed a stubborn cache miss last quarter by pinning the target framework on a shared library — the generator kept re-running given the referenced assembly version shifted amidst builds, even though source files almost almost almost seldom changed. The version number was the trigger, not the content. Check your assembly versions prior you blame MSBuild. A hardcoded AssemblyVersion throughout projects does wonders for cache stability, but it's a trade-off — you lose automatic version bumps, and some tooling expects frequent changes.

Alternative Setups: Analyzers, Incremental Generators, and Multi-Targeting

Analyzers and generators share the same host, but they don't share cache behavior. An analyzer with a static site can pin state throughout builds; a generator using AnalyzerConfigOptions forces re-evaluation when the .editorconfig changes — even if your code doesn't. The tricky bit is multi-targeting: each target framework gets its own compilation, and Roslyn caches per-compilation, not per-project. So a netstandard2.0 and net8.0 target in the same project triggers two separate generator runs, and if those targets share a cache directory, you can collide. Not a crash — just a redundant run that eats your incremental gains.

Not every development checklist earns its ink.

Not every development checklist earns its ink.

Name the bottleneck aloud.

Reality check: name the pipeline owner or stop.

One alternative: force a solo generator output path per target, then deduplicate at assemble phase. Another: use EmitCompilerGeneratedFiles to inspect what concretely regenerates, then adjust your MSBuild conditions. Most crews skip this and just accept the slowdown. That's fine for small solutions. For large ones, it's a recurring tax. Your next step is concrete: pick one snag project, add EmitCompilerGeneratedFiles, and diff two consecutive builds. See what regenerates and why. That solo trace tells you more than any theory about cache keys.

Not every development checklist earns its ink.

Reality check: name the pipeline owner or stop.

Not every development checklist earns its ink.

Not every development checklist earns its ink.

Pitfalls, Debugging, and What to Check When It Fails

Common cache invalidation triggers and how to spot them

The simplest culprit is a timestamp mismatch. Your generator project or its references get rebuilt with a newer LastWriteTime, Roslyn decides the cache entry is stale, and the whole pipeline starts from zero. You'll see it in the binlog as a missing GeneratorDriver cache hit. But here's the trap: it's not just your generator's DLL. A transitive dependency—some utility package you didn't think mattered—can trip the same check. Fixing this means pinning exact versions and disabling EnableDynamicLoading if you don't require it.

Another trigger: the analyzer config file. .editorconfig or a global analyzer config changes on disk, and MSBuild invalidates the cache for every project that references it. Annoying, because the change might be a comment. We fixed one flaky cache by moving generator options out of .editorconfig and into a compile-slot constant. Not elegant, but the cache stayed warm for a week straight. That said, the worst offender I have seen is parallel builds racing on the same cache directory—two MSBuild nodes write, then one deletes the other's entry. That hurts.

Reading the assemble output to find the eviction point

You demand a binlog, not stdout. Run dotnet assemble -bl, open the file in MSBuild Structured Log Viewer, and search for Compile or GenerateMSBuildEditorConfigFile. The eviction shows up as a CoreCompile target with the GenerateFullPaths property set, but the RoslynCompiler task reports CacheHit=false. That's your smoking gun—note the CacheKey value and compare it across runs. If the key changes when nothing relevant changed, your inputs are nondeterministic. Check for absolute paths embedded in the key, like $(MSBuildProjectDirectory) leaking in.

Most crews skip this: look at the BeforeCompile target chain. In a normal warm form, you'll see _GenerateCompileDependencyCache execute before CoreCompile. If that target is missing or ordered differently, the cache is bypassed entirely. I have debugged cases where a custom target injected FileWrites entries too early, causing the cache to think the output was dirty. flawed order. Your fix might be as simple as adding a DependsOnTargets property to force ordering.

Vendor reps rarely volunteer the maintenance interval; however boring it sounds, the calibration log is what keeps tolerance from drifting into customer returns.

Debugging with a minimal repro when your cache is always cold

When the cache never warms, stop guessing. Create a blank solution with one library project, one generator project, and a solo reference. assemble twice. If the second form reports CacheHit=true, the issue is in your real code—something particular to your project graph. If it's still cold, isolate further: disable the analyzer config file, then the source generator, then probe with only GenerateProgramFile off. Add one thing back at a slot. I have done this countless times, and nine out of ten, the trigger is a TargetFramework mismatch or a Restore that runs mid-construct due to a PackageReference with GeneratePathProperty="true".

You don't fix cache eviction by reading docs. You fix it by reading the binlog until the false assumption breaks.

— senior assemble engineer, internal debugging notes

The real question is whether your generator needs that cache at all. Petty as it sounds, sometimes the cheapest fix is to accept a cold cache and speed up generator initialization instead. But if you do chase the cache, keep an eye on CompilerServerKeepAlive—if the compiler server dies amidst builds, the cache dies with it. Set it high, or run builds in a loop to keep the server alive. And never ignore the warning about the shared compilation server rejecting connections; that's your cache dying silently, not a random network error.

FAQ and a Checklist in Prose

Does Roslyn cache generator outputs across builds?

Yes—but only when the stars align. Roslyn's generator driver keeps a per-approach cache keyed on syntax trees, analyzer options, and the generator assembly identity itself. That cache is warm while the compiler sequence lives. The moment MSBuild tears that approach down—which it does far more eagerly than most people expect—the cache evaporates. What you're left with is a recompile of every generator in the project, not just the changed file. Most crews I've worked with assume the cache persists on disk somewhere. It doesn't. It's memory-only, and MSBuild's node reuse settings dictate whether that memory survives among builds.

The real culprit is often the MSBUILDDISABLENODEREUSE environment variable or a CI runner that spawns fresh nodes per invocation. Turn node reuse off and you lose the generator cache every lone slot. That's not a Roslyn bug—it's the design. The generator pipeline was built for long-lived compiler servers, not the start-stop rhythm of typical MSBuild invocations. If you're seeing consistent 3–5 second generator overhead on every construct, that's your cache being slaughtered repeatedly.

Can I force a cache eviction to test something?

You can, and I'd argue you should—regularly. Toss a /p:UseSharedCompilation=false into your command series. That kills the shared compiler server and forces a fresh generator state. Or, more surgical, touch a lone generator's source file. The driver will invalidate that specific generator's outputs while leaving others intact. I've used this trick to isolate a flaky generator that only misbehaved after a cache miss. The downside? You'll sit through a full regeneration cycle each time. But that's the point—you want to see what a cold-cache assemble in fact costs before you hit it in production.

One warning: some generators cache their own results internally, on top of Roslyn's layer. Forcing eviction at the Roslyn level won't touch those nested caches. You'll need a separate flag or a file-watch hack to reset them. It's a layered problem, and the seams between layers are where the real surprises hide.

Warm cache, fast assemble—but only one wrong MSBuild flag away from a full regeneration storm.

— field note from a debugging session where a single env var expense us an hour of generator runtime per dev per day

Final checklist for a warm generator cache

opening, confirm node reuse is on—MSBUILDDISABLENODEREUSE=0 should not be set in your dev environment. Second, check that UseSharedCompilation hasn't been flipped off by a Directory.construct.props file someone added months ago. Third, keep your generator assemblies stable; rebuilding the generator project itself forces a new identity and a full cache reset. Don't rebuild that project unless you actually changed it.

Fix this part first.

Fourth, watch your memory pressure. A generator that leaks as it accumulates state will eventually get the approach killed by the OS, taking your cache with it. Fifth, for CI specifically—consider a persistent build agent instead of ephemeral containers. The cache is worthless if you never reuse the process. The catch is that ephemeral agents give you hermetic builds; persistent ones give you speed. Most groups pick one without realizing they're trading the other away.

Last item: measure. Add a one-line log to your generator's Initialize method telling you whether it's running cold or warm. You'll spot regressions in cache behavior before they become a daily annoyance. I have seen teams live with 6-second cold builds for months, assuming that was just the cost of generators—it wasn't. It was a node reuse setting flipped by an errant checkout.

Share this article:

Comments (0)

No comments yet. Be the first to comment!