Clean up after yourself
September 4, 2024
Just like in your real life, you gotta clean up after yourself in code too!
If we start a process, we should kill it when we're done. If we create a temporary file, we should delete it when we're done. Flip a switch on? Flip it back off.
(Prefer to watch instead of read? Check out the video version of this article on YouTube: Poppin Off: Clean Up After Yourself.)
The problem with not cleaning up
Let me give you an example, shown here in PHP but the point applies regardless of language. In this example we're going to use Ngrok to share our local app with the world. (That detail isn't important, but it is interesting!)
We have a main handle
function where we start a process, touch a file, write out some text, and then run an infinite
loop until we interrupt the command at which point it will exit.
public function handle(): void{ $this->startProcess(); $this->touchFile(); $this->line('Waiting...'); $this->line('(Press ctrl+c to stop.)'); while ($this->shouldRun) { echo "."; Sleep::for(1)->seconds(); } // @TODO Cleanup $this->line('Exiting');}
When we run the app in the terminal, we'll start a child process, touch a file, and then enter into an infinite loop.
When we hit ctrl+c
, the process stops and we exit.
This is what we'll see:
Starting ngrok process...Touching a file...Waiting...(Press ctrl+c to stop.)......^CStoppingExiting
But here's the catch: this command leaves a mess behind. The file you touched? It's still there. We don't delete it.
Here's the naive touchFile
implementation.
public function touchFile(){ $this->line('Touching a file...'); touch(base_path('sharing'));}
The process we started? It's probably terminated when the parent process ends, but there's no guarantee. Here's the
naive startProcess
implementation.
public function startProcess(){ $this->line('Starting ngrok...'); Process::start('ngrok');}
We need to clean up after ourselves to ensure everything is tidy when we're done.
So how do we do that? There are two approaches: returning a callback or registering an undo stack. Let's explore both.
Approach 1: returning a callback
The idea here is to return a callback from each method that undoes the work done by that method.
In the startProcess
function, we assign Process::start(‘ngrok')
to a variable.
public function startProcess(){ $this->line('Starting ngrok...'); $process = Process::start('ngrok'); }
In the "undo" function, we can use the $process
variable to see if the process is still running and send a SIGINT
to
interrupt it if so. Finally, we'll print out text that we're stopping Ngrok, as a little treat.
public function startProcess(){ $this->line('Starting ngrok...'); $process = Process::start('ngrok'); return function () use ($process) { if ($process->running()) { $process->signal(SIGINT); } $this->line('Stopping Ngrok'); };}
So now, in our main handle
function, our startProcess
function will return a callback that will stop the process if
it's running. We can save this callback to a variable and then call it at the end of the handle
function.
public function handle(): void{ $stopProcess = $this->startProcess(); $this->touchFile(); $this->line('Waiting...'); $this->line('(Press ctrl+c to stop.)'); while ($this->shouldRun) { echo "."; Sleep::for(1)->seconds(); } // Cleanup $stopProcess(); $this->line('Exiting'); }
So clean, right? We love it.
We'll update the touchFile
function similarly now to return a function that unlinks the file. If the file is gone, we
don't actually care, but we do want to make sure that it is in fact gone.
So we update the touchFile
function to add in the return function:
public function touchFile(){ $this->line('Touching a file...'); touch(base_path('sharing')); return function () { @unlink(base_path('sharing')); $this->line('Unlinking file.'); };}
Now back in our handle
function, we can assign the result of the touchFile
function to a variable called destroyFile
and then call it at the end of the handle
function.
public function handle(): void{ $stopProcess = $this->startProcess(); $destroyFile = $this->touchFile(); $this->line('Waiting...'); $this->line('(Press ctrl+c to stop.)'); while ($this->shouldRun) { echo "."; Sleep::for(1)->seconds(); } // Cleanup $stopProcess(); $destroyFile(); $this->line('Exiting'); }
Each function now returns its own undo function.
When we run the app again, we see the process start, the file is touched, the infinite loop runs until we hit ctrl+c
to interrupt it and we see ngrok stops, the file is unlinked, and we exit.
Approach 2: register an undo stack
The second method involves using a callback stack. This approach is particularly useful if your process could have multiple cleanup tasks.
To start, we'll add a protected array $cleanupCallbacks
to our class:
class Run extends Command implements SignalableCommandInterface{ proctected $signature = 'app:run'; protected bool $shouldRun = true; protected array $cleanupCallbacks = [];
This array will hold each of our cleanup callbacks.
Now in our startProcess
function, instead of returning a function, we can push the previous returned function into our
stack:
public function startProcess(){ $this->line('Starting ngrok...'); $process = Process::start('ngrok'); return function () use ($process) { $this->cleanupCallbacks[] = function () use ($process) { if ($process->running()) { $process->signal(SIGINT); } $this->line('Stopping Ngrok'); };}
Similarly with touchFile
, we change the return function to push the function into the callback stack:
public function touchFile(){ $this->line('Touching a file...'); touch(base_path('sharing')); return function () { $this->cleanupCallbacks[] = function () { @unlink(base_path('sharing')); $this->line('Unlinking file.'); };}
Then we just need to write a clean-up function. For the cleanup
function, we're going to iterate through each callback
function in the stack:
public function cleanup(){ foreach ($this->cleanupCallbacks as $cb) { $cb(); }}
When it's time to clean up, simply call our new cleanup
method and we should get the exact same output as Approach 1.
public function handle(): void{ $this->startProcess(); $this->touchFile(); $this->line('Waiting...'); $this->line('(Press ctrl+c to stop.)'); while ($this->shouldRun) { echo "."; Sleep::for(1)->seconds(); } // Cleanup $this->cleanup(); $this->line('Exiting'); }
This approach keeps your code neat and organized. The actions and their corresponding reversals stay close together, ensuring that nothing is left behind.
Wrapping up
Both methods, returning a callback and using a callback stack, are effective ways to clean up after yourself in code. Regardless of which implementation you choose, it keeps the action and the reversal of the action very close together. So a single method is responsible for declaring both how to instantiate and how to tear down. Even if the tear down comes in a different place and much, much later, it keeps the logic all together.
The choice between them depends on your specific needs and taste! Either way, you'll be sure that no processes are left running and no files are left lingering.
It's important to take pride in writing code that's not just functional but also clean and considerate of future maintainers, because honestly, you're probably the future maintainer.
YouTube video
Check out the video version of this article on YouTube!