@@ -2,14 +2,129 @@ package logging
22
33import (
44 "context"
5+ "fmt"
6+ "io"
7+ "os"
8+ "sync"
9+ "time"
510
611 "github.com/go-logr/zerologr"
712 "github.com/rs/zerolog"
813 logf "sigs.k8s.io/controller-runtime/pkg/log"
914)
1015
16+ // defaultCloseTimeout is the maximum time to wait for log flushing during shutdown.
17+ // If the underlying writer blocks longer than this, Close() will return with an error
18+ // rather than hanging indefinitely.
19+ const defaultCloseTimeout = 5 * time .Second
20+
1121var Logger zerolog.Logger
1222
23+ type closerHolder struct {
24+ mu sync.Mutex
25+ closer io.Closer // GUARDED_BY(mu)
26+ // closed is permanent once set to true. This prevents double-close bugs where
27+ // multiple shutdown paths might try to close the same closer. Once closed,
28+ // any new closer passed to Set() is immediately closed to prevent resource leaks.
29+ closed bool // GUARDED_BY(mu)
30+ }
31+
32+ func (ch * closerHolder ) Set (closer io.Closer ) {
33+ var toClose io.Closer
34+
35+ ch .mu .Lock ()
36+ if ch .closed {
37+ toClose = closer
38+ ch .mu .Unlock ()
39+ if toClose != nil {
40+ _ = toClose .Close ()
41+ }
42+ return
43+ }
44+
45+ // Don't close if the same closer is being set again
46+ if ch .closer != closer {
47+ toClose = ch .closer
48+ }
49+ ch .closer = closer
50+ ch .mu .Unlock ()
51+
52+ if toClose != nil {
53+ _ = toClose .Close ()
54+ }
55+ }
56+
57+ func (ch * closerHolder ) Close () error {
58+ return ch .CloseWithTimeout (defaultCloseTimeout )
59+ }
60+
61+ // CloseWithTimeout closes the held closer with a timeout to prevent shutdown hangs.
62+ // If the closer doesn't complete within the timeout, an error is returned but the
63+ // close operation may still complete in the background.
64+ //
65+ // NOTE: On timeout, the goroutine performing the close continues running until the
66+ // underlying Close() completes. This is intentional for shutdown scenarios where we
67+ // prefer to return promptly rather than block indefinitely. The goroutine will not
68+ // leak permanently—it will terminate when Close() eventually returns (or when the
69+ // process exits).
70+ func (ch * closerHolder ) CloseWithTimeout (timeout time.Duration ) error {
71+ var toClose io.Closer
72+
73+ ch .mu .Lock ()
74+ if ch .closed {
75+ ch .mu .Unlock ()
76+ return nil
77+ }
78+
79+ ch .closed = true
80+ toClose = ch .closer
81+ ch .closer = nil
82+ ch .mu .Unlock ()
83+
84+ if toClose == nil {
85+ return nil
86+ }
87+
88+ // For zero or negative timeout, close synchronously
89+ if timeout <= 0 {
90+ err := toClose .Close ()
91+ if err != nil {
92+ fmt .Fprintf (os .Stderr , "warning: failed to flush logs: %v\n " , err )
93+ }
94+ return err
95+ }
96+
97+ // Use a channel to wait for close with timeout
98+ done := make (chan error , 1 )
99+ go func () {
100+ done <- toClose .Close ()
101+ }()
102+
103+ select {
104+ case err := <- done :
105+ if err != nil {
106+ // Write directly to stderr since the logger may not be functional
107+ fmt .Fprintf (os .Stderr , "warning: failed to flush logs: %v\n " , err )
108+ }
109+ return err
110+ case <- time .After (timeout ):
111+ err := fmt .Errorf ("timeout after %v waiting for log flush" , timeout )
112+ fmt .Fprintf (os .Stderr , "warning: %v\n " , err )
113+ return err
114+ }
115+ }
116+
117+ // reset clears the closer state without closing the current closer.
118+ // This is only for test use to restore state between test cases.
119+ func (ch * closerHolder ) reset () {
120+ ch .mu .Lock ()
121+ ch .closer = nil
122+ ch .closed = false
123+ ch .mu .Unlock ()
124+ }
125+
126+ var globalCloser closerHolder
127+
13128func init () {
14129 SetGlobalLogger (zerolog .Nop ())
15130 logf .SetLogger (zerologr .New (& Logger ))
@@ -20,6 +135,34 @@ func SetGlobalLogger(logger zerolog.Logger) {
20135 zerolog .DefaultContextLogger = & Logger
21136}
22137
138+ func SetGlobalLoggerWithCloser (logger zerolog.Logger , closer io.Closer ) {
139+ // Store the closer BEFORE activating the logger to avoid a race window where
140+ // the logger is active but Close() wouldn't flush it (if called between the two operations).
141+ globalCloser .Set (closer )
142+ SetGlobalLogger (logger )
143+ }
144+
145+ // Close flushes and releases resources owned by the globally configured logger.
146+ // It is safe to call multiple times.
147+ func Close () error { return globalCloser .Close () }
148+
149+ // ResetCloserForTesting resets the global closer state.
150+ //
151+ // WARNING: This is for TEST USE ONLY. Production code must NEVER call this function.
152+ // Calling this in production will silently discard any pending log closer, potentially
153+ // losing buffered log messages.
154+ //
155+ // This function exists because Close() is designed to be idempotent and final (sets
156+ // closed=true permanently), but tests need to reset state between test cases to ensure
157+ // isolation. Without this, a test that calls Close() would cause subsequent tests'
158+ // SetGlobalLoggerWithCloser() calls to immediately close their new closers.
159+ //
160+ // Go's package visibility constraints prevent test files in other packages from
161+ // accessing the unexported globalCloser directly, necessitating this exported helper.
162+ func ResetCloserForTesting () {
163+ globalCloser .reset ()
164+ }
165+
23166func With () zerolog.Context { return Logger .With () }
24167
25168func Err (err error ) * zerolog.Event { return Logger .Err (err ) }
0 commit comments