Skip to content

Commit b99af57

Browse files
fix(docker): stat-probe overrides live negative cache in ResolveDockerPath (#696) (#699)
Minimal #696 fix per board decision (MCP-2744): drop the direct-exec spawn migration and all DOCKER_* env experiments; keep only the negative-cache fix. Root cause of 'status shows docker_path but spawn used bare docker': under a LaunchAgent/sandbox the first resolution can fail because only the restricted login-shell leg failed, caching a negative for dockerPathNegativeTTL=60s. Early upstream spawns hit that window and fall back to bare 'docker'. The well-known -path probe is a pure os.Stat (never sandbox- or login-shell-restricted), so a live cached negative must not shadow a docker binary present at a well-known path right now. ResolveDockerPath now re-runs the cheap os.Stat probe before honoring a still-live negative and, on success, upgrades the cache to a permanent success. With the cache fixed, the existing post-#697 shell wrap runs the resolved ABSOLUTE docker path as the command token, so login-shell PATH is irrelevant and #696 is fixed with no Colima regression and no env handling change. The direct-exec migration + login-shell env hydration are deferred to a separate systematic task. TDD: TestResolveDockerPath_StatProbeOverridesLiveNegative asserts a successful os.Stat well-known probe wins over a still-live cached negative (no TTL expiry). Related #696 Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent 79849e0 commit b99af57

2 files changed

Lines changed: 65 additions & 8 deletions

File tree

internal/shellwrap/shellwrap.go

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -217,15 +217,27 @@ func ResolveDockerPath(logger *zap.Logger) (string, error) {
217217
dockerPathMu.Lock()
218218
defer dockerPathMu.Unlock()
219219

220-
// Honor cache: keep successful resolutions forever; treat failures as
221-
// retryable after dockerPathNegativeTTL.
222-
if dockerPathHasResult {
223-
if dockerPathErr == nil {
224-
return dockerPath, nil
225-
}
226-
if !dockerPathExpires.IsZero() && time.Now().Before(dockerPathExpires) {
227-
return dockerPath, dockerPathErr
220+
// Honor cache: keep successful resolutions forever.
221+
if dockerPathHasResult && dockerPathErr == nil {
222+
return dockerPath, nil
223+
}
224+
225+
// A cached negative within its TTL would normally short-circuit here. But
226+
// the well-known-path probe is a pure os.Stat — never sandbox- or
227+
// login-shell-restricted — so a negative cached because only the restricted
228+
// login-shell leg failed must NOT permanently shadow a docker binary that is
229+
// sitting at a well-known path right now (the spawn-vs-status divergence in
230+
// MCP-2744). Re-run the cheap probe before honoring a live negative; on
231+
// success, upgrade the cache to a permanent success and return it.
232+
if dockerPathHasResult && dockerPathErr != nil &&
233+
!dockerPathExpires.IsZero() && time.Now().Before(dockerPathExpires) {
234+
if p := probeWellKnownDocker(logger); p != "" {
235+
dockerPath = p
236+
dockerPathErr = nil
237+
dockerPathExpires = time.Time{}
238+
return p, nil
228239
}
240+
return dockerPath, dockerPathErr
229241
}
230242

231243
path, err := resolveDockerPathUncached(logger)

internal/shellwrap/shellwrap_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,51 @@ func TestResolveDockerPath_NegativeTTLRetry(t *testing.T) {
219219
assert.Equal(t, want, got)
220220
}
221221

222+
// TestResolveDockerPath_StatProbeOverridesLiveNegative is the MCP-2744
223+
// regression guard: a still-LIVE cached negative (within its TTL, NOT expired)
224+
// must never shadow a docker binary that is sitting at a well-known path right
225+
// now. The well-known-path probe is a pure os.Stat — it is never sandbox- or
226+
// login-shell-restricted — so the first resolution failing only because the
227+
// restricted login-shell leg failed must not poison discovery for the whole
228+
// TTL window. Distinct from TestResolveDockerPath_NegativeTTLRetry, which
229+
// relies on TTL expiry; here the TTL stays at its default so the negative is
230+
// still "live" on the second call.
231+
func TestResolveDockerPath_StatProbeOverridesLiveNegative(t *testing.T) {
232+
if runtime.GOOS == "windows" {
233+
t.Skip("unix-only fixture")
234+
}
235+
resetDockerPathCacheForTest()
236+
t.Cleanup(resetDockerPathCacheForTest)
237+
238+
// Leave dockerPathNegativeTTL at its default (60s) so the cached negative is
239+
// still valid on the second call — we prove the os.Stat probe overrides it.
240+
241+
// FIRST call fails everywhere: no PATH docker, no well-known docker, the
242+
// login shell emits nothing.
243+
prevPaths := wellKnownDockerPathsFn
244+
wellKnownDockerPathsFn = func() []string { return nil }
245+
t.Cleanup(func() { wellKnownDockerPathsFn = prevPaths })
246+
247+
t.Setenv("PATH", t.TempDir())
248+
shellDir := t.TempDir()
249+
fakeShell := writeFakeShell(t, shellDir, "")
250+
t.Setenv("SHELL", fakeShell)
251+
252+
_, err := ResolveDockerPath(nil)
253+
require.Error(t, err, "first call should cache a negative (docker absent everywhere)")
254+
255+
// Docker Desktop's bundle binary now appears at a well-known path. The cheap
256+
// os.Stat probe must override the still-live cached negative on the very
257+
// next call — no TTL expiry required.
258+
dockerDir := t.TempDir()
259+
want := writeFakeDocker(t, dockerDir)
260+
wellKnownDockerPathsFn = func() []string { return []string{want} }
261+
262+
got, err := ResolveDockerPath(nil)
263+
require.NoError(t, err, "a live negative must not shadow a now-present well-known docker")
264+
assert.Equal(t, want, got)
265+
}
266+
222267
// TestResolveDockerPath_WellKnownPathFallback simulates a PKG-installer
223268
// context: docker is missing from $PATH and the login shell emits nothing,
224269
// but Docker Desktop installed a binary at a well-known path. The fallback

0 commit comments

Comments
 (0)