From e1aa7d61f44456af6f712b786fdc59c95ca04e64 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/4] 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 d6bc2c9..3e987a8 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 6bebffdba4e628dee5153d57a2b2791b4a67d0eb 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/4] 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 853280c..b7b349d 100644 --- a/src/commands/sync.go +++ b/src/commands/sync.go @@ -3,8 +3,8 @@ package commands import ( "common/settings" "fmt" - "nvm/constant" "nvm/bootstrap" + "nvm/constant" "os" "os/exec" "path/filepath" @@ -45,10 +45,14 @@ 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."` + 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."` + 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 } @@ -68,6 +72,22 @@ func (c *Doctor) Run() error { if c.Update { args = append(args, "--update") } + 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 b1b5a29876bac9ae81e25896e7414232097257fc 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/4] 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 b7b349d..599f0be 100644 --- a/src/commands/sync.go +++ b/src/commands/sync.go @@ -50,6 +50,7 @@ type Doctor struct { 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."` 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."` @@ -79,6 +80,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 ccdcb7fad16dca7ad51eb8f79a455f2b02d7471b Mon Sep 17 00:00:00 2001 From: coreybutler <770982+coreybutler@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:25:26 -0500 Subject: [PATCH 4/4] 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 599f0be..8f0ac89 100644 --- a/src/commands/sync.go +++ b/src/commands/sync.go @@ -52,8 +52,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 }