Skip to content

fix: return the smallest write from multiWriteSyncer - #1590

Open
kevin9327 wants to merge 1 commit into
uber-go:masterfrom
kevin9327:fix/multi-write-syncer-short-write
Open

kevin9327 wants to merge 1 commit into
uber-go:masterfrom
kevin9327:fix/multi-write-syncer-short-write

Conversation

@kevin9327

Copy link
Copy Markdown
Contributor

A WriteSyncer built from several sinks reports a full write even when one of
the sinks wrote nothing.

ws, _, _ := zap.Open("stdout", "/var/log/app.log")
// ...the log file's descriptor is later closed, or the disk fills up,
// so that sink's Write returns (0, err).

n, err := ws.Write(payload)  // n == len(payload), not 0

Writing to a closed or full file returns (0, err), which is the normal
short-write signal. multiWriteSyncer drops it: n comes back as the count
from whichever sink happened to be listed later. Callers that use the syncer as
the plain io.Writer it embeds — io.Copy, fmt.Fprint — then account for
bytes that were never written.

Cause

Write is documented directly above its own definition:

// When not all underlying syncers write the same number of bytes,
// the smallest number is returned even though Write() is called on
// all of them.
func (ws multiWriteSyncer) Write(p []byte) (int, error) {
	var writeErr error
	nWritten := 0
	for _, w := range ws {
		n, err := w.Write(p)
		writeErr = multierr.Append(writeErr, err)
		if nWritten == 0 && n != 0 {
			nWritten = n
		} else if n < nWritten {
			nWritten = n
		}
	}
	return nWritten, writeErr
}

nWritten == 0 is used to mean "nothing recorded yet", but 0 is also a
legitimate minimum. Once the running minimum is 0, the first branch matches
again on the next syncer and overwrites it. So a 0 only survives if it comes
from the last syncer:

syncers write returns smallest
1, 2, 3 1 1
3, 2, 1 1 1
0, 3 3 0
3, 0 0 0
3, 0, 5 5 0

Fix

Seed the minimum from the first syncer instead of from the zero value, so 0 is
carried through like any other count. An empty multiWriteSyncer still returns
0, unchanged.

Tests

TestMultiWriteSyncerReturnsSmallestWrite covers the five rows above.

Before, on unmodified master:

=== RUN   TestMultiWriteSyncerReturnsSmallestWrite/nothing_written_first
    write_syncer_test.go:134:
        	Error:      	Not equal:
        	            	expected: 0
        	            	actual  : 3
        	Messages:   	Expected the smallest number of bytes written.
=== RUN   TestMultiWriteSyncerReturnsSmallestWrite/nothing_written_in_the_middle
    write_syncer_test.go:134:
        	Error:      	Not equal:
        	            	expected: 0
        	            	actual  : 5
--- FAIL: TestMultiWriteSyncerReturnsSmallestWrite (0.00s)
    --- PASS: .../ascending (0.00s)
    --- PASS: .../descending (0.00s)
    --- FAIL: .../nothing_written_first (0.00s)
    --- PASS: .../nothing_written_last (0.00s)
    --- FAIL: .../nothing_written_in_the_middle (0.00s)

After, all five pass, and so do the existing tests that pin the current
behaviour — TestMultiWriteSyncerFailsShortWrite,
TestMultiWriteSyncerWritesBoth, TestMultiWriteSyncerFailsWrite,
TestNewMultiWriteSyncerWorksForSingleWriter,
TestWritestoAllSyncs_EvenIfFirstErrors and the three
TestMultiWriteSyncerSync_* tests.

Benchmarks

Write is on the logging hot path when more than one output is configured, so
I benchmarked three no-op syncers writing 128 bytes, before and after,
interleaved over two rounds (-benchtime=1s -count=6, three fastest ns/op per
run):

round 1  after:  18.00 18.14 25.15      before: 22.79 23.95 35.31
round 2  after:  23.67 28.30 33.18      before: 13.33 17.51 18.28

The ordering flips between rounds, so there is no measurable difference on this
machine — as expected, since the change swaps two comparisons for one. Both
versions are 0 B/op, 0 allocs/op.

Verification

go vet ./...     # clean
gofmt -l zapcore # clean
go test ./...

go test ./... produces the same set of failures before and after: TestConfig,
TestConfigWithSamplingHook, TestOpen, TestOpenOtherErrors,
TestStacktraceFiltersVendorZap and zapcore.TestIOCore. All are pre-existing
and Windows-only on this machine (t.TempDir() cleanup cannot delete a file zap
still holds open; the stacktrace test needs symlink privileges) and fail
identically on an unmodified checkout.

🤖 Generated with Claude Code

multiWriteSyncer.Write documents that it returns the smallest number of
bytes any of its syncers wrote, but the running minimum was reset by the
next syncer whenever it held zero:

	if nWritten == 0 && n != 0 {
		nWritten = n
	} else if n < nWritten {

A syncer that writes nothing -- the usual result of writing to a closed
or full file -- is therefore masked by any later syncer, and Write
reports a complete write.

Seed the minimum from the first syncer instead, so a zero is carried
through like any other value. An empty multiWriteSyncer still returns 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Sep 10, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.88%. Comparing base (bb1a55d) to head (1807536).
⚠️ Report is 2 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1590      +/-   ##
==========================================
+ Coverage   98.85%   98.88%   +0.03%     
==========================================
  Files          53       53              
  Lines        3047     3045       -2     
==========================================
- Hits         3012     3011       -1     
+ Misses         35       34       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants