Skip to content

node: make the backend probe timeout configurable - #377

Merged
aojea merged 6 commits into
google:mainfrom
fer-marino:fix/configurable-backend-probe-timeout
Sep 10, 2026
Merged

aojea merged 6 commits into
google:mainfrom
fer-marino:fix/configurable-backend-probe-timeout

Conversation

@fer-marino

Copy link
Copy Markdown
Contributor

Fixes #376.

sam-node.yaml's command-spawned MCP services are given a hard-coded 2s (dhtProbeTimeout) to answer an MCP initialize before the service is registered but withheld from advertisement ("backend did not answer: context deadline exceeded"), with no retry observed afterwards.

2s is tighter than the cold-start cost of realistic backends. Measured directly: a bare import fastmcp (Python) takes ~2.7s, and even SAM's own reference example, npx -y @modelcontextprotocol/server-everything, takes ~4.0s to answer initialize on Windows even warm/cached - SAM's own documented reference server cannot reliably meet its own default.

Adds a new ServiceRegistry.SetBackendProbeTimeout, wired from a new --backend-probe-timeout flag (0 keeps the existing 2s default, matching the convention already used by --dht-max-record-age and similar flags). Backward compatible: NewServiceRegistry's default is unchanged, and every existing call site that doesn't pass the new flag behaves exactly as before (verified: all pre-existing internal/node tests pass unmodified, including 6 that fail identically on unmodified main - confirmed via git stash - so unrelated to this change).

Also fixes three of the four node.Options{} construction sites in cmd/sam-node/main.go that already wire NewServiceRegistry-adjacent flags (DHTMaxRecordAge et al.) but were missing this one; the fourth (join-only enrollment path) doesn't call RegisterStaticServices and is out of scope.

Adds TestServiceRegistry_BackendProbeTimeoutIsConfigurable, using a new slowProbingService test fake that (unlike the existing probingService) actually respects context deadlines, so it can demonstrate: the same slow backend fails to advertise under the default timeout and succeeds once given more time.

@google-cla

google-cla Bot commented Sep 9, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a configurable backend probe timeout (--backend-probe-timeout) to allow command-spawned service backends with slow cold-start times to be successfully advertised. Feedback on the changes suggests replacing time.After with time.NewTimer in the test helper to avoid short-term memory leaks, and updating probeTimeout() to fall back to the default timeout if backendProbeTimeout is zero or negative to handle zero-initialized registries robustly.

Comment thread internal/node/service_registry_test.go
Comment thread internal/node/service_registry.go Outdated
Comment thread internal/node/service_registry.go Outdated
@aojea

aojea commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

sing the CLA and address the review commentds and we are good to go, thanks

@fer-marino
fer-marino force-pushed the fix/configurable-backend-probe-timeout branch from 8384722 to 81db9e9 Compare September 9, 2026 09:38
Comment thread internal/node/service_registry.go Outdated
@aojea

aojea commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

I prefer to squash the commits, having the intermediate steps merged are not useful because they are discarded in commits later

@fer-marino

Copy link
Copy Markdown
Contributor Author

Sure. That is usually my preference too

Comment thread internal/node/options.go
@aojea

aojea commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

/gemini review

one last pass just to be sure, but this LGTM

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a configurable backend probe timeout (--backend-probe-timeout) to prevent command-spawned service backends with slow cold-start times from failing registration and advertisement. It updates the service registry to accept this timeout, falling back to a default of 2 seconds if not specified. Feedback on the changes suggests refactoring the newly added unit tests to avoid real-time delays (time.NewTimer), which can introduce flakiness in CI environments, in favor of a deterministic mock that inspects the context deadline.

Comment thread internal/node/service_registry_test.go Outdated
Comment on lines +280 to +351
// slowProbingService is a backendProber whose Probe blocks until the given
// delay elapses or the context is cancelled first, whichever comes first -
// unlike probingService, it actually respects the probe deadline, which is
// what a real command-spawned backend with a slow cold-start does.
type slowProbingService struct {
*fakeService
delay time.Duration
}

func newSlowProbingSvc(name string, delay time.Duration) *slowProbingService {
return &slowProbingService{
fakeService: newFakeSvc(name, api.ServiceType_SERVICE_TYPE_MCP),
delay: delay,
}
}

func (p *slowProbingService) Probe(ctx context.Context) error {
timer := time.NewTimer(p.delay)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}

// The bug behind #376: defaultDHTProbeTimeout was a hard-coded 2s with no way to
// raise it, so a backend whose own cold-start cost alone exceeds that -
// measured in practice for moderately-featured MCP server stacks - could
// never be advertised on its first registration. NewServiceRegistry's
// backendProbeTimeout parameter (wired from --backend-probe-timeout) is the
// fix: the same slow backend must fail to advertise under the default and
// succeed once constructed with more time.
func TestServiceRegistry_BackendProbeTimeoutIsConfigurable(t *testing.T) {
const probeDelay = 60 * time.Millisecond

t.Run("default timeout is too short for a slow backend", func(t *testing.T) {
dht := &fakeDHT{}
r := NewServiceRegistry(dht, 10*time.Millisecond) // shorter than probeDelay

svc := newSlowProbingSvc("slow", probeDelay)
if err := r.Register(context.Background(), svc); err != nil {
t.Fatalf("Register: %v", err)
}
if len(dht.calls) != 0 {
t.Errorf("Provide called %d times for a backend slower than the probe timeout, want 0", len(dht.calls))
}
})

t.Run("raising the timeout lets the same backend advertise", func(t *testing.T) {
dht := &fakeDHT{}
r := NewServiceRegistry(dht, probeDelay*5) // comfortably longer than probeDelay

svc := newSlowProbingSvc("slow", probeDelay)
if err := r.Register(context.Background(), svc); err != nil {
t.Fatalf("Register: %v", err)
}
if len(dht.calls) != 2 {
t.Errorf("Provide called %d times once given enough time to probe, want 2 (name + type CID)", len(dht.calls))
}
})

t.Run("zero or negative backendProbeTimeout falls back to defaultDHTProbeTimeout", func(t *testing.T) {
for _, d := range []time.Duration{0, -1 * time.Second} {
r := NewServiceRegistry(&fakeDHT{}, d)
if got := r.backendProbeTimeout; got != defaultDHTProbeTimeout {
t.Errorf("NewServiceRegistry(dht, %v).backendProbeTimeout = %v, want %v", d, got, defaultDHTProbeTimeout)
}
}
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using real-time delays (time.NewTimer with 60 * time.Millisecond) in unit tests can introduce flakiness, especially in resource-constrained or virtualized CI environments (like GitHub Actions) where CPU throttling can cause unexpected scheduling delays. Additionally, real-time sleeps slow down the test suite execution.

We can make this test 100% deterministic, faster, and free of real-time dependencies by using a deadline-inspecting and context-controlled fake service instead of a timer. For the timeout case, we can let Probe block on ctx.Done(). For the success case, we can inspect the deadline of the context passed to Probe to verify that the configured timeout is correctly applied.

type slowProbingService struct {
	*fakeService
	shouldTimeout bool
	lastDeadline  time.Time
	hasDeadline   bool
}

func newSlowProbingSvc(name string) *slowProbingService {
	return &slowProbingService{
		fakeService: newFakeSvc(name, api.ServiceType_SERVICE_TYPE_MCP),
	}
}

func (p *slowProbingService) Probe(ctx context.Context) error {
	p.lastDeadline, p.hasDeadline = ctx.Deadline()
	if p.shouldTimeout {
		<-ctx.Done()
		return ctx.Err()
	}
	return nil
}

// The bug behind #376: defaultDHTProbeTimeout was a hard-coded 2s with no way to
// raise it, so a backend whose own cold-start cost alone exceeds that -
// measured in practice for moderately-featured MCP server stacks - could
// never be advertised on its first registration. NewServiceRegistry's
// backendProbeTimeout parameter (wired from --backend-probe-timeout) is the
// fix: the same slow backend must fail to advertise under the default and
// succeed once constructed with more time.
func TestServiceRegistry_BackendProbeTimeoutIsConfigurable(t *testing.T) {
	t.Run("default timeout is too short for a slow backend", func(t *testing.T) {
		dht := &fakeDHT{}
		r := NewServiceRegistry(dht, 10*time.Millisecond)

		svc := newSlowProbingSvc("slow")
		svc.shouldTimeout = true
		if err := r.Register(context.Background(), svc); err != nil {
			t.Fatalf("Register: %v", err)
		}
		if len(dht.calls) != 0 {
			t.Errorf("Provide called %d times for a backend slower than the probe timeout, want 0", len(dht.calls))
		}
	})

	t.Run("raising the timeout applies the configured duration to the probe context", func(t *testing.T) {
		dht := &fakeDHT{}
		timeout := 500 * time.Millisecond
		r := NewServiceRegistry(dht, timeout)

		svc := newSlowProbingSvc("slow")
		if err := r.Register(context.Background(), svc); err != nil {
			t.Fatalf("Register: %v", err)
		}
		if len(dht.calls) != 2 {
			t.Errorf("Provide called %d times once given enough time to probe, want 2 (name + type CID)", len(dht.calls))
		}
		if !svc.hasDeadline {
			t.Fatal("expected probe context to have a deadline")
		}
		remaining := time.Until(svc.lastDeadline)
		if remaining > timeout || remaining < timeout-100*time.Millisecond {
			t.Errorf("expected probe deadline to be close to %v, got remaining %v", timeout, remaining)
		}
	})

	t.Run("zero or negative backendProbeTimeout falls back to defaultDHTProbeTimeout", func(t *testing.T) {
		for _, d := range []time.Duration{0, -1 * time.Second} {
			r := NewServiceRegistry(&fakeDHT{}, d)
			if got := r.backendProbeTimeout; got != defaultDHTProbeTimeout {
				t.Errorf("NewServiceRegistry(dht, %v).backendProbeTimeout = %v, want %v", d, got, defaultDHTProbeTimeout)
			}
		}
	})
}

@aojea

aojea commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

@fer-marino since you have to rebase, what about considering the suggestion in #377 (comment) to avoid flakes? I think is reasonable

sam-node.yaml's command-spawned MCP services are given a hard-coded 2s
(dhtProbeTimeout) to answer an MCP initialize before the service is
registered but withheld from advertisement ("backend did not answer:
context deadline exceeded"), with no retry observed afterwards.

2s is tighter than the cold-start cost of realistic backends. Measured
directly: a bare `import fastmcp` (Python) takes ~2.7s, and even SAM's
own reference example, `npx -y @modelcontextprotocol/server-everything`,
takes ~4.0s to answer initialize on Windows even warm/cached - SAM's own
documented reference server cannot reliably meet its own default.

Adds a new ServiceRegistry.SetBackendProbeTimeout, wired from a new
--backend-probe-timeout flag (0 keeps the existing 2s default, matching
the convention already used by --dht-max-record-age and similar flags).
Backward compatible: NewServiceRegistry's default is unchanged, and every
existing call site that doesn't pass the new flag behaves exactly as
before (verified: all pre-existing internal/node tests pass unmodified,
including 6 that fail identically on unmodified main - confirmed via git
stash - so unrelated to this change).

Also fixes three of the four node.Options{} construction sites in
cmd/sam-node/main.go that already wire NewServiceRegistry-adjacent flags
(DHTMaxRecordAge et al.) but were missing this one; the fourth (join-only
enrollment path) doesn't call RegisterStaticServices and is out of scope.

Adds TestServiceRegistry_BackendProbeTimeoutIsConfigurable, using a new
slowProbingService test fake that (unlike the existing probingService)
actually respects context deadlines, so it can demonstrate: the same slow
backend fails to advertise under the default timeout and succeeds once
given more time.
…ix timer leak in test

- aojea: renamed dhtProbeTimeout to defaultDHTProbeTimeout for clarity
  now that there's also a configurable per-registry value.
- gemini-code-assist: probeTimeout() now falls back to
  defaultDHTProbeTimeout when backendProbeTimeout is <= 0, so a
  zero-initialized ServiceRegistry (e.g. newServiceRegistryForTest's
  struct literal, which bypasses NewServiceRegistry) behaves the same as
  a properly constructed one instead of timing out every probe
  immediately.
- gemini-code-assist: slowProbingService.Probe now uses time.NewTimer
  with a deferred Stop() instead of time.After, avoiding the short-term
  timer leak when the context is cancelled before the delay elapses.

All internal/node tests pass, including the new
TestServiceRegistry_BackendProbeTimeoutIsConfigurable.
…etter pair

Config is populated once, at construction, everywhere else in this
package (see internal/node/options.go) - this had grown a setter and
a getter instead. NewServiceRegistry now takes backendProbeTimeout
directly and defaults it in the constructor if <= 0; the timeout is
never mutated afterwards, so reading the plain field needs no lock.
@fer-marino
fer-marino force-pushed the fix/configurable-backend-probe-timeout branch from deadf0e to 1d047d1 Compare September 10, 2026 07:41
@fer-marino

Copy link
Copy Markdown
Contributor Author

Both done, pushed as 1d047d1:

  • Rebased onto main (had a couple of real conflicts against feat(node): declare labels in the node config file instead of --labels #382's labels-config move, resolved in the two "fix rebase conflict"/follow-up commits).
  • Applied your suggested deterministic rewrite of the probe-timeout test verbatim - slowProbingService is now deadline-inspecting/context-controlled instead of a real timer, no more real-time sleeps.

@aojea

aojea commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Thanks

@aojea
aojea merged commit 4683977 into google:main Sep 10, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sam-node: command-spawned backend health-check deadline (~2.4s) has no configurable timeout, and is tighter than realistic MCP server startup costs

2 participants