From 71d6ec4c2e8306b7485a72740a1373054af1614c Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Fri, 4 Sep 2026 07:55:42 +0200 Subject: [PATCH] Keep the npm download out of the pyright tests' time budget The three tests that talk to pyright each hand one three-minute token to the handshake and their requests. On a runner with node and no pyright the server comes through npx, whose first start downloads the package from the npm registry before the server says a word - inside that same token. Every CI runner is fresh, so the first pyright test of a run always paid for the download, and when the registry had a slow morning (16 s one day, 109 s the next on the same runner image, over 180 s twice) the test failed with a cancellation inside initialize that named neither the download nor how long it took. The download now happens once per test assembly, through ExternalTool.RunAsync with a budget of its own, before any pyright test starts its server. RunAsync logs the command with its elapsed time, so a slow registry is a number in the test output rather than a timeout three minutes later. A download that fails still fails the tests, with that reason: a registry that cannot be reached is a fact about the run. Raising the three-minute budget was rejected because it hides the cause and slows every failure; a warm-up step in the workflow was rejected because a developer's first run has the same exposure and the test should measure the server, not the network, wherever it runs. Assisted-by: Claude:claude-fable-5-1:Claude Code --- .../PythonInterpreterTests.cs | 2 +- tests/Stampeded.Core.Tests/PythonLspTests.cs | 2 +- .../PythonProjectConfigTests.cs | 2 +- tests/Stampeded.Core.Tests/PythonServer.cs | 49 +++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 tests/Stampeded.Core.Tests/PythonServer.cs diff --git a/tests/Stampeded.Core.Tests/PythonInterpreterTests.cs b/tests/Stampeded.Core.Tests/PythonInterpreterTests.cs index e4fe5c1..6786c09 100644 --- a/tests/Stampeded.Core.Tests/PythonInterpreterTests.cs +++ b/tests/Stampeded.Core.Tests/PythonInterpreterTests.cs @@ -53,7 +53,7 @@ public void WithoutOneTheAnswerIsWhateverPythonMeansHere() [Test] public async Task AnImportResolvesIntoAnEnvironmentThatIsNotInTheWorktree() { - if (LanguageServers.Python() is not { } spec) + if (await PythonServer.ResolveAsync() is not { } spec) { Assert.Ignore("no Python language server available"); return; diff --git a/tests/Stampeded.Core.Tests/PythonLspTests.cs b/tests/Stampeded.Core.Tests/PythonLspTests.cs index e99b23a..be59e8d 100644 --- a/tests/Stampeded.Core.Tests/PythonLspTests.cs +++ b/tests/Stampeded.Core.Tests/PythonLspTests.cs @@ -15,7 +15,7 @@ public class PythonLspTests [Test] public async Task PyrightAnswersDefinitionsAndReferencesAcrossFiles() { - if (LanguageServers.Python() is not { } spec) + if (await PythonServer.ResolveAsync() is not { } spec) { Assert.Ignore("no Python language server available"); return; diff --git a/tests/Stampeded.Core.Tests/PythonProjectConfigTests.cs b/tests/Stampeded.Core.Tests/PythonProjectConfigTests.cs index 20d34e0..78bcaa1 100644 --- a/tests/Stampeded.Core.Tests/PythonProjectConfigTests.cs +++ b/tests/Stampeded.Core.Tests/PythonProjectConfigTests.cs @@ -15,7 +15,7 @@ public class PythonProjectConfigTests [Test] public async Task AProjectConfigNamingItsOwnVenvDoesNotBlindTheReview() { - if (LanguageServers.Python() is not { } spec) + if (await PythonServer.ResolveAsync() is not { } spec) { Assert.Ignore("no Python language server available"); return; diff --git a/tests/Stampeded.Core.Tests/PythonServer.cs b/tests/Stampeded.Core.Tests/PythonServer.cs new file mode 100644 index 0000000..327ef1a --- /dev/null +++ b/tests/Stampeded.Core.Tests/PythonServer.cs @@ -0,0 +1,49 @@ +using Stampeded.Core.Infra; +using Stampeded.Core.Lsp; + +namespace Stampeded.Core.Tests; + +/// +/// The Python language server a test talks to, with its download kept out of the test's own +/// time budget. +/// +/// On a machine with node and no pyright, answers with +/// npx, and the first npx start downloads the package from the npm registry before the server +/// says a word. A test hands one three-minute token to the handshake and its requests, which +/// is generous for a language server and nothing at all for a registry having a slow morning: +/// the download alone has been measured at 16 seconds and at 109 seconds on the same runner +/// image. Every CI runner is fresh, so the first pyright test in a run always paid for it, and +/// failed with a cancellation inside initialize that named neither the download nor its +/// duration. +/// +/// So the download happens here, once per test assembly, under a budget of its own, through +/// - which logs the command with its elapsed time, so a +/// slow registry shows up as a number in the test output. npx keys its cache on the package +/// spec, so pyright --version puts the package where pyright-langserver then +/// finds it. A download that fails or runs out its budget fails every pyright test with that +/// reason, rather than being skipped: a registry that cannot be reached is a fact about the +/// run, not about the machine. +/// +static class PythonServer +{ + static readonly Lazy downloaded = new(DownloadAsync); + + /// The server spec, or null on a machine with no way to run one; for the npx + /// form, only after the package is in the npm cache. + public static async Task ResolveAsync() + { + if (LanguageServers.Python() is not { } spec) + return null; + if (spec.Arguments.Contains("--package")) + await downloaded.Value; + return spec; + } + + static async Task DownloadAsync() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(10)); + await ExternalTool.RunAsync(LanguageServers.OnPath("npx")!, + ["--yes", "--package", "pyright", "--", "pyright", "--version"], + Path.GetTempPath(), timeout.Token); + } +}