From dc74d331a8cc22a65e25b1b88ef1a3dc5948be46 Mon Sep 17 00:00:00 2001 From: coreybutler <770982+coreybutler@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:33:07 -0500 Subject: [PATCH 1/5] chore: bump version to 2.0.2 Co-authored-by: Cursor --- src/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/manifest.json b/src/manifest.json index 45f1276..19737a0 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,6 +1,6 @@ { "name": "nvm", - "version": "2.0.1", + "version": "2.0.2", "description": "Node Version Manager for Windows", "appLabel": "NVM for Windows", "appId": "40078385-F676-4C61-9A9C-F9028599D6D3", From ff8fb1b4d86f672fb119b1e07beaa6dae287df69 Mon Sep 17 00:00:00 2001 From: coreybutler <770982+coreybutler@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:33:33 -0500 Subject: [PATCH 2/5] feat: relax network deadlines from install, env, and doctor Co-authored-by: Cursor --- src/cmd/main.go | 2 + src/commands/env.go | 71 ++++++++++++++++++++++++--------- src/commands/install/install.go | 18 +++++---- src/commands/sync.go | 30 +++++++++++--- src/constant/relax.go | 49 +++++++++++++++++++++++ src/constant/relax_test.go | 47 ++++++++++++++++++++++ src/installer/download_error.go | 19 +++++++++ src/log/log.go | 13 ++++++ 8 files changed, 217 insertions(+), 32 deletions(-) create mode 100644 src/constant/relax.go create mode 100644 src/constant/relax_test.go diff --git a/src/cmd/main.go b/src/cmd/main.go index b8f8542..669b945 100644 --- a/src/cmd/main.go +++ b/src/cmd/main.go @@ -6,6 +6,7 @@ import ( "common/fs" "common/license" "common/notify" + "common/resolver" "common/settings" "common/system" "common/verifycache" @@ -32,6 +33,7 @@ var ( ) func main() { + resolver.OnDeadline = log.LogNetworkDeadline if len(os.Args) < 2 { os.Args = append(os.Args, "--help") } diff --git a/src/commands/env.go b/src/commands/env.go index 1b08f17..fab959e 100644 --- a/src/commands/env.go +++ b/src/commands/env.go @@ -15,6 +15,7 @@ import ( "nvm/bootstrap" "nvm/commands/cache" "nvm/constant" + "nvm/log" "nvm/status" "os" "os/user" @@ -33,22 +34,18 @@ var ( helpURL string = "https://docs.nvm-windows.com" ) -const remoteReachabilityTimeout = 1500 * time.Millisecond - -// Use common/http so Author mirrors (mirror.author.io) get Bearer access token. -var reachabilityClient = nvmhttp.NewClient(remoteReachabilityTimeout) - type Env struct { constant.FlagJSON + RelaxDeadlines constant.RelaxDeadlines `optional:"" placeholder:"MS" help:"Relax network deadlines for this command. Omit a value to triple the configured budgets, or pass milliseconds."` } type installData struct { - Version string `json:"version"` + Version string `json:"version"` BuildTime string `json:"build_time"` BuildArchitecture string `json:"build_architecture"` InstallDir string `json:"path"` - Upgrade string `json:"upgrade"` - Variables map[string]string `json:"variables"` + Upgrade string `json:"upgrade"` + Variables map[string]string `json:"variables"` } type vmOps struct { @@ -69,6 +66,10 @@ type vmOps struct { VersionsCacheRoot string `json:"cache_root"` CachedVersionsCount int `json:"cache_count"` CachedVersionsSizeMB int64 `json:"cache_size_mb"` + TimeoutCatalog string `json:"timeout_catalog_ms"` + TimeoutCatalogMirror string `json:"timeout_catalog_mirror_ms"` + TimeoutReachability string `json:"timeout_reachability_ms"` + TimeoutDownload string `json:"timeout_download_ms"` } // nodeRuntimeFlags reports shim-enforced Node.js CLI security flags. @@ -135,6 +136,7 @@ var ( ) func (e *Env) Run(ctx *kong.Context, vars kong.Vars) error { + settings.UseRelax(e.RelaxDeadlines.Setting()) var spinner *status.Spinner if !e.JSON { spinner = status.NewSpinner("Analyzing environment") @@ -249,6 +251,7 @@ func (e *Env) Run(ctx *kong.Context, vars kong.Vars) error { } } + budgets := settings.ActiveNetworkBudgets() node_ping_results := runReachabilityChecks(cfg.NodeMirror, isNodeMirrorReachable) npm_ping_results := runReachabilityChecks(cfg.NpmMirror, isNpmMirrorReachable) @@ -289,7 +292,7 @@ func (e *Env) Run(ctx *kong.Context, vars kong.Vars) error { BuildTime: vars["buildTime"], BuildArchitecture: buildArchitecture, InstallDir: path(programRoot), - Upgrade: map[bool]string{true: "blocked", false: "allowed"}[cfg.DisableUpgrade], + Upgrade: map[bool]string{true: "blocked", false: "allowed"}[cfg.DisableUpgrade], // Variables: map[string]string{ // "NVM_HOME": getUserEnvVar("NVM_HOME"), // "NVM_NODE_PATH": getUserEnvVar("NVM_NODE_PATH"), @@ -312,6 +315,10 @@ func (e *Env) Run(ctx *kong.Context, vars kong.Vars) error { NpmGlobalModuleTotal: moduleTotalCount, NpmGlobalModuleUnique: moduleUniqueCount, NpmModuleSizeMB: moduleSizeBytes / (1024 * 1024), + TimeoutCatalog: budgets.Catalog.Note(), + TimeoutCatalogMirror: budgets.CatalogMirror.Note(), + TimeoutReachability: budgets.Reachability.Note(), + TimeoutDownload: budgets.Download.Note(), }, Node: nodeFlags, PackageManagers: packageManagersCfg{ @@ -453,10 +460,7 @@ func (e *Env) Run(ctx *kong.Context, vars kong.Vars) error { // Node Mirrors fmt.Fprintf(t, "%s%s Download Sources\t\n", indent(1), branch) for i, mirror := range out.VersionManagement.NodeMirror { - reachable := "" - if !out.VersionManagement.NodeMirrorPingResult[mirror] { - reachable = " (unreachable)" - } + reachable := unreachableNote(out.VersionManagement.NodeMirrorPingResult[mirror], out.VersionManagement.TimeoutReachability) if i == 0 { fmt.Fprintf(t, "%s%s%s %s Node.js\t: %s%s\n", indent(1), line, indent(1), branch, mirror, reachable) @@ -467,10 +471,7 @@ func (e *Env) Run(ctx *kong.Context, vars kong.Vars) error { // npm Mirrors for i, mirror := range out.VersionManagement.NpmMirror { - reachable := "" - if !out.VersionManagement.NpmMirrorPingResult[mirror] { - reachable = " (unreachable)" - } + reachable := unreachableNote(out.VersionManagement.NpmMirrorPingResult[mirror], out.VersionManagement.TimeoutReachability) if i == 0 { fmt.Fprintf(t, "%s%s%s %s npm\t: %s%s\n", indent(1), line, indent(1), end, mirror, reachable) @@ -479,6 +480,12 @@ func (e *Env) Run(ctx *kong.Context, vars kong.Vars) error { } } + fmt.Fprintf(t, "%s%s Network deadlines\t\n", indent(1), branch) + fmt.Fprintf(t, "%s%s%s %s TimeoutCatalogMs\t: %s\n", indent(1), line, indent(1), branch, out.VersionManagement.TimeoutCatalog) + fmt.Fprintf(t, "%s%s%s %s TimeoutCatalogMirrorMs\t: %s\n", indent(1), line, indent(1), branch, out.VersionManagement.TimeoutCatalogMirror) + fmt.Fprintf(t, "%s%s%s %s TimeoutReachabilityMs\t: %s\n", indent(1), line, indent(1), branch, out.VersionManagement.TimeoutReachability) + fmt.Fprintf(t, "%s%s%s %s TimeoutDownloadMs\t: %s\n", indent(1), line, indent(1), end, out.VersionManagement.TimeoutDownload) + fmt.Fprintf(t, "%s%s Installed Versions\t\n", indent(1), branch) // Active Version @@ -960,9 +967,33 @@ func runReachabilityChecks(mirrors []string, checker func(string) bool) map[stri return results } +func unreachableNote(reachable bool, budget string) string { + if reachable { + return "" + } + if strings.Contains(budget, settings.SourceCommandFlag) { + return " (unreachable; phase=reachability " + budget + ")" + } + return " (unreachable)" +} + +func reachabilityClient() *nvmhttp.Client { + return nvmhttp.NewClient(settings.ActiveNetworkBudgets().Reachability.Duration()) +} + +func noteReachabilityDeadline(url string, err error) { + if !nvmhttp.IsDeadline(err) { + return + } + b := settings.ActiveNetworkBudgets().Reachability + log.LogNetworkDeadline("reachability", url, b.Milliseconds, b.Source) +} + func isNodeMirrorReachable(url string) bool { - res, err := reachabilityClient.Head(url + "/index.tab") + target := strings.TrimRight(url, "/") + "/index.tab" + res, err := reachabilityClient().Head(target) if err != nil { + noteReachabilityDeadline(target, err) return false } defer res.Body.Close() @@ -975,8 +1006,10 @@ func isNodeMirrorReachable(url string) bool { } func isNpmMirrorReachable(url string) bool { - res, err := reachabilityClient.Get(url + "/-/ping") + target := strings.TrimRight(url, "/") + "/-/ping" + res, err := reachabilityClient().Get(target) if err != nil { + noteReachabilityDeadline(target, err) return false } defer res.Body.Close() diff --git a/src/commands/install/install.go b/src/commands/install/install.go index 583e468..b83226a 100644 --- a/src/commands/install/install.go +++ b/src/commands/install/install.go @@ -10,18 +10,20 @@ import ( ) type Install struct { - CopyFrom string `flag:"copy-from" help:"Copy the global modules from an existing Node.js installation to the new installation. This does affect the original version and does not verify module compatibility with the new version." placeholder:"VERSION"` - From string `flag:"from" help:"Install global modules from the list of modules installed in an another Node.js version." placeholder:"VERSION"` - Notify bool `flag:"notify" hidden:"true" help:"Notify user when a version has been automatically installed."` - Cache bool `flag:"cache" help:"Save the download (offline cache)."` - NoCache bool `flag:"no-cache" help:"Install without caching. This is the default unless configured to always cache downloads."` - Force bool `flag:"force" help:"Force (re)install if the version already exists."` - Debug bool `flag:"debug" hidden:"true" help:"Write timing logs to the install directory for debugging."` - Insecure bool `flag:"insecure" help:"Accept invalid TLS/SSL certs from download sources."` + CopyFrom string `flag:"copy-from" help:"Copy the global modules from an existing Node.js installation to the new installation. This does affect the original version and does not verify module compatibility with the new version." placeholder:"VERSION"` + From string `flag:"from" help:"Install global modules from the list of modules installed in an another Node.js version." placeholder:"VERSION"` + Notify bool `flag:"notify" hidden:"true" help:"Notify user when a version has been automatically installed."` + Cache bool `flag:"cache" help:"Save the download (offline cache)."` + NoCache bool `flag:"no-cache" help:"Install without caching. This is the default unless configured to always cache downloads."` + Force bool `flag:"force" help:"Force (re)install if the version already exists."` + Debug bool `flag:"debug" hidden:"true" help:"Write timing logs to the install directory for debugging."` + Insecure bool `flag:"insecure" help:"Accept invalid TLS/SSL certs from download sources."` + RelaxDeadlines constant.RelaxDeadlines `optional:"" placeholder:"MS" help:"Relax network deadlines for this command. Omit a value to triple the configured budgets, or pass milliseconds."` constant.ArgVersion } func (s *Install) Run(ctx *kong.Context) error { + settings.UseRelax(s.RelaxDeadlines.Setting()) cfg := settings.Global() var cacheRoot string diff --git a/src/commands/sync.go b/src/commands/sync.go index c1eb23c..570ac17 100644 --- a/src/commands/sync.go +++ b/src/commands/sync.go @@ -49,11 +49,15 @@ func (s *Upgrade) Run() error { } type Doctor struct { - Checks []string `arg:"" optional:"" help:"Specific checks to run. If not specified, all checks will be run."` - Autofix bool `flag:"autofix" help:"Automatically fix issues when possible."` - List bool `flag:"list" help:"List all available checks without running them."` - Update bool `flag:"update" help:"Force sync utility asset update before running checks."` - Force bool `flag:"force" help:"When upgrading, do not warn about processes that will be closed."` + Checks []string `arg:"" optional:"" help:"Specific checks to run. If not specified, all checks will be run."` + Autofix bool `flag:"autofix" help:"Automatically fix issues when possible."` + List bool `flag:"list" help:"List all available checks without running them."` + Update bool `flag:"update" help:"Force sync utility asset update before running checks."` + Force bool `flag:"force" help:"When upgrading, do not warn about processes that will be closed."` + RelaxDeadlines constant.RelaxDeadlines `optional:"" placeholder:"MS" help:"Relax network deadlines for this command. Omit a value to triple the configured budgets, or pass milliseconds."` + MeasureDeadlines bool `flag:"measure-deadlines" help:"Time each download source and print min, average, and max."` + MeasureCount int `flag:"measure-count" default:"3" help:"How many times to repeat each deadline measurement."` + AutoDeadlines bool `flag:"auto-deadlines" help:"Measure deadlines and save HKCU values from the results."` constant.FlagJSON } @@ -76,6 +80,22 @@ func (c *Doctor) Run() error { if c.Force { args = append(args, "--force") } + if c.RelaxDeadlines.Active() { + if c.RelaxDeadlines.Milliseconds() > 0 { + args = append(args, fmt.Sprintf("--relax-deadlines=%d", c.RelaxDeadlines.Milliseconds())) + } else { + args = append(args, "--relax-deadlines") + } + } + if c.MeasureDeadlines { + args = append(args, "--measure-deadlines") + } + if c.AutoDeadlines { + args = append(args, "--auto-deadlines") + } + if (c.MeasureDeadlines || c.AutoDeadlines) && c.MeasureCount > 0 { + args = append(args, "--measure-count", fmt.Sprintf("%d", c.MeasureCount)) + } if c.JSON { args = append(args, "--json") } diff --git a/src/constant/relax.go b/src/constant/relax.go new file mode 100644 index 0000000..a93355c --- /dev/null +++ b/src/constant/relax.go @@ -0,0 +1,49 @@ +package constant + +import ( + "common/settings" + "fmt" + "strconv" + "strings" + + "github.com/alecthomas/kong" +) + +// RelaxDeadlines is --relax-deadlines. +// A bare flag triples configured budgets. A positive integer is milliseconds. +// Fields stay unexported so kong treats the value as one flag. +type RelaxDeadlines struct { + active bool + milliseconds int +} + +func (r RelaxDeadlines) Active() bool { return r.active } + +func (r RelaxDeadlines) Milliseconds() int { return r.milliseconds } + +func (r RelaxDeadlines) Setting() settings.RelaxDeadlines { + return settings.RelaxDeadlines{Active: r.active, Milliseconds: r.milliseconds} +} + +func (r *RelaxDeadlines) Decode(ctx *kong.DecodeContext) error { + r.active = true + raw, ok := tokenString(ctx.Scan.Peek().Value) + if !ok || raw == "" || strings.HasPrefix(raw, "-") { + return nil + } + n, err := strconv.Atoi(raw) + if err != nil { + return nil + } + if n <= 0 { + return fmt.Errorf("--relax-deadlines must be a positive number of milliseconds") + } + ctx.Scan.Pop() + r.milliseconds = n + return nil +} + +func tokenString(v any) (string, bool) { + s, ok := v.(string) + return s, ok +} diff --git a/src/constant/relax_test.go b/src/constant/relax_test.go new file mode 100644 index 0000000..8032e39 --- /dev/null +++ b/src/constant/relax_test.go @@ -0,0 +1,47 @@ +package constant + +import ( + "testing" + + "github.com/alecthomas/kong" +) + +func TestRelaxFlagParsing(t *testing.T) { + parse := func(args ...string) (RelaxDeadlines, []string) { + t.Helper() + var cli struct { + RelaxDeadlines RelaxDeadlines `optional:"" placeholder:"MS" help:"Relax network deadlines for this command."` + Version []string `arg:"" optional:""` + } + parser := kong.Must(&cli) + _, err := parser.Parse(args) + if err != nil { + t.Fatalf("parse %v: %v", args, err) + } + return cli.RelaxDeadlines, cli.Version + } + + bare, version := parse("--relax-deadlines") + if !bare.Active() || bare.Milliseconds() != 0 { + t.Fatalf("bare = %+v", bare) + } + if len(version) != 0 { + t.Fatalf("bare consumed args: %v", version) + } + + custom, version := parse("--relax-deadlines", "9000", "20") + if !custom.Active() || custom.Milliseconds() != 9000 { + t.Fatalf("custom = %+v", custom) + } + if len(version) != 1 || version[0] != "20" { + t.Fatalf("version = %v", version) + } + + absent, version := parse("22") + if absent.Active() { + t.Fatal("flag should be absent") + } + if len(version) != 1 || version[0] != "22" { + t.Fatalf("version = %v", version) + } +} diff --git a/src/installer/download_error.go b/src/installer/download_error.go index d51ee3c..bf3e07e 100644 --- a/src/installer/download_error.go +++ b/src/installer/download_error.go @@ -2,9 +2,12 @@ package installer import ( nvmhttp "common/http" + "common/settings" "fmt" "net/http" "strings" + + "nvm/log" ) func describeDownloadResultFailure(label, url string, result nvmhttp.DownloadResult) error { @@ -38,6 +41,22 @@ func formatNodeMirrorDownloadFailure(version, archiveName string, mirrors []stri mirrorNote, ) } + if nvmhttp.IsDeadline(lastErr) { + b := settings.ActiveNetworkBudgets().Download + log.LogNetworkDeadline("download", "", b.Milliseconds, b.Source) + note := "TimeoutDownloadMs " + b.Note() + if settings.ActiveNetworkBudgets().Verbose { + note = "phase=download " + note + } + return fmt.Errorf( + "failed to download Node.js v%s (%s) from %s (%s): %w", + version, + archiveName, + mirrorNote, + note, + lastErr, + ) + } return fmt.Errorf( "failed to download Node.js v%s (%s) from %s: %w", version, diff --git a/src/log/log.go b/src/log/log.go index 809e864..0899690 100644 --- a/src/log/log.go +++ b/src/log/log.go @@ -17,6 +17,19 @@ import ( // Callers can pass any JSON-marshalable value to LogStructured/WarnStructured/ErrorStructured. type StructuredPayload map[string]any +// LogNetworkDeadline emits informational NVM4501 when structured logging is allowed. +func LogNetworkDeadline(phase, url string, budgetMs int, source string) { + if !license.AllowsStructuredLogging() { + return + } + eventlog.LogStructured("network.deadline_exceeded", StructuredPayload{ + "phase": phase, + "url": url, + "budget_ms": budgetMs, + "source": source, + }, 4501) +} + func RegisterEventSource(appName string) error { return eventlog.RegisterEventSource(appName) } From 81c0a8b521382aa84d58c121cb750184670c0236 Mon Sep 17 00:00:00 2001 From: coreybutler <770982+coreybutler@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:08:14 -0500 Subject: [PATCH 3/5] feat: add flags for one network deadline Install, env, and doctor can set one Timeout value for that command. Co-authored-by: Cursor --- src/commands/env.go | 3 +- src/commands/install/install.go | 3 +- src/commands/sync.go | 2 + src/constant/deadline.go | 68 +++++++++++++++++++++++++++++++++ src/constant/deadline_test.go | 64 +++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 src/constant/deadline.go create mode 100644 src/constant/deadline_test.go diff --git a/src/commands/env.go b/src/commands/env.go index fab959e..0b53816 100644 --- a/src/commands/env.go +++ b/src/commands/env.go @@ -37,6 +37,7 @@ var ( type Env struct { constant.FlagJSON RelaxDeadlines constant.RelaxDeadlines `optional:"" placeholder:"MS" help:"Relax network deadlines for this command. Omit a value to triple the configured budgets, or pass milliseconds."` + Deadlines constant.DeadlineFlags `embed:""` } type installData struct { @@ -136,7 +137,7 @@ var ( ) func (e *Env) Run(ctx *kong.Context, vars kong.Vars) error { - settings.UseRelax(e.RelaxDeadlines.Setting()) + settings.UseCommandDeadlines(e.RelaxDeadlines.Setting(), e.Deadlines.Setting()) var spinner *status.Spinner if !e.JSON { spinner = status.NewSpinner("Analyzing environment") diff --git a/src/commands/install/install.go b/src/commands/install/install.go index b83226a..8f7b695 100644 --- a/src/commands/install/install.go +++ b/src/commands/install/install.go @@ -19,11 +19,12 @@ type Install struct { Debug bool `flag:"debug" hidden:"true" help:"Write timing logs to the install directory for debugging."` Insecure bool `flag:"insecure" help:"Accept invalid TLS/SSL certs from download sources."` RelaxDeadlines constant.RelaxDeadlines `optional:"" placeholder:"MS" help:"Relax network deadlines for this command. Omit a value to triple the configured budgets, or pass milliseconds."` + Deadlines constant.DeadlineFlags `embed:""` constant.ArgVersion } func (s *Install) Run(ctx *kong.Context) error { - settings.UseRelax(s.RelaxDeadlines.Setting()) + settings.UseCommandDeadlines(s.RelaxDeadlines.Setting(), s.Deadlines.Setting()) cfg := settings.Global() var cacheRoot string diff --git a/src/commands/sync.go b/src/commands/sync.go index 570ac17..cd3727e 100644 --- a/src/commands/sync.go +++ b/src/commands/sync.go @@ -55,6 +55,7 @@ type Doctor struct { Update bool `flag:"update" help:"Force sync utility asset update before running checks."` Force bool `flag:"force" help:"When upgrading, do not warn about processes that will be closed."` RelaxDeadlines constant.RelaxDeadlines `optional:"" placeholder:"MS" help:"Relax network deadlines for this command. Omit a value to triple the configured budgets, or pass milliseconds."` + Deadlines constant.DeadlineFlags `embed:""` MeasureDeadlines bool `flag:"measure-deadlines" help:"Time each download source and print min, average, and max."` MeasureCount int `flag:"measure-count" default:"3" help:"How many times to repeat each deadline measurement."` AutoDeadlines bool `flag:"auto-deadlines" help:"Measure deadlines and save HKCU values from the results."` @@ -87,6 +88,7 @@ func (c *Doctor) Run() error { args = append(args, "--relax-deadlines") } } + args = append(args, c.Deadlines.Args()...) if c.MeasureDeadlines { args = append(args, "--measure-deadlines") } diff --git a/src/constant/deadline.go b/src/constant/deadline.go new file mode 100644 index 0000000..a50d4f1 --- /dev/null +++ b/src/constant/deadline.go @@ -0,0 +1,68 @@ +package constant + +import ( + "common/settings" + "fmt" + "strconv" + "strings" + + "github.com/alecthomas/kong" +) + +// DeadlineMs is one optional millisecond flag. +// Fields stay unexported so kong treats the value as one flag. +type DeadlineMs struct { + set bool + ms int +} + +func (d DeadlineMs) Set() bool { return d.set } + +func (d DeadlineMs) Milliseconds() int { return d.ms } + +func (d *DeadlineMs) Decode(ctx *kong.DecodeContext) error { + raw, ok := tokenString(ctx.Scan.Peek().Value) + if !ok || raw == "" || strings.HasPrefix(raw, "-") { + return fmt.Errorf("must be a positive number of milliseconds") + } + n, err := strconv.Atoi(raw) + if err != nil || n < 1 { + return fmt.Errorf("must be a positive number of milliseconds") + } + ctx.Scan.Pop() + d.set = true + d.ms = n + return nil +} + +// DeadlineFlags sets one network deadline for this command. +// Kong embed flattens these fields onto the command. +type DeadlineFlags struct { + TimeoutCatalogMs DeadlineMs `optional:"" placeholder:"MS" help:"Set the catalog deadline for this command, in milliseconds."` + TimeoutCatalogMirrorMs DeadlineMs `optional:"" placeholder:"MS" help:"Set the per-mirror catalog deadline for this command, in milliseconds."` + TimeoutReachabilityMs DeadlineMs `optional:"" placeholder:"MS" help:"Set the reachability deadline for this command, in milliseconds."` + TimeoutDownloadMs DeadlineMs `optional:"" placeholder:"MS" help:"Set the download deadline for this command, in milliseconds."` +} + +func (f DeadlineFlags) Setting() settings.StepDeadlines { + return settings.StepDeadlines{ + CatalogMs: f.TimeoutCatalogMs.Milliseconds(), + CatalogMirrorMs: f.TimeoutCatalogMirrorMs.Milliseconds(), + ReachabilityMs: f.TimeoutReachabilityMs.Milliseconds(), + DownloadMs: f.TimeoutDownloadMs.Milliseconds(), + } +} + +func (f DeadlineFlags) Args() []string { + args := make([]string, 0, 4) + add := func(flag string, d DeadlineMs) { + if d.Set() { + args = append(args, fmt.Sprintf("--%s=%d", flag, d.Milliseconds())) + } + } + add("timeout-catalog-ms", f.TimeoutCatalogMs) + add("timeout-catalog-mirror-ms", f.TimeoutCatalogMirrorMs) + add("timeout-reachability-ms", f.TimeoutReachabilityMs) + add("timeout-download-ms", f.TimeoutDownloadMs) + return args +} diff --git a/src/constant/deadline_test.go b/src/constant/deadline_test.go new file mode 100644 index 0000000..e5ac523 --- /dev/null +++ b/src/constant/deadline_test.go @@ -0,0 +1,64 @@ +package constant + +import ( + "strings" + "testing" + + "github.com/alecthomas/kong" +) + +func TestDeadlineFlagParsing(t *testing.T) { + parse := func(args ...string) (DeadlineFlags, RelaxDeadlines, []string, error) { + t.Helper() + var cli struct { + RelaxDeadlines RelaxDeadlines `optional:"" placeholder:"MS"` + DeadlineFlags `embed:""` + Version []string `arg:"" optional:""` + } + parser := kong.Must(&cli) + _, err := parser.Parse(args) + return cli.DeadlineFlags, cli.RelaxDeadlines, cli.Version, err + } + + mirror, relax, version, err := parse("--timeout-catalog-mirror-ms", "3000", "22") + if err != nil { + t.Fatal(err) + } + if !mirror.TimeoutCatalogMirrorMs.Set() || mirror.TimeoutCatalogMirrorMs.Milliseconds() != 3000 { + t.Fatalf("mirror = %+v", mirror.TimeoutCatalogMirrorMs) + } + if mirror.TimeoutCatalogMs.Set() || relax.Active() { + t.Fatalf("other flags set: %+v %+v", mirror, relax) + } + if len(version) != 1 || version[0] != "22" { + t.Fatalf("version = %v", version) + } + steps := mirror.Setting() + if steps.CatalogMirrorMs != 3000 || steps.CatalogMs != 0 || steps.DownloadMs != 0 { + t.Fatalf("steps = %+v", steps) + } + if got := strings.Join(mirror.Args(), " "); got != "--timeout-catalog-mirror-ms=3000" { + t.Fatalf("args = %q", got) + } + + both, relax, version, err := parse("--relax-deadlines", "--timeout-download-ms=5000", "20") + if err != nil { + t.Fatal(err) + } + if !relax.Active() || relax.Milliseconds() != 0 { + t.Fatalf("relax = %+v", relax) + } + if both.TimeoutDownloadMs.Milliseconds() != 5000 || both.Setting().DownloadMs != 5000 { + t.Fatalf("download = %+v", both.TimeoutDownloadMs) + } + if len(version) != 1 || version[0] != "20" { + t.Fatalf("version = %v", version) + } + + if _, _, _, err := parse("--timeout-catalog-ms=0"); err == nil { + t.Fatal("expected rejection of 0") + } + if _, _, _, err := parse("--timeout-reachability-ms"); err == nil { + t.Fatal("expected rejection of a missing value") + } +} From 870995871145ba06f5a601d300833b68476a0515 Mon Sep 17 00:00:00 2001 From: coreybutler <770982+coreybutler@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:25:05 -0500 Subject: [PATCH 4/5] feat(doctor): describe the 59-attempt deadline save Co-authored-by: Cursor --- src/commands/sync.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/sync.go b/src/commands/sync.go index cd3727e..0edfe4d 100644 --- a/src/commands/sync.go +++ b/src/commands/sync.go @@ -57,8 +57,8 @@ type Doctor struct { RelaxDeadlines constant.RelaxDeadlines `optional:"" placeholder:"MS" help:"Relax network deadlines for this command. Omit a value to triple the configured budgets, or pass milliseconds."` Deadlines constant.DeadlineFlags `embed:""` MeasureDeadlines bool `flag:"measure-deadlines" help:"Time each download source and print min, average, and max."` - MeasureCount int `flag:"measure-count" default:"3" help:"How many times to repeat each deadline measurement."` - AutoDeadlines bool `flag:"auto-deadlines" help:"Measure deadlines and save HKCU values from the results."` + MeasureCount int `flag:"measure-count" default:"3" help:"How many times to repeat each deadline measurement. Ignored with --auto-deadlines."` + AutoDeadlines bool `flag:"auto-deadlines" help:"Run 59 attempts per download source and save the slowest sample as your deadlines."` constant.FlagJSON } From d342407d8bb6c24ddf40897c4e374dfe1b80d0b2 Mon Sep 17 00:00:00 2001 From: coreybutler <770982+coreybutler@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:40:11 -0500 Subject: [PATCH 5/5] ci: let checks read pull requests and common The change filter needs pull request permission. Tests replace common modules from the sibling checkout, which the runner does not have. The team guard needs members permission. Co-authored-by: Cursor --- .github/workflows/test.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3e96aa1..baf7287 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,7 @@ jobs: runs-on: ubuntu-slim permissions: contents: read + pull-requests: read outputs: has_changes: ${{ steps.compute.outputs.has_changes }} matrix: ${{ steps.compute.outputs.matrix }} @@ -78,6 +79,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + members: read steps: - name: Validate Team Membership For Sensitive Changes env: @@ -123,6 +125,14 @@ jobs: - name: Checkout Code uses: actions/checkout@v6 + - name: Checkout common modules + shell: pwsh + run: | + $dest = Join-Path (Split-Path (Split-Path $env:GITHUB_WORKSPACE -Parent) -Parent) "common" + if (-not (Test-Path (Join-Path $dest "settings\go.mod"))) { + git clone --depth 1 https://github.com/nvm-windows/common.git $dest + } + - name: Set up Go uses: actions/setup-go@v6 with: