From 5a1e5d64de467374bc19930e4ed9a048380e2847 Mon Sep 17 00:00:00 2001 From: David Roizenman Date: Tue, 18 Nov 2025 23:02:50 -0800 Subject: [PATCH 1/4] fix: handle broken pipe error while rapidly reading from secrets mount --- pkg/controllers/secrets.go | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/pkg/controllers/secrets.go b/pkg/controllers/secrets.go index 2dd0428a..b349b2b1 100644 --- a/pkg/controllers/secrets.go +++ b/pkg/controllers/secrets.go @@ -26,6 +26,7 @@ import ( "path/filepath" "sort" "strings" + "syscall" "text/template" "time" @@ -176,7 +177,7 @@ func MountSecrets(secrets []byte, mountPath string, maxReads int) (string, func( return "", nil, Error{Err: errors.New("The mount path already exists. This may be due to another running instance of the Doppler CLI, or due to an improper shutdown. If this is unexpected, delete the file and try again.")} } - if err := utils.CreateNamedPipe(mountPath, 0600); err != nil { + if err := utils.CreateNamedPipe(mountPath, 0o600); err != nil { return "", nil, Error{Err: err, Message: "Unable to mount secrets file"} } @@ -224,7 +225,6 @@ func MountSecrets(secrets []byte, mountPath string, maxReads int) (string, func( utils.HandleError(err, message) } - numReads++ utils.LogDebug("Secrets mount opened by reader") if _, err := f.Write(secrets); err != nil { @@ -232,6 +232,14 @@ func MountSecrets(secrets []byte, mountPath string, maxReads int) (string, func( if errors.Is(err, fs.ErrNotExist) && fifoCleanupStarted { break } + // broken pipe occurs when reader closes pipe before writing completes (eg. with vite dev server) + var errno syscall.Errno + if errors.As(err, &errno) && errno == syscall.EPIPE { + utils.LogDebug("Reader closed pipe before write completed") + _ = f.Close() + continue + } + cleanupFIFO() utils.HandleError(err, message) } @@ -241,10 +249,20 @@ func MountSecrets(secrets []byte, mountPath string, maxReads int) (string, func( if errors.Is(err, fs.ErrNotExist) && fifoCleanupStarted { break } + // broken pipe on close is safe to ignore - the reader has already disconnected + var errno syscall.Errno + if errors.As(err, &errno) && errno == syscall.EPIPE { + utils.LogDebug("Pipe closed by reader") + continue + } + cleanupFIFO() utils.HandleError(err, message) } + // only increment read count after successfully writing and closing + numReads++ + // delay before re-opening file so reader can detect an EOF. // if we immediately re-open the file, the original reader will keep reading time.Sleep(time.Millisecond * 10) From c16ce75cbc9b1ddaa5945828813569ad7128a6f0 Mon Sep 17 00:00:00 2001 From: David Roizenman Date: Wed, 19 Nov 2025 20:39:39 -0800 Subject: [PATCH 2/4] revert to counting reads before writing, but only when needed --- pkg/controllers/secrets.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/controllers/secrets.go b/pkg/controllers/secrets.go index b349b2b1..61875f2c 100644 --- a/pkg/controllers/secrets.go +++ b/pkg/controllers/secrets.go @@ -225,6 +225,9 @@ func MountSecrets(secrets []byte, mountPath string, maxReads int) (string, func( utils.HandleError(err, message) } + if enableReadsLimit { + numReads++ + } utils.LogDebug("Secrets mount opened by reader") if _, err := f.Write(secrets); err != nil { @@ -260,9 +263,6 @@ func MountSecrets(secrets []byte, mountPath string, maxReads int) (string, func( utils.HandleError(err, message) } - // only increment read count after successfully writing and closing - numReads++ - // delay before re-opening file so reader can detect an EOF. // if we immediately re-open the file, the original reader will keep reading time.Sleep(time.Millisecond * 10) From c2e41cec493ad4ba0a4a7fc52e08da81e6440e57 Mon Sep 17 00:00:00 2001 From: David Roizenman Date: Thu, 27 Nov 2025 20:19:04 -0800 Subject: [PATCH 3/4] simplify to errors.Is --- pkg/controllers/secrets.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pkg/controllers/secrets.go b/pkg/controllers/secrets.go index 61875f2c..bd23ac85 100644 --- a/pkg/controllers/secrets.go +++ b/pkg/controllers/secrets.go @@ -236,8 +236,7 @@ func MountSecrets(secrets []byte, mountPath string, maxReads int) (string, func( break } // broken pipe occurs when reader closes pipe before writing completes (eg. with vite dev server) - var errno syscall.Errno - if errors.As(err, &errno) && errno == syscall.EPIPE { + if errors.Is(err, syscall.EPIPE) { utils.LogDebug("Reader closed pipe before write completed") _ = f.Close() continue @@ -253,8 +252,7 @@ func MountSecrets(secrets []byte, mountPath string, maxReads int) (string, func( break } // broken pipe on close is safe to ignore - the reader has already disconnected - var errno syscall.Errno - if errors.As(err, &errno) && errno == syscall.EPIPE { + if errors.Is(err, syscall.EPIPE) { utils.LogDebug("Pipe closed by reader") continue } From 63d67a9d99f022b59adc57a77a57ca361f771857 Mon Sep 17 00:00:00 2001 From: David Roizenman Date: Sat, 10 Jan 2026 15:18:20 -0800 Subject: [PATCH 4/4] add test for #503 Co-authored-by: mikesellitto --- pkg/controllers/secrets_test.go | 60 +++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/pkg/controllers/secrets_test.go b/pkg/controllers/secrets_test.go index bccb47c6..ce03712a 100644 --- a/pkg/controllers/secrets_test.go +++ b/pkg/controllers/secrets_test.go @@ -17,9 +17,13 @@ limitations under the License. package controllers import ( + "os" + "path/filepath" "strings" "testing" + "time" + "github.com/DopplerHQ/cli/pkg/utils" "github.com/stretchr/testify/assert" ) @@ -106,3 +110,59 @@ func TestSecretsToBytes(t *testing.T) { t.Errorf("Unable to convert secrets to byte array in %s format", format) } } + +func TestMountSecrets(t *testing.T) { + if !utils.SupportsNamedPipes { + t.Skip("Named pipes not supported on this platform") + } + + secrets := []byte(`{"SECRET_KEY":"secret_value"}`) + mountPath := filepath.Join(t.TempDir(), "secrets_mount") + + path, cleanup, err := MountSecrets(secrets, mountPath, 1) + if !err.IsNil() { + t.Fatalf("MountSecrets failed: %v", err.Err) + } + defer cleanup() + + time.Sleep(50 * time.Millisecond) + + content, readErr := os.ReadFile(path) + assert.NoError(t, readErr) + assert.Equal(t, string(secrets), string(content)) +} + +func TestMountSecretsBrokenPipe(t *testing.T) { + if !utils.SupportsNamedPipes { + t.Skip("Named pipes not supported on this platform") + } + + // large data to exceed pipe buffer and trigger EPIPE when reader closes early + secrets := make([]byte, 256*1024) + for i := range secrets { + secrets[i] = byte('A' + (i % 26)) + } + mountPath := filepath.Join(t.TempDir(), "secrets_mount_epipe") + + path, cleanup, err := MountSecrets(secrets, mountPath, 11) + if !err.IsNil() { + t.Fatalf("MountSecrets failed: %v", err.Err) + } + defer cleanup() + + time.Sleep(50 * time.Millisecond) + + for i := 0; i < 10; i++ { + f, openErr := os.OpenFile(path, os.O_RDONLY, 0) + if openErr != nil { + continue + } + f.Read(make([]byte, 1)) + f.Close() + time.Sleep(5 * time.Millisecond) + } + + content, readErr := os.ReadFile(path) + assert.NoError(t, readErr, "mount should survive broken pipe") + assert.Equal(t, secrets, content) +}