Skip to content

feat: leg 5 of the #890 canary — the pristine-COMPILER control (compiler=PRIVATE-COPY-EMITS / THREW) - #4165

Merged
rbuergi merged 2 commits into
mainfrom
feat/private-roslyn-copy-leg
Sep 13, 2026
Merged

rbuergi merged 2 commits into
mainfrom
feat/private-roslyn-copy-leg

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Builds the control #890 has been waiting for since its 2026-09-10 specification and its 2026-09-12 supersession check ("the decisive control is still not implemented"). Does not close #890 — the closing condition is the READING of this leg on an occurrence.

What it is

PrivateRoslynCopy.Emit — leg 5 of the emit canary, the pristine-COMPILER control. Legs 1–4 all execute the one shared Microsoft.CodeAnalysis*.dll (same mapped image, same native code, same statics), so none of them can separate "the process cannot emit" from "the shared copy of Roslyn cannot emit" — which is why BELOW-ROSLYN has been reading "not the references and not the source" as "not Roslyn" on every occurrence.

On the same two verdicts legs 3 and 4 ride (BELOW-ROSLYN, DIVERGENT), it loads Microsoft.CodeAnalysis + Microsoft.CodeAnalysis.CSharp a second time — from freshly read bytes into a collectible AssemblyLoadContext that serves the two to each other and lets every other dependency fall through to the default context — drives EmitCanarySource through that private copy by reflection against an image-backed CoreLib the private copy builds itself, and unloads.

compiler= meaning
PRIVATE-COPY-EMITS a never-executed Roslyn emits the source the shared one cannot, same process, same CLR ⇒ the broken state lives IN the shared copy (a static, a cache, a miscompiled method); scope the compiler out per generation — not a runtime bug
PRIVATE-COPY-THREW … at … even fresh compiler code cannot emit ⇒ BELOW-ROSLYN earned; the private copy's frame is the repro to file
PRIVATE-COPY-DIAGNOSTICS(…) compiled and refused — a shape no other leg has produced; read the ids
PRIVATE-COPY-UNAVAILABLE(…) / NOT-RUN the leg could not run / no probe — stated, never folded into a verdict

Cost: two loads (~15 MB read), one tiny emit, one Unload(), on the already-failing terminal path only. Measured ~1 s on a healthy process including both loads.

Test

EmitCanaryPrivateCompilerLegTest: on a healthy process the reading is PRIVATE-COPY-EMITS, twice in a row (the context is re-created per run) — a control that cannot run would retire the discriminator silently, and a Roslyn upgrade that reshapes Create/Emit now reds this test instead of turning the next occurrence into UNAVAILABLE. Both verdict shapes render the compiler= reading; its absence is named; a faulting probe reports UNAVAILABLE. The existing canary suites (EmitCanaryControlTest, EmitCanaryFlatLegTest, EmitCanaryDissectionTest) stay green.

The reflection binds to the private copy's own types by full name and fills every optional parameter with its declared default, so it names nothing of Roslyn's optional-parameter surface.

Doc: Doc/Architecture/NodeTypeCompilation → "Leg 5 — the pristine-COMPILER control". What's New: Feature.

Pairs-with: none — a new internal class and an optional parameter on an internal method.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BRLdhAiX5DjdZC2WUfPg9E

Legs 1–4 all execute the one shared Microsoft.CodeAnalysis*, so none can separate "the process
cannot emit" from "the shared copy of Roslyn cannot emit"; BELOW-ROSLYN has been read on every
occurrence from evidence that only ever showed "not the references" and "not the source". The
2026-09-12 check on #890 named this control as the decisive one still unbuilt.

PrivateRoslynCopy.Emit loads Microsoft.CodeAnalysis and Microsoft.CodeAnalysis.CSharp a second
time from fresh bytes into a collectible AssemblyLoadContext (the two serve each other; every
other dependency falls through to the default context), drives the canary source through that
private copy by reflection against an image-backed CoreLib the private copy builds itself, and
unloads. compiler=PRIVATE-COPY-EMITS ⇒ the broken state lives IN the shared compiler, not below
it; PRIVATE-COPY-THREW@frame ⇒ the process cannot emit at all and BELOW-ROSLYN is earned;
UNAVAILABLE ⇒ the leg could not run, stated rather than folded. It rides the same two verdicts
legs 3 and 4 ride (BELOW-ROSLYN, DIVERGENT), on the already-failing path only.

EmitCanaryPrivateCompilerLegTest pins that a healthy process reads PRIVATE-COPY-EMITS twice in
a row (~1 s incl. both loads) — a control that cannot run would retire the discriminator
silently — and that both verdict shapes render the compiler= reading, name its absence, and
report a faulting probe as UNAVAILABLE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRLdhAiX5DjdZC2WUfPg9E

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved moderate issues remain in integration, cancellation, probe classification, and test coverage.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds leg 5 of the emit canary: a collectible, private Roslyn compiler probe for diagnosing shared compiler failures.

Changes:

  • Implements isolated Roslyn loading and reflective emission.
  • Integrates compiler= readings into emit-failure verdicts.
  • Adds tests, architecture documentation, and a What's New entry.
File summaries
File Summary
test/MeshWeaver.Compiler.Pipeline.Test/EmitCanaryPrivateCompilerLegTest.cs Tests private emission and verdict rendering.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-13-a-failed-compile-now-checks-a-fresh-copy-of-the-compiler.md Documents the feature announcement.
src/MeshWeaver.Documentation/Data/Architecture/NodeTypeCompilation.md Documents leg 5 behavior and diagnostic readings.
src/MeshWeaver.Compiler/PrivateRoslynCopy.cs Implements private Roslyn loading and emit probing.
src/MeshWeaver.Compiler/EmitPipeline.cs Integrates the new canary leg into the emit pipeline.
Review details

Suppressed comments (10)

src/MeshWeaver.Compiler/EmitPipeline.cs:446

  • This wires leg 5 only into ProbeSharedEmitState, which is reached from the disk-emission catch. The in-memory path (EmitToMemory) still calls compilation.Emit directly without invoking the canary, so EnableDiskCache=false deployments/tests can still lose a compiler throw without any compiler= reading. Route the same probe through the memory emit path while preserving the existing failure funnel.
            () => PrivateRoslynCopy.Emit(EmitCanarySource));

src/MeshWeaver.Compiler/EmitPipeline.cs:867

  • This text repeats the same overstrong inference: a fresh copy emitting only shows that this loaded/compiler-code path differs from the shared one; it does not rule out a CLR/JIT failure because both copies execute on the same runtime. The existing Leg 5 residual discussion later in this document already acknowledges that distinction, so this wording would send triage in the wrong direction.
            + "🚨 THEN READ compiler=: it is the one leg that does not run the shared Roslyn — "
            + "PRIVATE-COPY-EMITS means a fresh copy of the compiler emits this very source in this "
            + "very process, so the fault is in the shared copy's state or code and NOT below "
            + "Roslyn after all; PRIVATE-COPY-THREW at the same frame is what earns this verdict. "

src/MeshWeaver.Compiler/EmitPipeline.cs:870

  • The new leg is appended to a canary=BELOW-ROSLYN result, but IsProcessEmitFailure only checks that prefix (EmitPipeline.cs:284-287). Thus compiler=PRIVATE-COPY-EMITS, PRIVATE-COPY-DIAGNOSTICS, or PRIVATE-COPY-UNAVAILABLE still causes RunCompile to log PROCESS CANNOT EMIT and stamp the compile unavailable, contrary to the leg's stated semantics. The classifier and its tests must consume the compiler reading before treating this as a process-wide failure.
            + Dissection(dissect)
            + " " + Flatness(flat, sharedSite)
            + " " + PrivateRoslynCopy.Render(compiler);

src/MeshWeaver.Compiler/PrivateRoslynCopy.cs:62

  • PrivateRoslynCopy.Emit has no cancellation input, so this reflection path supplies defaults to the private Emit call (including CancellationToken.None when that overload has a token) and cannot observe the active compile cancellation. Because it runs synchronously while handling the emit exception, a hung file read or private emit can outlive RoslynCompileTimeout and leave a dedicated compile thread/ALC pinned per occurrence. Thread the active cancellation token through this diagnostic or otherwise put the probe behind a bounded owner.
    internal static string Emit(string source)

src/MeshWeaver.Compiler/PrivateRoslynCopy.cs:99

  • The empty catch hides a failed collectible-context unload and still returns the compiler result as if cleanup succeeded. This probe runs for every qualifying failure, so an unload failure can retain the private Roslyn context and its assembly graph across repeated occurrences with no diagnostic evidence. Surface the cleanup failure separately (or log it) instead of silently discarding it.
            try { context.Unload(); }
            catch { /* best-effort: a copy that will not unload costs memory, not correctness */ }

src/MeshWeaver.Compiler/PrivateRoslynCopy.cs:229

  • The fallback for a parameter without a declared default manufactures default(T)/null even when the parameter is required. If a Roslyn upgrade adds a required argument to one of these reflected APIs, the probe can invoke it with an invented value and report a misleading emit/diagnostics result instead of becoming unavailable; reject unsupported required parameters so the healthy-process guard cannot silently validate a changed probe.
                : p.ParameterType.IsValueType
                    ? Activator.CreateInstance(p.ParameterType)
                    : null;

src/MeshWeaver.Compiler/PrivateRoslynCopy.cs:173

  • A successful emit from a fresh collectible ALC does not prove the fault is not below Roslyn: both copies still share the CLR/JIT, while the private copy simply gets a separately JIT-compiled method. A runtime/JIT defect affecting the already-JITed shared method can therefore produce this result, so this message must not direct triage away from dotnet/runtime.
            return $"{Prefix}PRIVATE-COPY-EMITS — a copy of Roslyn loaded from fresh bytes into its own "
                + "collectible context, never executed in this process before, emits the same source in "
                + "the same process on the same CLR ⇒ the broken state lives IN the shared compiler (a "
                + "static, a cache, a miscompiled method), not below it; scope the shared compiler out "
                + "per generation, and do not file this against the runtime";

src/MeshWeaver.Documentation/Data/Architecture/NodeTypeCompilation.md:1380

  • The documented output shape does not match PrivateRoslynCopy.Emit: the implementation emits compiler=PRIVATE-COPY-THREW {Type} at {Frame}: {Message}, not compiler=PRIVATE-COPY-THREW@<frame>. Since this page is the triage contract, document the actual token so readers and parsers do not search for a string that is never produced.
or `compiler=PRIVATE-COPY-THREW@<frame>` on the same line as `canary=`, `dissect=` and `flat=`.

src/MeshWeaver.Documentation/Data/Architecture/NodeTypeCompilation.md:1361

  • This table says PRIVATE-COPY-EMITS rules out the CLR/runtime, but the private and shared copies still share the CLR/JIT and the private copy starts with different JIT state. That contradicts the residual caveat in the existing Leg 5 section at NodeTypeCompilation.md:1621-1626 and can misdirect the incident response; describe this as evidence about the shared Roslyn load/code path, not proof against a runtime/JIT cause.
| `compiler=PRIVATE-COPY-EMITS` | a never-before-executed Roslyn emits the source the shared one cannot, same process, same CLR | 🚨 **the broken state lives IN the shared copy** — a static, a cache, a miscompiled method — not below it. `BELOW-ROSLYN` was NOT earned. The remedy is to scope the shared compiler out (a per-generation compiler context); a `dotnet/runtime` report is not what the evidence supports |

test/MeshWeaver.Compiler.Pipeline.Test/EmitCanaryPrivateCompilerLegTest.cs:28

  • This test only checks the returned token, so it would still pass if Emit were changed to use the default Roslyn assemblies and return the same success text; it never verifies that the emitted types belong to a non-default collectible load context. The test name promises that invariant, so add an observable identity/context assertion or an internal seam that makes the private assembly identity testable.
        var verdict = PrivateRoslynCopy.Emit(
            "public class MwEmitCanary<T> { public class Inner<U> { public class Leaf<V> "
            + "{ public T A; public U B; public V C; } } }");
        output.WriteLine(verdict);

        verdict.Should().StartWith(PrivateRoslynCopy.Prefix + "PRIVATE-COPY-EMITS",
  • Files reviewed: 5/5 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +87 to +90
catch (TargetInvocationException reflective) when (reflective.InnerException is { } inner)
{
return $"{Prefix}PRIVATE-COPY-THREW {inner.GetType().Name} at {EmitPipeline.ThrowSite(inner)}: {inner.Message}";
}
Comment on lines +175 to +189
var ids = new List<string>();
if (result.GetType().GetProperty("Diagnostics")?.GetValue(result) is IEnumerable diagnostics)
{
foreach (var diagnostic in diagnostics)
{
var severity = diagnostic.GetType().GetProperty("Severity")?.GetValue(diagnostic)?.ToString();
if (severity != nameof(DiagnosticSeverity.Error))
continue;
var id = diagnostic.GetType().GetProperty("Id")?.GetValue(diagnostic)?.ToString();
if (id is not null && !ids.Contains(id))
ids.Add(id);
if (ids.Count == 5)
break;
}
}
Comment on lines +241 to +247
private readonly Dictionary<string, Assembly> own = new(StringComparer.OrdinalIgnoreCase);

public Assembly LoadPrivate(string simpleName, byte[] image)
{
using var stream = new MemoryStream(image, writable: false);
var assembly = LoadFromStream(stream);
own[simpleName] = assembly;
Comment on lines +1342 to +1346
#### Leg 5 — the pristine-COMPILER control (`compiler=`, 2026-09-13)

Legs 1–4 have one ceiling in common that none of them can see over: **all four execute the same
`Microsoft.CodeAnalysis*.dll`** — the same mapped image, the same native code, the same statics.
Leg 2 varies the references, leg 4 the source, leg 3 the call site; nothing varies the compiler.
@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

508 tests   508 ✅  38s ⏱️
  1 suites    0 💤
  1 files      0 ❌

Results for commit 2e6e661.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

261 tests   261 ✅  2m 18s ⏱️
  1 suites    0 💤
  1 files      0 ❌

Results for commit 2e6e661.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

390 tests   390 ✅  42s ⏱️
  2 suites    0 💤
  2 files      0 ❌

Results for commit 2e6e661.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 333 tests   1 331 ✅  4m 3s ⏱️
    3 suites      2 💤
    3 files        0 ❌

Results for commit 2e6e661.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

2 036 tests   2 036 ✅  5m 10s ⏱️
    4 suites      0 💤
    4 files        0 ❌

Results for commit 2e6e661.

♻️ This comment has been updated with latest results.

…ompiler= format; memory emit stamps the canary

Copilot review on #4165:
- A throw from parse/reference/options/create is compiler=UNAVAILABLE(stage=… threw …) — the
  control was never asked to emit; PRIVATE-COPY-THREW is reserved for the emit stage.
- ImmutableList / ImmutableDictionary in place of the mutable accumulators.
- The doc's 2026-09-10 specification section is the canonical leg-5 contract; the implementation
  section now points at its table, tokens aligned (UNAVAILABLE(…) / NOT-RUN), DIAGNOSTICS added.
- The EMITS wording follows the spec: the fault travels with this process's copy of the compiler
  (image, mapping, native code), not with the CLR heap — the tiering residual is restated.
- EmitToMemory stamps the canary verdict on an emit-phase throw like the disk path does; a
  deployment emitting to memory used to lose every #890 reading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRLdhAiX5DjdZC2WUfPg9E
@rbuergi

rbuergi commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in the follow-up commit, all four inline and the two suppressed:

  • THREW only from the emit stage — the reflective stage is tracked; a throw from parse / reference / options / create reports compiler=UNAVAILABLE(stage=… threw …): the private copy was never asked to emit, so it has not answered.
  • Immutable collections — the diagnostic ids accumulate in an ImmutableList<string>; the load context holds an ImmutableDictionary<string, Assembly> replaced whole via SetItem.
  • One compiler= format — the doc's 2026-09-10 specification section is the canonical contract; the new section is now the implementation note pointing at that table, with the tokens aligned (UNAVAILABLE(…) / NOT-RUN) and PRIVATE-COPY-DIAGNOSTICS(<ids>) added to the table.
  • The EMITS inference — reworded to the specification's: the fault travels with this process's copy of the compiler (its image, its mapping, or the native code produced for it), not with the CLR heap; the tiering residual (one emit does not separate fresh mapping from fresh native code) is restated in the verdict, the class remarks and the doc.
  • The memory emit path — EmitToMemory now stamps the canary verdict on an emit-phase throw exactly as the disk path does; a deployment emitting to memory used to lose every #890 reading, legs 1–5 alike.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    6 files      6 suites   9m 2s ⏱️
2 892 tests 2 700 ✅ 192 💤 0 ❌
2 896 runs  2 704 ✅ 192 💤 0 ❌

Results for commit 2e6e661.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

   17 files     17 suites   21m 56s ⏱️
7 420 tests 7 226 ✅ 194 💤 0 ❌
7 424 runs  7 230 ✅ 194 💤 0 ❌

Results for commit 2e6e661.

@rbuergi
rbuergi marked this pull request as ready for review September 13, 2026 09:01
Merged via the queue into main with commit bd4314b Sep 13, 2026
37 checks passed
rbuergi added a commit that referenced this pull request Sep 13, 2026
…ide of a coin toss

CI (queue build 34750238233, shard 1) failed the BuildupAction row with "No exception was
thrown". Its own output says why: TEST START …065 → "retiring this activation" …067 (+2 ms) →
FAILED …078. The activation faulted and retired BEFORE the request was posted, so the request
activated a fresh hub and was SERVED — which is exactly what step 3 of the same test asserts as
the contract. Both orderings are legal; the assertion was written for one of them, and which one
occurs was decided by how long GetClient()'s own hub construction took against an immediate
fault. Only BuildupAction faults immediately (a DataContext's init is slower), which is why only
that row failed.

NOT a #4165/#4151 interaction: the BuildupAction host has no AddData and therefore no
SynchronizationStream — its output block carries ZERO SYNC_STREAM lines where the passing
DataContext row carries five — so #4151's FaultStore/GetStream change cannot reach it.

The fence: the first init attempt parks until the test releases it, and the test releases only
once the request is provably in flight AT that activation. Step 3 already pins the other
ordering, so the test now states the whole contract instead of one side of it.

🚨 The first fence was wrong, and #4154's new report is what showed it:
  target pump now: host/1 RunLevel=Starting turn=InitializeHubRequest running 35004ms
                   buffer=1 deferred=0 … draining=True openGates=1
A BuildupAction runs ON the init turn, so the pump is busy and the request sits in the MAIN
queue, never reaching the deferral decision; a DataContext initialises OFF the turn, so the
request parks in the DEFERRED queue. InFlightAt accepts either queue, at that hub.

Verified on origin/main merged in (bd4314b, with #4151 and #4165): 4/4 green,
MeshWeaver.Data.Test 512/512, MeshWeaver.Messaging.Hub.Test 432/432.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi deleted the feat/private-roslyn-copy-leg branch September 13, 2026 10:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CI: one Roslyn Emit poisons the whole process — every later compile NREs in Cci.MetadataWriter.GetConsolidatedTypeParameters, shard ends exit=124

2 participants