Skip to content

Commit 89cf261

Browse files
fix(shellwrap): unify ResolveDockerSource with ResolveDockerPath cache path (Codex P2 r4)
ResolveDockerSource honored a live cached negative by returning 'absent' WITHOUT running the well-known-path stat probe that ResolveDockerPath uses during the negative-TTL window — so docker_cli_source reported 'absent' while ResolveDockerPath returned the bundled path (schema-v5 telemetry mismatch). Root-cause the recurring divergence (this is the 2nd source-tracking drift between the two functions) by extracting a single shared resolveDockerPathLocked helper that is the only writer of the cache + dockerPathSource. ResolveDockerPath and ResolveDockerSource now drive the identical cache path, including the MCP-2744 override, so they cannot disagree on the same cache state. Regression test asserts ResolveDockerSource reports 'bundled' (not 'absent') when a well-known docker appears during the negative-TTL window — verified red->green against the shared-resolver refactor. Related #696 Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent 71c1b4e commit 89cf261

2 files changed

Lines changed: 56 additions & 22 deletions

File tree

internal/shellwrap/shellwrap.go

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,16 @@ func probeWellKnownDocker(logger *zap.Logger) string {
228228
func ResolveDockerPath(logger *zap.Logger) (string, error) {
229229
dockerPathMu.Lock()
230230
defer dockerPathMu.Unlock()
231+
return resolveDockerPathLocked(logger)
232+
}
231233

234+
// resolveDockerPathLocked is the single cache-aware resolver. Callers MUST hold
235+
// dockerPathMu. It is the one place the docker-path cache (path, err, expiry)
236+
// AND the parallel dockerPathSource enum are written, so ResolveDockerPath and
237+
// ResolveDockerSource can never diverge on the same cache state — including the
238+
// MCP-2744 stat-probe override. (Earlier the two functions duplicated this
239+
// logic and the source-tracking drifted between them.)
240+
func resolveDockerPathLocked(logger *zap.Logger) (string, error) {
232241
// Honor cache: keep successful resolutions forever.
233242
if dockerPathHasResult && dockerPathErr == nil {
234243
return dockerPath, nil
@@ -273,33 +282,18 @@ func ResolveDockerPath(logger *zap.Logger) (string, error) {
273282
// ResolveDockerSource returns the coarse, fixed-enum label describing how the
274283
// docker CLI was resolved (DockerSourcePath / DockerSourceBundled /
275284
// DockerSourceLoginShell), or DockerSourceAbsent when docker cannot be found.
276-
// It shares the same cache as ResolveDockerPath, so a prior successful or
277-
// failed resolution is reused (honoring the negative TTL). Never returns the
278-
// resolved path — only the branch — so callers (telemetry) cannot leak it.
285+
// It drives the SAME cache path as ResolveDockerPath (via resolveDockerPathLocked),
286+
// so the reported source always matches the resolution ResolveDockerPath would
287+
// give for the current cache state — including the MCP-2744 stat-probe override
288+
// during the negative-TTL window. Never returns the resolved path — only the
289+
// branch — so callers (telemetry) cannot leak it.
279290
func ResolveDockerSource(logger *zap.Logger) string {
280291
dockerPathMu.Lock()
281292
defer dockerPathMu.Unlock()
282-
283-
if dockerPathHasResult {
284-
if dockerPathErr == nil {
285-
return sourceOrAbsent(dockerPathSource)
286-
}
287-
if !dockerPathExpires.IsZero() && time.Now().Before(dockerPathExpires) {
288-
return DockerSourceAbsent
289-
}
290-
}
291-
292-
path, source, err := resolveDockerPathUncached(logger)
293-
dockerPath = path
294-
dockerPathSource = source
295-
dockerPathErr = err
296-
dockerPathHasResult = true
297-
if err != nil {
298-
dockerPathExpires = time.Now().Add(dockerPathNegativeTTL)
293+
if _, err := resolveDockerPathLocked(logger); err != nil {
299294
return DockerSourceAbsent
300295
}
301-
dockerPathExpires = time.Time{}
302-
return sourceOrAbsent(source)
296+
return sourceOrAbsent(dockerPathSource)
303297
}
304298

305299
// sourceOrAbsent normalizes an empty source string to DockerSourceAbsent so the

internal/shellwrap/shellwrap_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,46 @@ func TestResolveDockerSource_StatProbeUpgradeReportsBundled(t *testing.T) {
393393
"source must upgrade to bundled when the stat probe overrides the cached negative")
394394
}
395395

396+
// TestResolveDockerSource_NegativeTTLProbeOverride is the Codex round-4
397+
// regression guard: ResolveDockerSource must apply the same well-known-path
398+
// stat-probe override that ResolveDockerPath does during the negative-TTL
399+
// window. Before the shared-resolver refactor, ResolveDockerSource returned
400+
// "absent" off the cached negative without ever probing, so docker_cli_source
401+
// reported the wrong source while ResolveDockerPath returned the bundled path.
402+
func TestResolveDockerSource_NegativeTTLProbeOverride(t *testing.T) {
403+
if runtime.GOOS == "windows" {
404+
t.Skip("unix-only fixture")
405+
}
406+
resetDockerPathCacheForTest()
407+
t.Cleanup(resetDockerPathCacheForTest)
408+
409+
// FIRST resolution fails everywhere → caches a LIVE negative (default TTL).
410+
prevPaths := wellKnownDockerPathsFn
411+
wellKnownDockerPathsFn = func() []string { return nil }
412+
t.Cleanup(func() { wellKnownDockerPathsFn = prevPaths })
413+
414+
t.Setenv("PATH", t.TempDir())
415+
shellDir := t.TempDir()
416+
t.Setenv("SHELL", writeFakeShell(t, shellDir, ""))
417+
418+
_, err := ResolveDockerPath(nil)
419+
require.Error(t, err, "first call should cache a live negative")
420+
421+
// Docker now appears at a well-known path. The very next ResolveDockerSource
422+
// call must run the stat probe (not honor the cached "absent") and report
423+
// bundled — without any TTL expiry.
424+
dockerDir := t.TempDir()
425+
want := writeFakeDocker(t, dockerDir)
426+
wellKnownDockerPathsFn = func() []string { return []string{want} }
427+
428+
require.Equal(t, DockerSourceBundled, ResolveDockerSource(nil),
429+
"ResolveDockerSource must apply the stat-probe override during the negative-TTL window")
430+
// And ResolveDockerPath agrees (cache was upgraded to a permanent success).
431+
got, err := ResolveDockerPath(nil)
432+
require.NoError(t, err)
433+
require.Equal(t, want, got)
434+
}
435+
396436
func TestMinimalEnv_DropsSecrets(t *testing.T) {
397437
t.Setenv("AWS_ACCESS_KEY_ID", "AKIA_test_dummy_value_00000000")
398438
t.Setenv("GITHUB_TOKEN", "ghp_dummy_test_token_1234567890abcdef")

0 commit comments

Comments
 (0)