A Randomly-Timed Memory Leak
Table of Contents
Background #
This problem was found in my command-line interface (CLI) screensaver inhibitor project, named cocainate
and written in Go. The screensaver inhibitor can wait for either a termination signal, or for an optionally-provided duration. The screensaver inhibitor’s session is tracked by a data structure with the specified duration, and a termination signals channel, linked to the process’ signal buffer.
s := Session{
Duration: duration,
Signals: make(chan os.Signal, 1),
}
signal.Notify(s.Signals, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT)
The part of the code shown below is how I used to implement the CLI functionality that waits for either:
- a timer (with user-specified duration) to end. This is triggered by a
time.Time
object sent to the channel returned by thetime.After
function, which occurs after the duration specified in the function’s input, - or for the user to manually stop the screensaver inhibitor. This is triggered by a channel that listens for terminations signals sent to the programs by either the operating system, or the user via the command-line shell.
select {
case <-time.After(s.Duration):
case <-s.Signals:
}
Potential Channel Leaks #
The issue starts when the user terminates the screen inhibitor session before the timer (with the duration specified in the CLI’s arguments) ends. According to the documentation of time.After
1:
After
waits for the duration to elapse and then sends the current time on the returned channel. It is equivalent toNewTimer(d).C
. The underlyingTimer
is not recovered by the garbage collector until the timer fires. If efficiency is a concern, useNewTimer
instead and callTimer.Stop
if the timer is no longer needed.
%%{init: {"sequence": {"mirrorActors": false}}}%%
sequenceDiagram
participant CLI
actor User
participant Timer
par CLI to User
loop
CLI->>User: check for termination signal
activate User
end
User->>CLI: terminate session
deactivate User
Note over User,Timer: Timer's channel still exists
and CLI to Timer
loop
CLI->>Timer: check for duration end signal
end
end
Therefore, in this case, if the user sends a termination signal, the timer’s channel continues to exists in the heap until its duration is over, because it’s not stopped manually. Over the course of time between the receiving of the termination signal to the end of the timer’s duration, the timer’s channel is still accessible despite no longer being read by the program, which constitutes a memory leak2, which may lead to performance and reliability issues. I should also note that this is an issue only when cocainate
’s code is imported as a Go module3 to other codebases that don’t immediately terminate the entire process upon receiving a termination signal.
How I Fixed It #
In this commit (shown below), I use a a complete time.Timer
object rather than just its channel (as advised by Go’s standard library documentation), which allows me to close its channel after a termination signal is received. Since now the timer’s channel is closed when it’s no longer necessary, it allows Go’s garbage collector to clean it sooner, thus preventing the memory leak and the problems it can cause down the line.
timer := time.NewTimer(s.Duration)
select {
case <-timer.C:
case <-s.Signals:
timer.Stop()
}
time.After
’s Function Documentation (https://pkg.go.dev/time#After) ↩︎Memory Leak Wikipedia article (https://en.wikipedia.org/wiki/Memory_leak) ↩︎
Bui-Palsulich, T., & Compton, E. (2019, March 19). Using Go Modules. The Go Programming Language Blog. https://go.dev/blog/using-go-modules ↩︎