diff --git a/services/lmstudio-proxy/README.md b/services/lmstudio-proxy/README.md index 71a8b70d..76c76e24 100644 --- a/services/lmstudio-proxy/README.md +++ b/services/lmstudio-proxy/README.md @@ -29,6 +29,7 @@ lmstudio-proxy [flags] | `--ignore-persisted-port` | `false` | Use `--port` even when a prior runtime port was saved | | `--ipc` | *(empty — use stdio)* | Path to a Unix domain socket or Windows named pipe for IPC | | `--cluster-dir` | *(empty)* | Cluster trust directory (`node.crt`/`node.key` plus trusted pins). Enables the LAN mTLS inference ingress while this node is a cluster member; empty means no ingress and no peer candidates. | +| `--response-header-timeout` | *(`$NVPAIR_PROXY_RESPONSE_HEADER_TIMEOUT`, else `30m`)* | How long to wait for an upstream's first response header byte (Go duration; `0` waits indefinitely). The clock starts when the request is sent, so for a **non-streaming** completion it covers queueing and the whole generation — the engine sends nothing until the answer is complete. Streaming requests are unaffected. Applies to every inference transport, including the mTLS ingress hop on a peer, so raise it on every node a request may traverse. Unreachable hosts are still cut off by the 10 s dial timeout. | | `--log-level` | *(`$NVPAIR_LOG_LEVEL`, else `info`)* | Initial log level: `debug`, `info`, `warn`, or `error`. Changeable at runtime with `log/set-level`. | | `--version` | | Print version and exit | diff --git a/services/lmstudio-proxy/main.go b/services/lmstudio-proxy/main.go index 846ac9cb..9446bf4b 100644 --- a/services/lmstudio-proxy/main.go +++ b/services/lmstudio-proxy/main.go @@ -16,6 +16,7 @@ import ( "nvpair-shared/applog" "nvpair-shared/clustertrust" + "nvpair-shared/envflag" ) func main() { @@ -25,6 +26,8 @@ func main() { clusterDir := flag.String("cluster-dir", "", "cluster trust directory (node.crt/key + trusted pins); enables the LAN mTLS inference ingress when this node is clustered") showVersion := flag.Bool("version", false, "print version and exit") resolveLevel := applog.RegisterFlag(nil, slog.LevelInfo) + resolveResponseHeaderTimeout := envflag.RegisterDuration(nil, "response-header-timeout", "NVPAIR_PROXY_RESPONSE_HEADER_TIMEOUT", + defaultResponseHeaderTimeout, "how long to wait for an upstream's first response header byte, e.g. 30m or 0 to wait indefinitely; a non-streaming completion sends none until it is fully generated") flag.Parse() if *showVersion { @@ -73,6 +76,11 @@ func main() { codec := NewCodec(transport) disc := NewDiscovery() proxy := NewProxy(codec, disc, effectivePort) + responseHeaderTimeout, err := resolveResponseHeaderTimeout() + if err != nil { + log.Fatalf("invalid response header timeout: %v", err) + } + proxy.responseHeaderTimeout = responseHeaderTimeout // Open a live view of this node's cluster mTLS trust fabric. While unclustered // the proxy serves only the loopback plaintext personality; once this node is // a member the same listener also serves the pin-gated LAN mTLS ingress, and diff --git a/services/lmstudio-proxy/proxy.go b/services/lmstudio-proxy/proxy.go index 6e619e77..86ad5a5d 100644 --- a/services/lmstudio-proxy/proxy.go +++ b/services/lmstudio-proxy/proxy.go @@ -372,6 +372,13 @@ type Proxy struct { transportMu sync.Mutex plainTransport *http.Transport peerTransports map[string]*http.Transport + // responseHeaderTimeout is the ResponseHeaderTimeout applied to every + // inference transport (plain, manual and mTLS peer alike); 0 disables it. + // Defaults to defaultResponseHeaderTimeout; main overrides it from + // --response-header-timeout / $NVPAIR_PROXY_RESPONSE_HEADER_TIMEOUT + // before serving. Transports are built lazily, so it must be final by + // the first forwarded request. + responseHeaderTimeout time.Duration // nextRequestID is a monotonic counter for tagging RequestStarted / // RequestEvent pairs. Atomic add returns the new value, so request @@ -400,6 +407,8 @@ func NewProxy(codec *Codec, discovery *Discovery, port int) *Proxy { targets: reach.NewChooser(), runID: newRunID(), activity: nodeactivity.NewReporter(activityReportInterval), + + responseHeaderTimeout: defaultResponseHeaderTimeout, } } @@ -512,7 +521,6 @@ func (p *Proxy) Run(ctx context.Context) error { const ( proxyDialTimeout = 10 * time.Second proxyKeepAlive = 30 * time.Second - proxyResponseTimeout = 120 * time.Second proxyMaxIdleConns = 50 proxyIdleConnTimeout = 90 * time.Second // Inbound http.Server limits — keep IdleTimeout aligned with client @@ -522,6 +530,20 @@ const ( maxModelListBytes = 16 << 20 ) +// defaultResponseHeaderTimeout bounds how long the proxy waits for the +// FIRST response header byte from an upstream engine or peer. It starts +// when the request is sent, so for a non-streaming completion it covers +// queueing, prefill and the whole generation: the engine sends nothing +// until the answer is complete. The old 120s cut off any non-streaming +// request that queued behind other jobs or ran a long reasoning model, +// and the peer's ingress hop applied the same limit again. Unreachable +// hosts are still caught by proxyDialTimeout, dead clients by +// idleClientWriteTimeout, and a client that gives up cancels the upstream +// request through its context; this deadline only remains as a backstop +// for an engine that accepted the connection and never answers. +// Overridable per install: see Proxy.responseHeaderTimeout. +const defaultResponseHeaderTimeout = 30 * time.Minute + // idleClientWriteTimeout bounds how long a single write of streamed response // bytes to the client may block. A killed client can leave a half-open socket // whose kernel send buffer fills and never drains; without this deadline the @@ -588,7 +610,7 @@ func (p *Proxy) serveHTTP(ctx context.Context, ln net.Listener) { slog.Info("proxy timeouts configured", "dial_timeout", proxyDialTimeout, "keep_alive", proxyKeepAlive, - "response_header_timeout", proxyResponseTimeout, + "response_header_timeout", p.responseHeaderTimeout, "max_idle_conns", proxyMaxIdleConns, "idle_conn_timeout", proxyIdleConnTimeout, ) @@ -713,13 +735,13 @@ func (p *Proxy) candidateTransport(c candidate) *http.Transport { return p.peerHTTPTransport(c.peerUUID) } -func newProxyTransport(tlsCfg *tls.Config) *http.Transport { +func newProxyTransport(tlsCfg *tls.Config, responseHeaderTimeout time.Duration) *http.Transport { tr := &http.Transport{ DialContext: (&net.Dialer{ Timeout: proxyDialTimeout, KeepAlive: proxyKeepAlive, }).DialContext, - ResponseHeaderTimeout: proxyResponseTimeout, + ResponseHeaderTimeout: responseHeaderTimeout, MaxIdleConns: proxyMaxIdleConns, MaxIdleConnsPerHost: proxyMaxIdleConns, IdleConnTimeout: proxyIdleConnTimeout, @@ -734,7 +756,7 @@ func (p *Proxy) plainHTTPTransport() *http.Transport { p.transportMu.Lock() defer p.transportMu.Unlock() if p.plainTransport == nil { - p.plainTransport = newProxyTransport(nil) + p.plainTransport = newProxyTransport(nil, p.responseHeaderTimeout) } return p.plainTransport } @@ -750,13 +772,13 @@ func (p *Proxy) peerHTTPTransport(peerUUID string) *http.Transport { delete(p.peerTransports, peerUUID) } if p.mesh == nil { - return newProxyTransport(nil) + return newProxyTransport(nil, p.responseHeaderTimeout) } cfg, ok := p.mesh.ClientTLSConfig(peerUUID) if !ok { - return newProxyTransport(nil) + return newProxyTransport(nil, p.responseHeaderTimeout) } - tr := newProxyTransport(cfg) + tr := newProxyTransport(cfg, p.responseHeaderTimeout) if p.peerTransports == nil { p.peerTransports = make(map[string]*http.Transport) } diff --git a/services/lmstudio-proxy/response_header_timeout_test.go b/services/lmstudio-proxy/response_header_timeout_test.go new file mode 100644 index 00000000..2cbb01bc --- /dev/null +++ b/services/lmstudio-proxy/response_header_timeout_test.go @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// The configured header timeout must reach every inference transport, and 0 +// must mean "no deadline" rather than Go's default of none-set-yet. +func TestResponseHeaderTimeoutReachesEveryTransport(t *testing.T) { + p := testProxy(NewDiscovery(), 1235) + if p.responseHeaderTimeout != defaultResponseHeaderTimeout { + t.Fatalf("default = %v, want %v", p.responseHeaderTimeout, defaultResponseHeaderTimeout) + } + for _, want := range []time.Duration{45 * time.Minute, 0} { + p := testProxy(NewDiscovery(), 1235) + p.responseHeaderTimeout = want + if got := p.plainHTTPTransport().ResponseHeaderTimeout; got != want { + t.Errorf("plain transport ResponseHeaderTimeout = %v, want %v", got, want) + } + // Unclustered, a peer lookup falls back to a plain transport built the same way. + if got := p.peerHTTPTransport("no-such-peer").ResponseHeaderTimeout; got != want { + t.Errorf("peer transport ResponseHeaderTimeout = %v, want %v", got, want) + } + } +} + +// A non-streaming completion sends no header until generation ends. With a +// deadline shorter than that silence the request must fail as an upstream +// error; with a longer one the same request must succeed. This is the +// behaviour the 120s default broke for queued or long-running jobs. +func TestResponseHeaderTimeoutBoundsSilentUpstream(t *testing.T) { + const silence = 500 * time.Millisecond + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(silence) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"choices":[]}`)) + })) + defer upstream.Close() + + for _, tc := range []struct { + name string + timeout time.Duration + status int + }{ + {"shorter than the silence", silence / 3, http.StatusBadGateway}, + {"longer than the silence", 10 * silence, http.StatusOK}, + {"disabled", 0, http.StatusOK}, + } { + t.Run(tc.name, func(t *testing.T) { + disc := NewDiscovery() + disc.AddManual(nodeForModel(t, "serving-node", upstream.URL, "qwen")) + p := testProxy(disc, 1235) + p.responseHeaderTimeout = tc.timeout + + rec := httptest.NewRecorder() + p.handleHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"qwen"}`))) + if rec.Code != tc.status { + t.Fatalf("status = %d (%s), want %d", rec.Code, strings.TrimSpace(rec.Body.String()), tc.status) + } + }) + } +} diff --git a/services/ollama-proxy/README.md b/services/ollama-proxy/README.md index 35f959b5..fba9f8c1 100644 --- a/services/ollama-proxy/README.md +++ b/services/ollama-proxy/README.md @@ -28,6 +28,7 @@ ollama-proxy [flags] | `--ignore-persisted-port` | `false` | Use `--port` even when `proxy-port.json` contains a saved port (used by broker-managed startup) | | `--ipc` | *(empty — use stdio)* | Path to a Unix domain socket or Windows named pipe for IPC | | `--cluster-dir` | *(empty)* | Cluster trust directory (`node.crt`/`node.key` plus trusted pins). Enables the LAN mTLS inference ingress while this node is a cluster member; empty means no ingress and no peer candidates. | +| `--response-header-timeout` | *(`$NVPAIR_PROXY_RESPONSE_HEADER_TIMEOUT`, else `30m`)* | How long to wait for an upstream's first response header byte (Go duration; `0` waits indefinitely). The clock starts when the request is sent, so for a **non-streaming** completion it covers queueing and the whole generation — the engine sends nothing until the answer is complete. Streaming requests are unaffected. Applies to every inference transport, including the mTLS ingress hop on a peer, so raise it on every node a request may traverse. Unreachable hosts are still cut off by the 10 s dial timeout. | | `--log-level` | *(`$NVPAIR_LOG_LEVEL`, else `info`)* | Initial log level: `debug`, `info`, `warn`, or `error`. Changeable at runtime with `log/set-level`. | | `--version` | | Print version and exit | diff --git a/services/ollama-proxy/main.go b/services/ollama-proxy/main.go index afd147dd..187922f8 100644 --- a/services/ollama-proxy/main.go +++ b/services/ollama-proxy/main.go @@ -17,6 +17,7 @@ import ( "nvpair-shared/applog" "nvpair-shared/clustertrust" + "nvpair-shared/envflag" ) type aliasAddressFlags []string @@ -36,6 +37,8 @@ func main() { clusterDir := flag.String("cluster-dir", "", "cluster trust directory (node.crt/key + trusted pins); enables the LAN mTLS inference ingress when this node is clustered") showVersion := flag.Bool("version", false, "print version and exit") resolveLevel := applog.RegisterFlag(nil, slog.LevelInfo) + resolveResponseHeaderTimeout := envflag.RegisterDuration(nil, "response-header-timeout", "NVPAIR_PROXY_RESPONSE_HEADER_TIMEOUT", + defaultResponseHeaderTimeout, "how long to wait for an upstream's first response header byte, e.g. 30m or 0 to wait indefinitely; a non-streaming completion sends none until it is fully generated") flag.Parse() if *showVersion { @@ -84,6 +87,11 @@ func main() { codec := NewCodec(transport) disc := NewDiscovery() proxy := NewProxy(codec, disc, effectivePort) + responseHeaderTimeout, err := resolveResponseHeaderTimeout() + if err != nil { + log.Fatalf("invalid response header timeout: %v", err) + } + proxy.responseHeaderTimeout = responseHeaderTimeout for _, aliasAddress := range aliasAddresses { if err := proxy.setLoopbackAlias(aliasAddress); err != nil { log.Fatalf("invalid alias address: %v", err) diff --git a/services/ollama-proxy/proxy.go b/services/ollama-proxy/proxy.go index ad4ac7a2..e125029d 100644 --- a/services/ollama-proxy/proxy.go +++ b/services/ollama-proxy/proxy.go @@ -381,6 +381,13 @@ type Proxy struct { transportMu sync.Mutex plainTransport *http.Transport peerTransports map[string]*http.Transport + // responseHeaderTimeout is the ResponseHeaderTimeout applied to every + // inference transport (plain, manual and mTLS peer alike); 0 disables it. + // Defaults to defaultResponseHeaderTimeout; main overrides it from + // --response-header-timeout / $NVPAIR_PROXY_RESPONSE_HEADER_TIMEOUT + // before serving. Transports are built lazily, so it must be final by + // the first forwarded request. + responseHeaderTimeout time.Duration // nextRequestID is a monotonic counter for tagging RequestStarted / // RequestEvent pairs. Atomic add returns the new value, so request @@ -409,6 +416,8 @@ func NewProxy(codec *Codec, discovery *Discovery, port int) *Proxy { targets: reach.NewChooser(), runID: newRunID(), activity: nodeactivity.NewReporter(activityReportInterval), + + responseHeaderTimeout: defaultResponseHeaderTimeout, } } @@ -528,7 +537,6 @@ func (p *Proxy) Run(ctx context.Context) error { const ( proxyDialTimeout = 10 * time.Second proxyKeepAlive = 30 * time.Second - proxyResponseTimeout = 120 * time.Second proxyMaxIdleConns = 50 proxyIdleConnTimeout = 90 * time.Second // Inbound http.Server limits — keep IdleTimeout aligned with client @@ -538,6 +546,20 @@ const ( maxModelListBytes = 16 << 20 ) +// defaultResponseHeaderTimeout bounds how long the proxy waits for the +// FIRST response header byte from an upstream engine or peer. It starts +// when the request is sent, so for a non-streaming completion it covers +// queueing, prefill and the whole generation: the engine sends nothing +// until the answer is complete. The old 120s cut off any non-streaming +// request that queued behind other jobs or ran a long reasoning model, +// and the peer's ingress hop applied the same limit again. Unreachable +// hosts are still caught by proxyDialTimeout, dead clients by +// idleClientWriteTimeout, and a client that gives up cancels the upstream +// request through its context; this deadline only remains as a backstop +// for an engine that accepted the connection and never answers. +// Overridable per install: see Proxy.responseHeaderTimeout. +const defaultResponseHeaderTimeout = 30 * time.Minute + // idleClientWriteTimeout bounds how long a single write of streamed response // bytes to the client may block. A killed client can leave a half-open socket // whose kernel send buffer fills and never drains; without this deadline the @@ -604,7 +626,7 @@ func (p *Proxy) serveHTTP(ctx context.Context, ln net.Listener) { slog.Info("proxy timeouts configured", "dial_timeout", proxyDialTimeout, "keep_alive", proxyKeepAlive, - "response_header_timeout", proxyResponseTimeout, + "response_header_timeout", p.responseHeaderTimeout, "max_idle_conns", proxyMaxIdleConns, "idle_conn_timeout", proxyIdleConnTimeout, ) @@ -867,13 +889,13 @@ func (p *Proxy) candidateTransport(c candidate) *http.Transport { return p.peerHTTPTransport(c.peerUUID) } -func newProxyTransport(tlsCfg *tls.Config) *http.Transport { +func newProxyTransport(tlsCfg *tls.Config, responseHeaderTimeout time.Duration) *http.Transport { tr := &http.Transport{ DialContext: (&net.Dialer{ Timeout: proxyDialTimeout, KeepAlive: proxyKeepAlive, }).DialContext, - ResponseHeaderTimeout: proxyResponseTimeout, + ResponseHeaderTimeout: responseHeaderTimeout, MaxIdleConns: proxyMaxIdleConns, MaxIdleConnsPerHost: proxyMaxIdleConns, IdleConnTimeout: proxyIdleConnTimeout, @@ -888,7 +910,7 @@ func (p *Proxy) plainHTTPTransport() *http.Transport { p.transportMu.Lock() defer p.transportMu.Unlock() if p.plainTransport == nil { - p.plainTransport = newProxyTransport(nil) + p.plainTransport = newProxyTransport(nil, p.responseHeaderTimeout) } return p.plainTransport } @@ -904,13 +926,13 @@ func (p *Proxy) peerHTTPTransport(peerUUID string) *http.Transport { delete(p.peerTransports, peerUUID) } if p.mesh == nil { - return newProxyTransport(nil) + return newProxyTransport(nil, p.responseHeaderTimeout) } cfg, ok := p.mesh.ClientTLSConfig(peerUUID) if !ok { - return newProxyTransport(nil) + return newProxyTransport(nil, p.responseHeaderTimeout) } - tr := newProxyTransport(cfg) + tr := newProxyTransport(cfg, p.responseHeaderTimeout) if p.peerTransports == nil { p.peerTransports = make(map[string]*http.Transport) } diff --git a/services/ollama-proxy/response_header_timeout_test.go b/services/ollama-proxy/response_header_timeout_test.go new file mode 100644 index 00000000..8b0280e7 --- /dev/null +++ b/services/ollama-proxy/response_header_timeout_test.go @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// The configured header timeout must reach every inference transport, and 0 +// must mean "no deadline" rather than Go's default of none-set-yet. +func TestResponseHeaderTimeoutReachesEveryTransport(t *testing.T) { + p := testProxy(NewDiscovery(), 11434) + if p.responseHeaderTimeout != defaultResponseHeaderTimeout { + t.Fatalf("default = %v, want %v", p.responseHeaderTimeout, defaultResponseHeaderTimeout) + } + for _, want := range []time.Duration{45 * time.Minute, 0} { + p := testProxy(NewDiscovery(), 11434) + p.responseHeaderTimeout = want + if got := p.plainHTTPTransport().ResponseHeaderTimeout; got != want { + t.Errorf("plain transport ResponseHeaderTimeout = %v, want %v", got, want) + } + // Unclustered, a peer lookup falls back to a plain transport built the same way. + if got := p.peerHTTPTransport("no-such-peer").ResponseHeaderTimeout; got != want { + t.Errorf("peer transport ResponseHeaderTimeout = %v, want %v", got, want) + } + } +} + +// A non-streaming completion sends no header until generation ends. With a +// deadline shorter than that silence the request must fail as an upstream +// error; with a longer one the same request must succeed. This is the +// behaviour the 120s default broke for queued or long-running jobs. +func TestResponseHeaderTimeoutBoundsSilentUpstream(t *testing.T) { + const silence = 500 * time.Millisecond + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(silence) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"choices":[]}`)) + })) + defer upstream.Close() + + for _, tc := range []struct { + name string + timeout time.Duration + status int + }{ + {"shorter than the silence", silence / 3, http.StatusBadGateway}, + {"longer than the silence", 10 * silence, http.StatusOK}, + {"disabled", 0, http.StatusOK}, + } { + t.Run(tc.name, func(t *testing.T) { + disc := NewDiscovery() + disc.AddManual(nodeForModel(t, "serving-node", upstream.URL, "llama")) + p := testProxy(disc, 11434) + p.responseHeaderTimeout = tc.timeout + + rec := httptest.NewRecorder() + p.handleHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader(`{"model":"llama"}`))) + if rec.Code != tc.status { + t.Fatalf("status = %d (%s), want %d", rec.Code, strings.TrimSpace(rec.Body.String()), tc.status) + } + }) + } +} diff --git a/services/readme.md b/services/readme.md index ee3523d5..382f2bf9 100644 --- a/services/readme.md +++ b/services/readme.md @@ -167,6 +167,14 @@ NVPAIR_LOG_LEVEL=debug ./build/bin/nvpair-ui-broker Accepted values: `debug`, `info`, `warn`, `error`. The level can also be changed live over the broker's `log/set-level` JSON-RPC method (not persisted across restarts), so for launch-time issues use the env var or the flag. +## Inference timeouts + +`ollama-proxy` and `lmstudio-proxy` wait up to **30 minutes** for an upstream's first response header byte (`--response-header-timeout`, or `NVPAIR_PROXY_RESPONSE_HEADER_TIMEOUT` in the environment the proxies inherit; `0` waits indefinitely). For a non-streaming completion that clock covers queueing and the whole generation, because the engine sends nothing until the answer is complete; streaming requests are unaffected. The same deadline runs on a peer's ingress hop, so set it on every node a request may traverse — on a headless install export it in `pair-start.sh` before the broker starts. Unreachable hosts are still cut off by the 10 s dial timeout, and a client that disconnects cancels the upstream request immediately. + +```bash +NVPAIR_PROXY_RESPONSE_HEADER_TIMEOUT=1h ./build/bin/nvpair-ui-broker # or 0 to disable +``` + ## Testing There are two layers, and while you are editing a component you want the first diff --git a/services/shared/envflag/duration.go b/services/shared/envflag/duration.go new file mode 100644 index 00000000..ca7ebbb3 --- /dev/null +++ b/services/shared/envflag/duration.go @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package envflag registers command-line flags whose default comes from an +// NVPAIR_* environment variable, so a value can be set on a headless install +// (pair-start.sh, a systemd unit) or on the desktop without a flag change. +package envflag + +import ( + "flag" + "fmt" + "os" + "time" +) + +// RegisterDuration registers a -- duration flag on the given FlagSet +// (or the default one when fs == nil) and returns a resolver that, when called +// after flag.Parse, returns the effective value using this precedence: +// +// CLI flag (if set) > env var > fallback +// +// Values use Go duration syntax ("2m30s", "0"). An unparseable or negative +// value from either source is an error naming its origin, so a typo in a unit +// file is reported rather than silently replaced by the fallback. +func RegisterDuration(fs *flag.FlagSet, name, envVar string, fallback time.Duration, usage string) func() (time.Duration, error) { + if fs == nil { + fs = flag.CommandLine + } + val := fs.String(name, "", fmt.Sprintf("%s (default: $%s or %s)", usage, envVar, fallback)) + return func() (time.Duration, error) { + if *val != "" { + return parse("--"+name, *val) + } + if env := os.Getenv(envVar); env != "" { + return parse("$"+envVar, env) + } + return fallback, nil + } +} + +func parse(origin, raw string) (time.Duration, error) { + d, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("%s: %q is not a duration (use e.g. \"30m\", \"90s\" or \"0\")", origin, raw) + } + if d < 0 { + return 0, fmt.Errorf("%s: %q must not be negative", origin, raw) + } + return d, nil +} diff --git a/services/shared/envflag/duration_test.go b/services/shared/envflag/duration_test.go new file mode 100644 index 00000000..a6d6d02e --- /dev/null +++ b/services/shared/envflag/duration_test.go @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package envflag + +import ( + "flag" + "strings" + "testing" + "time" +) + +const testEnv = "NVPAIR_TEST_ENVFLAG_DURATION" + +func resolve(t *testing.T, args []string, env string) (time.Duration, error) { + t.Helper() + if env != "" { + t.Setenv(testEnv, env) + } + fs := flag.NewFlagSet("test", flag.ContinueOnError) + get := RegisterDuration(fs, "wait", testEnv, 5*time.Minute, "how long to wait") + if err := fs.Parse(args); err != nil { + t.Fatalf("parse: %v", err) + } + return get() +} + +func TestFallbackWhenNothingIsSet(t *testing.T) { + d, err := resolve(t, nil, "") + if err != nil || d != 5*time.Minute { + t.Fatalf("got %v, %v; want 5m, nil", d, err) + } +} + +func TestEnvOverridesFallback(t *testing.T) { + d, err := resolve(t, nil, "90s") + if err != nil || d != 90*time.Second { + t.Fatalf("got %v, %v; want 90s, nil", d, err) + } +} + +func TestFlagOverridesEnv(t *testing.T) { + d, err := resolve(t, []string{"--wait", "0"}, "90s") + if err != nil || d != 0 { + t.Fatalf("got %v, %v; want 0, nil", d, err) + } +} + +func TestBadValuesNameTheirOrigin(t *testing.T) { + if _, err := resolve(t, nil, "soon"); err == nil || !strings.Contains(err.Error(), "$"+testEnv) { + t.Fatalf("env: got %v; want an error naming $%s", err, testEnv) + } + if _, err := resolve(t, []string{"--wait", "-1s"}, ""); err == nil || !strings.Contains(err.Error(), "--wait") { + t.Fatalf("flag: got %v; want an error naming --wait", err) + } +} diff --git a/services/versions.json b/services/versions.json index 29d8c230..b6d9b40e 100644 --- a/services/versions.json +++ b/services/versions.json @@ -1,10 +1,10 @@ { "$comment": "Single source of truth for all version numbers. See VERSIONING.md for bump rules.", - "product": "0.91.7", - "installer": "0.91.7", + "product": "0.92.0", + "installer": "0.92.0", "components": { - "ollama-proxy": "0.26.2", - "lmstudio-proxy": "0.16.2", + "ollama-proxy": "0.27.0", + "lmstudio-proxy": "0.17.0", "nvpair-node-info": "0.13.3", "nvpair-node-scanner": "0.20.3", "nvpair-manual-nodes": "0.11.1",