Skip to content

Fix the open v0.22.0 review findings, and prove the profiler on real F# programs - #385

Merged
MelbourneDeveloper merged 24 commits into
mainfrom
fix/382-netfx-review-findings
Oct 18, 2026
Merged

MelbourneDeveloper merged 24 commits into
mainfrom
fix/382-netfx-review-findings

Conversation

@MelbourneDeveloper

@MelbourneDeveloper MelbourneDeveloper commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

TLDR

Fixes the three findings from the v0.22.0 static review that were still open on main, and closes the review's CI gap for Dependabot PRs. Closes #382, closes #383, closes #384. It also proves the profiler on real F# programs: two F# fixtures, nine new VS Code suites, and the lspkit and host fixes they forced (Nimblesite/lspkit#12–#15). It adds a narrated Profiling F# docs page, and the film kit that shoots it.

Details

v0.22.0 review findings

  • C#: a single-target project whose name ends in (tfm) is reported as a multi-targeted framework context #382: C# framework contexts (Workspace/TargetFrameworks.cs). FrameworkOf used to treat any final (…) in Project.Name as a target framework. It now reads only the (tfm) that MSBuildWorkspace appends after the project file's stem, using NativePaths.StemOf. A single-target Utility(net48).csproj answers { active: null, available: [] }, as [NETFX-CONTEXT] requires.
  • Framework switch announces sharplsp/targetFrameworkChanged before the open documents' diagnostics are re-published #383: switch order (target_framework.rs, main.rs, diagnostics.rs).
    • handle_set no longer announces. It returns a Switched value.
    • main.rs clears nav_cache, then calls the new diagnostics::publish_now, and only then calls Switched::announce, which sends sharplsp/targetFrameworkChanged, the declared refreshes and the response.
    • publish_now fetches and publishes every open document's diagnostics concurrently under the push gate, and waits for each first answer. A failed or provisional fetch goes on as an ordinary background push.
    • fetch_and_publish_gated and publish_now share one new publish_once attempt. main.rs's push_target replaces the sidecar/path lookup that trigger_diagnostics duplicated.
  • VS Code test runner: an explicit MOCHA_FILES selection of Windows-only suites runs zero tests and passes on Linux/macOS #384: test runner (src/test/suite/index.ts). resolveSuiteFiles is exported and takes its globs and platform. Off Windows, a pattern whose matches are all windowsOnly suites now throws, naming the pattern and [NETFX-SCOPE]. The new test-runner-selection.test.ts is registered in the workspace chunk.
  • Dependabot gate (.github/workflows/ci.yml). The whole pipeline skips Dependabot, and the terminal CI job used to pass that empty run. A security bump that dependabot-automerge.yml failed to sweep therefore showed a green required check and could merge into main untested. A new step, Fail a Dependabot PR to main, which ran no job, fails it. That enforces [DIST-CI-DEPENDABOT]'s rule that the tested dependabot-upgrades → main consolidation PR is the only route into main.
  • Specs.
    • DOTNET-FRAMEWORK-SPEC [NETFX-CONTEXT] now covers the parenthesized-name rule and states that the host waits for each document's first publication.
    • DISTRIBUTION-SPEC gains the invariant "A selection never runs nothing". [DIST-CI-PROTECTION] rule 2 and [DIST-CI-DEPENDABOT] now say a Dependabot PR fails CI.
  • F# sidecar (FSharpCSharpReferences.fs). The in-memory compile of a referenced C# project now runs that project's source generators (e.g. PolySharp's IsExternalInit polyfill), so GitReader's net48 F# projects no longer read an unbuilt DLL (FS0078/FS0084). [SHARPLSP-ARCHITECTURE-PROJECTS-FSHARP-CSHARP-REFERENCES] says so; FSharpCSharpReferenceTests › "a C# project its source generator completes for .NET Framework is read in memory" failed first with FS0078/FS0084/FS0039.
  • Real-repo setup (real-repo-helpers.ts, real-repo-kit.ts). The clone and dotnet restore of a real repository now run asynchronously, in a setup hook with a budget of its own. A cold restore of a large multi-target solution (once about 10 minutes on a Windows runner, against 2 minutes usually) used to freeze the extension host and spend the whole 600 s budget that the solution load and warm-up also need. No assertion changes.
  • Review doc. docs/reviews/V0.22.0-STATIC-REVIEW.md gains a Resolution table. The Run/F5 samePath finding was already fixed on main. The Dependabot skip stays, but its terminal-gate pass is fixed.

F# profiling

This PR also adds the F# profiler test bed ([PROFILER-SHARED-LIBRARY-VSCODE]), and moves lspkit onto the profiler fixes those tests exposed.

  • Fixtures (src/sharplsp/tests/fixtures/fsharp-profiling/). Both are real F# programs driven over a stdin line protocol:
    • FsPricingEngine is the CPU workload. It runs CSV parsing, recursive discount rules, fuzzy search, route planning and reports, each on demand, on one workload thread.

    • FsOrderLedger is the memory workload. Every leak in it has its own F# module-value root:

      • a never-removed event handler;
      • a memoize cache;
      • a mailbox audit trail;
      • an undo history of immutable Maps;
      • rejection capture;
      • a blob store.

      release, audit pause and burn on give every test a deterministic heap and CPU profile.

  • VS Code suites (src/editors/vscode/src/test/suite/):
    • Through the UI:
      • profiler-fsharp-cpu-e2e (new), and profiler-fsharp-e2e (extended with an F# heap zoo): profile from the process row, F# hot functions and lines in their own source files, the results panel, exports.
      • -memory-e2e: track, snapshot, diff, leak confidence, decorations on the constructing line.
      • -retention-e2e: retention graphs rooted at module values by their source names, objects-by-type, GC.
      • -corner-e2e: concurrent sessions, refusals and error codes, heap profile, generation bytes.
      • -soak-e2e and -soak-cpu-e2e: long runs, many snapshots, coherence of the store and the UI.
    • Over the wire, with no UI: profiler-lsp-protocol-e2e, profiler-lsp-cpu-e2e and profiler-lsp-memory-e2e.
    • Shared helpers live in profiler-fsharp-kit, profiler-fsharp-soak-kit, profiler-fsharp-artifacts and profiler-fixtures. profiler-fixtures holds the fixture locations and Release builds, moved out of profiler-kit.
    • profiler-ui-debuggee takes a DebuggeeSource, so the debug-launched suites can build an F# debuggee.
    • All the new suites fall under the profiler*.test.js chunk glob.
  • lspkit moves from 031bfc8 to lspkit main's 6ea8dce, which carries the F# profiler fixes:
  • Host (src/sharplsp/src/profiler/host.rs). A profiler command whose arguments are not exactly one params object is INVALID_PARAMS ([PROF-WIRE-METHODS]); an absent argument list is still empty params. Every production caller already sends one object (the lsp-profiler-ui library's 13 calls). Three older suite call sites sent cpu.list bare; they now pass {}, with no assertion changes.
  • Plan. docs/plans/PROFILER-PLAN.md records the F# test bed and the 16 defects it found.
  • Website.
    • A new Profiling F# page (en, ja, zh) shows what the profiler does with F#'s compiled code:

      • closures and pipeline stages by name;
      • records and union cases painted on the line that builds them;
      • module values named as written in the retention graph.

      The profiler, F# and memory-leak pages link to it.

    • The F# pages play profiler-fsharp, a narrated and captioned film of FsStorefront's CPU and memory.

    • {% video %} plays a narrated film unmuted with caption tracks; autoplaying loops stay muted.

  • Film tools (src/website/tools/profiler-film/). They become a reusable kit:
    • a film is one file in films/, shot with make _film FILM=<film>;
    • demos, including the new F# FsStorefront, live in demos/;
    • kit/ holds the stage, camera, overlay and locally synthesised voice and score;
    • the README says how to add a film.

How Do The Automated Tests Prove It Works?

v0.22.0 review findings

Each test was written first and confirmed to fail for its bug before the fix:

  • WorkspaceManagerTargetFrameworkTests.A_single_target_project_has_no_framework_to_choose is now a theory over Single and Utility(net48), on real restored projects.
    • Before the fix: Assert.Null() Failure … Actual: "net48".
    • After: both cases answer null/empty, keep the right Project, and refuse a switch.
    • The new A_multi_targeted_project_named_like_a_framework_answers_its_declared_frameworks proves Tool(net10.0).csproj still answers [net48, net10.0] and switches. All 9 tests in the class pass.
  • test_full_stack_target_framework_switch (host e2e, real Roslyn sidecar). The fixture gained a #else member with an unused local, so only net10.0 reports CS0168. The new assert_switch_order asserts that the publication carrying CS0168 (at 8:37) arrives before sharplsp/targetFrameworkChanged, which arrives before each refresh.
    • Before the fix: the announcement and both refreshes arrived with no re-published diagnostics.
    • After: passes.
  • test-runner-selection.test.ts checks a real windowsOnly name from test-chunks.json.
    • Off Windows, netfx-language.test.js and netfx-*.test.js throw with the platform reason, while *.test.js runs only the non-Windows suite.
    • On win32, the same patterns select their suites, and a pattern matching nothing still fails.
    • With the guard disabled it fails with Missing expected exception; with the guard: 2 passing.
  • security-gates.test.mjs › the terminal gate fails a Dependabot PR to main, whose pipeline ran nothing. It parses the real ci.yml and asserts:
    • CI only sees PRs to main, and detect-changes skips Dependabot.
    • A github.actor == 'dependabot[bot]' step fails with exit 1 before Report, naming dependabot-upgrades.
    • Before the fix: AssertionError: a Dependabot PR must not report green. After: all 35 tools/ci contract tests pass, and actionlint is clean.

F# profiling

Each fix was proven by a test that failed before it.

  • Host: profiler::host::tests::anything_but_one_params_object_is_malformed and one_params_object_or_no_argument_list_is_well_formed. The VS Code test profiler-lsp-protocol-e2e › "all seventeen requests enforce their typed arguments" failed before the fix with sharplsp.lspProfiler.capabilities with [] should have failed.

  • lspkit: each fix in Rename: methods, functions, local functions, and operators #12–Rename: fields and events #15 lands with a failing-first engine or UI test. For example:

    • dotnet_e2e › objects_of_a_small_type_keep_its_own_summary_row failed with no summary row for List<LeakedPayload>.
    • a_duration_caps_the_recorded_time now also requires that cpu.list omits a session past its duration; it failed with the session still listed.

    lspkit's make ci passes 412/412 and its UI suite passes 161.

  • VS Code, run locally on macOS at lspkit 6ea8dce, covering profiler-fsharp-*, profiler-lsp-* and memory-flows. Before the fixes: 24 passing, 8 failing. After: every test passes. One run gave 50 passing; the last, "the profiler tells the truth about four sustained F# workloads", passes (7/7 in its suite) since its Stopwatch window opens once samples flow ([PROF-AGGREGATION-LOGIC]). Before that, the window counted the profiler's unsampled attach time.

    • The eight failures were the fall of the timeline's peak, a stopped session still answering, a stage's sampled share, a hot framework frame with an invented file, leak types named by bare address, the undo history's closed generic, a timed session that never ended, and capabilities accepting [].
  • The rest of the pipeline, run locally:

    • Rust shards: 292/292 and 261/261.
    • .NET sidecars: 125, 568 and 437 passing.
    • Tooling: 74 passing.
    • Analyse: Rust, Zed, csharpier, Prettier, ESLint, tsc and citations all clean; Deslop at 6.59%.

… framework-switch order, Windows-only selections that run nothing

- A single-target C# project whose name ends in (tfm) is no longer read as a framework context (#382)
- A framework switch re-publishes open documents' diagnostics before announcing itself and asking for refreshes (#383)
- Off Windows, a MOCHA_FILES pattern naming only windowsOnly suites fails instead of passing a run of nothing (#384)
@MelbourneDeveloper
MelbourneDeveloper enabled auto-merge (squash) October 18, 2026 06:22
The whole PR pipeline skips Dependabot, and the terminal gate passed the
skipped run, so a security bump the sweep did not retire showed a green
required check and could merge into main untested. The gate now fails it;
bumps reach main only through the tested dependabot-upgrades consolidation PR.
…located, each diff replaces the last diff's leak badges (Nimblesite/lspkit#12)
…hreads counted once per tick, arrays never located, honest retention truncation and PROCESS_NOT_FOUND, fresh leak badges
The objects step keeps the requested type's own row when it is too small to
rank among the twenty largest, so a union case such as OrderStatus+Paid
agrees with its totals. Types whose PDB names a build server's checkout,
as FSharp.Core's does, have no location instead of a path that does not
exist here.
The CPU results panel and the memory dashboard checked the same SharpLsp
brand, foreign-product and nonce rules in two copies, and the webviews suite
kept its own copy of the kit's dashboard snapshot. One helper each brings
duplication back under the Deslop ratchet; every assertion is unchanged.
The corner suite releases the ledger before counting 300 live orders and no
longer claims the dashboard keeps a stopped session's snapshot, which
memory-flows and the F# memory suite rule out. The memory-step helpers and
error codes move into the soak kit, the wire suite splits into protocol,
CPU and memory suites, and the fixture locations and builds move from
profiler-kit into profiler-fixtures, so no suite or kit passes 500 lines.
FSharp.Core types no longer anchor at the build server's paths, sites with
no location stay unlocated in the UI, the objects summary keeps the
requested type's row, and the retention graph draws each root by its label.
Every profiler operation takes exactly one argument, its typed params
object ([PROF-WIRE-METHODS]). The host passed the first argument on and
ignored the rest, so `capabilities` answered `[]`, `[42]` and two objects
alike. Anything but one JSON object is now INVALID_PARAMS before the
service sees it; an absent argument list still means empty params.
… tools reusable

A new Profiling F# page (en, ja, zh) covers what the profiler does with F#'s
compiled code: closures and pipeline stages by name, records and union cases
painted on the line that builds them, module values named as written in the
retention graph, and the fix. The profiler, F# and memory-leak pages link to it,
and the F# pages play profiler-fsharp: FsStorefront's CPU and memory, narrated,
scored and captioned in every site language.

The film tools become a kit any film can use. A film is one file in films/: its
demo, its poster and its scenes, told through kit/acts/cpu.mjs and
kit/acts/memory.mjs. film.mjs (or make _film FILM=<film>) shoots and publishes
it. kit/set.mjs stages a demo from demos/ beside an isolated VS Code;
kit/workbench.mjs, camera.mjs and overlay.mjs are the moves, the recorder and
the captions; kit/sound/ speaks a reviewed script locally with Kokoro
(kokoro-js), synthesises an original score, ducks it under the voice and
normalises to -16 LUFS. The README says how to add a film.

{% video %} plays a narrated film unmuted with caption tracks; only autoplaying
loops stay muted.
Heap snapshots name every type during a CPU session, peak_bytes is the
session peak, a timed CPU session ends, the UI's Stop ends the server
session, and a hot function with no location stays unlocated. The F# CPU
suite reads the now-optional hot-function line without assuming it.
The wire CPU suite waits for a timed session to leave cpu.list and holds
its report to exactly the requested two seconds; the protocol suite
rejects two params objects to cpu.list. The soak suites check the live
status bar's own sample count and elapsed time, that a flat comparison
resets a growth streak, and the undo history's closed generic through
objectsByType, whose summary now keeps a type too small to rank.
The ground truth read the fixture's stage timers before the profiler had
attached, so parse's measured share carried one to two seconds the profiler
never sampled, while every share is of the profiled window
([PROF-AGGREGATION-LOGIC]). The timers are now read after live progress
starts; every assertion and tolerance is unchanged.
@MelbourneDeveloper MelbourneDeveloper changed the title Fix the open v0.22.0 review findings: parenthesized C# project names, framework-switch order, Windows-only selections Fix the open v0.22.0 review findings, and prove the profiler on real F# programs Oct 18, 2026
…it bare

Every profiler operation is `workspace/executeCommand` with exactly one
argument, its typed params object (lspkit LSP-PROFILER-SPEC.md, the LSP
surface table, [PROF-WIRE-METHODS]), and the host now refuses anything
else. Three test call sites sent `cpu.list` with no argument at all,
which the host used to tolerate. Every production caller (the
lsp-profiler-ui library's 13 calls) already sends one object, and no
contribution or other editor runs these commands bare. The calls now pass
`{}`. No assertion changes.
The profiler UI's progress operation log trimmed single entries past 500,
which SharpLsp's profiler run now exceeds, so the refresh-progress and
balanced-progress checks read a truncated log.
An F# project reads the C# projects it references from an in-memory Roslyn
compile of their design-time command line, but that compile skipped the
source generators the command line names. GitReader.Core polyfills .NET
Framework with PolySharp, so for net48 its init accessors had no
IsExternalInit, Roslyn emitted nothing, and the F# test project read a DLL
nobody built: FS0078 and FS0084 on every net48 file. The compile now runs
the /analyzer: assemblies' C# generators first, as the build does.
ensureRepoReady cloned and ran `dotnet restore` with execFileSync inside
the extension host, in the same 600 s setup hook that then loads the
solution and warms it up. A cold restore of GitReader on a Windows runner
once took 596 s (fix/377's identical cold start took 114 s): VS Code was
reported unresponsive for all of it, and the hook timed out although the
restore succeeded. The clone and restore now run asynchronously, in a
setup hook with a budget of its own. No assertion changes.
The generator pass passed mutable locals by reference to Roslyn's out
parameters inside a match arm, which the F# optimizer turns into invalid IL
in Release: every F# project referencing a C# project failed to load with
"Common Language Runtime detected an invalid program". The pass now adds
the driver's generated trees to the compilation, and a generator assembly
that does not load here is skipped and logged instead of throwing.
@MelbourneDeveloper
MelbourneDeveloper merged commit 67eba07 into main Oct 18, 2026
57 checks passed
@MelbourneDeveloper
MelbourneDeveloper deleted the fix/382-netfx-review-findings branch October 18, 2026 10:48
MelbourneDeveloper added a commit that referenced this pull request Oct 19, 2026
## TLDR
Website films play in Windows Chrome and Edge again: every video lists
its H.264 MP4 before its VP9 WebM, and a new playback test proves frames
actually decode.

## Details
- **Root cause.** Windows Chrome/Edge accept the VP9 `<source>`, decode
one frame, then stop with `PIPELINE_ERROR_DECODE`, and never try the
next source. Every film listed WebM first, so every film failed there.
- **`src/_data/videos.json`**: all 18 films now list `["mp4", "webm"]`.
- **`video-figure.js`**: the VideoObject `contentUrl` points to the
first (preferred) encoding instead of the last, so search engines link
the MP4.
- **New `tests/video-playback.spec.js`**: plays 20 films across docs and
blog pages, then pauses, seeks to the middle and resumes. Each step
checks that the clock advances, frames decode
(`getVideoPlaybackQuality().totalVideoFrames`) and `video.error` stays
null. A served file and a resolved `play()` don't prove playback.
- **`tests/media.js`**: a new `expectFramesAdvance` helper, which also
catches decode errors raised after `play()` resolves.
- **`playwright.config.js`**: on Windows, the playback spec also runs on
installed Chrome and Edge, where the bug reproduced; bundled Chromium
and WebKit passed. The phone projects use installed Chrome there because
Windows WebKit rejects known-good H.264.
- **`docs/specs/DESIGN-SYSTEM.md` [WEB-DESIGN-MEDIA]** records the
MP4-first rule and the playback-test requirement.
- **Test expectations** in `profiler-media.spec.js` and
`site-videos.spec.js` now expect MP4 first.
- **Branch housekeeping.** This branch also merges the stale branches
`feature/narrated-doc-films`, `feature/doc-stills` and
`fix/382-netfx-review-findings` (all already squash-merged as #392, #393
and #385), plus `dependabot-upgrades`. None of them changes any file
against main:
- main already had every bump except TypeScript 7, which
typescript-eslint 8.72 does not support yet (peer range `<6.1.0`);
  - the `lspkit` pin stays on lspkit `main` (`ccc59bf`).

## How Do The Automated Tests Prove It Works?
- **`video-playback.spec.js`**: one test per film, named `<route> video
<n> decodes, pauses, seeks and resumes`. It runs on Chromium, iPhone SE
and iPhone 14 everywhere, and on Windows also on installed `chrome` and
`msedge`. Each film must:
- advance more than 1s and decode more than 5 new frames with `error ===
null`;
  - stay put while paused, so `currentTime` is unchanged after 250ms;
  - advance and decode again after seeking to `duration / 2`.
- **`site-videos.spec.js` › `expectDescribed`** asserts the VideoObject
`contentUrl` is the first listed source.
- **`profiler-media.spec.js`** asserts the hero film's `<source>` order
is MP4 then WebM.
- **The full website Playwright suite (`make _test-website`)** passes
locally, and Deslop reports 6.5% against the 6.65% threshold.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment