diff --git a/.github/sourcelink/README.md b/.github/sourcelink/README.md new file mode 100644 index 00000000..546a8fe3 --- /dev/null +++ b/.github/sourcelink/README.md @@ -0,0 +1,50 @@ +# SourceLink step-into verification + +Fixtures for [`sourcelink-stepinto.yaml`](../workflows/sourcelink-stepinto.yaml), +which proves the *debugger* half of SourceLink end-to-end: that pressing **F11** +in a consumer steps into the library's **real source** (resolved via the +SourceLink map in the PDB), not a decompiled placeholder. + +This complements [`sourcelink.yaml`](../workflows/sourcelink.yaml), which proves +every document in the PDB resolves to real GitHub content via +`dotnet sourcelink test`. Together they cover the full chain F11 depends on: +SourceLink map in the PDB → source-file resolution → GitHub raw-URL fetch. + +## How it works + +1. Build [`consumer/`](consumer/) with `-c Debug -p:ContinuousIntegrationBuild=true`. + Its `ProjectReference` compiles the library with the **same SourceLink map a + released package ships** (deterministic `/_/…` source roots that map to GitHub + raw URLs), and Debug/non-optimized codegen so the step-into target isn't + reordered away. +2. Run [`verify_stepinto.py`](verify_stepinto.py) — it drives + [netcoredbg](https://github.com/Samsung/netcoredbg) over its MI interface to + break in the consumer, step into `Report`'s constructor, and assert the + resulting frame is the library's SourceLink-mapped source file with symbols + loaded. If SourceLink or the sequence points were broken the step would land + with no source and the script exits non-zero. + +## Why a ProjectReference, not the packed NuGet + +netcoredbg is the only CI-scriptable .NET debugger, and it does **not** reliably +pair a *package*-sourced assembly with its symbol-package (`.snupkg`) PDB — it +reports `symbols-loaded=0` and cannot step into it (verified during development). +A `ProjectReference` built with `ContinuousIntegrationBuild=true` produces a +**byte-identical SourceLink PDB**, so the SourceLink map under test is the same +one that ships; only the delivery path differs. The packed-package symbol/URL +resolution is covered by `sourcelink.yaml`. + +## Files + +- `consumer/StepIntoConsumer.csproj` / `Program.cs` — the fixture consumer. The + break line is marked `STEP_INTO_TARGET`. +- `Directory.Build.props` / `.targets` — empty isolation stubs so the consumer + does **not** inherit the repo's analyzers / BannedSymbols / multi-TFM policy. +- `verify_stepinto.py` — the debugger driver (exit 0 = step-into resolved real + source). + +## Scope + +Scheduled + manual, not a PR gate — debugger automation is heavier and slightly +less deterministic than a unit test, and SourceLink's raw URLs resolve only +against a pushed commit. diff --git a/.github/sourcelink/consumer/Directory.Build.props b/.github/sourcelink/consumer/Directory.Build.props new file mode 100644 index 00000000..cc81238d --- /dev/null +++ b/.github/sourcelink/consumer/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/.github/sourcelink/consumer/Directory.Build.targets b/.github/sourcelink/consumer/Directory.Build.targets new file mode 100644 index 00000000..cb60c727 --- /dev/null +++ b/.github/sourcelink/consumer/Directory.Build.targets @@ -0,0 +1,3 @@ + + + diff --git a/.github/sourcelink/consumer/Program.cs b/.github/sourcelink/consumer/Program.cs new file mode 100644 index 00000000..a5401cea --- /dev/null +++ b/.github/sourcelink/consumer/Program.cs @@ -0,0 +1,12 @@ +using System; +using Wolfgang.Etl.Abstractions; + +// End-to-end SourceLink "step into" fixture. The debugger sets a breakpoint on +// the marked line below and issues a step-into (the F11 a consumer +// would press). If SourceLink is intact the debugger resolves the library's real +// source (from GitHub) at the constructor below, instead of a decompiled +// placeholder. Report's constructor is a plain, non-async method with a guard +// clause, which makes it a clean and stable step-into target. + +var report = new Report(42); // STEP_INTO_TARGET +Console.WriteLine(report.CurrentItemCount); diff --git a/.github/sourcelink/consumer/StepIntoConsumer.csproj b/.github/sourcelink/consumer/StepIntoConsumer.csproj new file mode 100644 index 00000000..6b8144dc --- /dev/null +++ b/.github/sourcelink/consumer/StepIntoConsumer.csproj @@ -0,0 +1,29 @@ + + + + + Exe + net10.0 + enable + disable + + false + portable + + ../../../src/Wolfgang.Etl.Abstractions/Wolfgang.Etl.Abstractions.csproj + + + + + + + diff --git a/.github/sourcelink/verify_stepinto.py b/.github/sourcelink/verify_stepinto.py new file mode 100644 index 00000000..ab76c5bb --- /dev/null +++ b/.github/sourcelink/verify_stepinto.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Drive netcoredbg to prove SourceLink "step into" (F11) works end-to-end. + +Sets a breakpoint in the consumer, runs to it, issues a step-into, and inspects +the resulting stack frame. Succeeds (exit 0) only if the step landed in the +expected library source file with the library's symbols loaded — i.e. a debugger +resolves real library source, not a decompiled placeholder. If SourceLink or the +symbol package were broken, the step would land with no source mapping and this +fails. + +Usage: + verify_stepinto.py +""" +import subprocess +import sys +import re +import threading +import queue +import time + +if len(sys.argv) != 5: + print(__doc__) + sys.exit(2) + +debugger, consumer_dll, break_spec, expected_src = sys.argv[1:5] + +proc = subprocess.Popen( + [debugger, "--interpreter=mi", "--", "dotnet", consumer_dll], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, +) + +events: "queue.Queue[str]" = queue.Queue() + + +def _reader(): + for line in proc.stdout: + events.put(line.rstrip("\n")) + events.put(None) + + +threading.Thread(target=_reader, daemon=True).start() + + +def send(command): + print(">>>", command, flush=True) + proc.stdin.write(command + "\n") + proc.stdin.flush() + + +def wait_for(needles, timeout=60): + deadline = time.time() + timeout + while time.time() < deadline: + try: + line = events.get(timeout=max(0.1, deadline - time.time())) + except queue.Empty: + return None + if line is None: + return None + print("dbg>", line, flush=True) + if any(n in line for n in needles): + return line + return None + + +def fail(reason): + print("RESULT=FAIL reason=" + reason, flush=True) + try: + send("-gdb-exit") + except Exception: + pass + sys.exit(1) + + +send("-break-insert " + break_spec) +wait_for(["^done", "^error"], 15) +send("-exec-run") + +# netcoredbg halts at the managed entry point first; continue to the breakpoint. +stop = wait_for(["*stopped"], 60) +if stop and 'reason="entry-point-hit"' in stop: + send("-exec-continue") + stop = wait_for(["*stopped"], 60) + +if not stop or "breakpoint-hit" not in stop: + fail("breakpoint-not-hit") + +# Step into (F11). Retry a couple of times in case the first step stays on the +# call line before descending into the callee. +resolved = None +for _ in range(4): + send("-exec-step") + stop = wait_for(["*stopped"], 30) + if not stop: + break + match = re.search(r'frame=\{[^}]*?file="([^"]+)"[^}]*?fullname="([^"]+)"', stop) + if match and match.group(1).endswith(expected_src): + resolved = (match.group(1), match.group(2), stop) + break + +if not resolved: + fail("did-not-step-into-" + expected_src) + +send("-gdb-exit") + +source_file, fullname, stop = resolved +line_match = re.search(r'line="(\d+)"', stop) +print("RESOLVED_FILE=" + source_file, flush=True) +print("RESOLVED_FULLNAME=" + fullname, flush=True) +print("RESOLVED_LINE=" + (line_match.group(1) if line_match else "?"), flush=True) +print("RESULT=PASS", flush=True) +sys.exit(0) diff --git a/.github/workflows/sourcelink-stepinto.yaml b/.github/workflows/sourcelink-stepinto.yaml new file mode 100644 index 00000000..b4b61668 --- /dev/null +++ b/.github/workflows/sourcelink-stepinto.yaml @@ -0,0 +1,64 @@ +name: SourceLink Step-Into + +# Proves the DEBUGGER half of SourceLink end-to-end (#214): that pressing F11 in a +# consumer steps into the library's real source (resolved via the SourceLink map in +# the PDB), not a decompiled placeholder. sourcelink.yaml already proves every PDB +# document resolves to real GitHub content; this drives an actual debugger step-into. +# +# See .github/sourcelink/README.md. Scheduled + manual, NOT a PR gate — debugger +# automation is heavier/less deterministic than a unit test, and SourceLink's raw +# URLs resolve only against a pushed commit. + +on: + schedule: + - cron: '0 4 * * 2' # weekly Tuesday 04:00 UTC + workflow_dispatch: + +permissions: + contents: read + +env: + NETCOREDBG_VERSION: '3.1.2-1054' + CONSUMER: .github/sourcelink/consumer + EXPECTED_SRC: Report.cs + +jobs: + step-into: + name: F11 step-into resolves library source + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: '10.0.x' + + - name: Install netcoredbg + run: | + set -euo pipefail + url="https://github.com/Samsung/netcoredbg/releases/download/${NETCOREDBG_VERSION}/netcoredbg-linux-amd64.tar.gz" + curl -sSfL -o netcoredbg.tar.gz "$url" + tar -xzf netcoredbg.tar.gz + echo "NETCOREDBG=$PWD/netcoredbg/netcoredbg" >> "$GITHUB_ENV" + + - name: Build the step-into fixture (Debug, deterministic SourceLink PDB) + # Debug + ContinuousIntegrationBuild=true: the referenced library compiles + # with the same SourceLink map a released package ships, non-optimized so the + # step-into target is not reordered/inlined. + run: > + dotnet build "$CONSUMER/StepIntoConsumer.csproj" + -c Debug -p:ContinuousIntegrationBuild=true + + - name: Drive netcoredbg step-into + run: | + set -euo pipefail + bpline=$(grep -n STEP_INTO_TARGET "$CONSUMER/Program.cs" | head -1 | cut -d: -f1) + dll="$CONSUMER/bin/Debug/net10.0/StepIntoConsumer.dll" + echo "Breakpoint at Program.cs:$bpline — expecting step-into to land in $EXPECTED_SRC" + python3 -u .github/sourcelink/verify_stepinto.py \ + "$NETCOREDBG" "$dll" "Program.cs:$bpline" "$EXPECTED_SRC"