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: 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..0b53816 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,19 @@ 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."` + Deadlines constant.DeadlineFlags `embed:""` } 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 +67,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 +137,7 @@ var ( ) func (e *Env) Run(ctx *kong.Context, vars kong.Vars) error { + settings.UseCommandDeadlines(e.RelaxDeadlines.Setting(), e.Deadlines.Setting()) var spinner *status.Spinner if !e.JSON { spinner = status.NewSpinner("Analyzing environment") @@ -249,6 +252,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 +293,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 +316,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 +461,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 +472,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 +481,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 +968,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 +1007,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..8f7b695 100644 --- a/src/commands/install/install.go +++ b/src/commands/install/install.go @@ -10,18 +10,21 @@ 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."` + Deadlines constant.DeadlineFlags `embed:""` constant.ArgVersion } func (s *Install) Run(ctx *kong.Context) error { + 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 c1eb23c..0edfe4d 100644 --- a/src/commands/sync.go +++ b/src/commands/sync.go @@ -49,11 +49,16 @@ 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."` + 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. 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 } @@ -76,6 +81,23 @@ 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") + } + } + args = append(args, c.Deadlines.Args()...) + 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/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") + } +} 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) } 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",