Please purchase the course to watch this video.

Full Course
The creation of the pguard
application mimics the functionality of the Unix timeout
command, allowing users to set time limits on executing commands and manage process termination effectively. This lesson illustrates how pguard
can constrain resource usage and prevent indefinite process hanging, particularly useful in resource-limited environments or when handling untrusted inputs. It incorporates advanced features such as graceful shutdown capabilities, enabling processes to terminate smoothly upon receiving interrupt signals while respecting specified timeout durations. This approach not only enhances control over command execution but also reflects practices common in DevOps and systems-level programming, laying the groundwork for more sophisticated command-line tools and applications.
No links available for this lesson.
So far we've managed to take a look at how we can perform execution of other commands and processes, as well as how we can manage cancellation when it comes to both using signals and context.Context
. In this lesson we're going to be applying everything we've learned through this module in building a brand new application called pguard
.
The inspiration for this application is based on the timeout
command found in Unix systems. The timeout
command is a command that allows you to timeout applications after a given amount of time. For example here I'm specifying timeout 5
, which means any command I pass into this command I want to be timed out after 5 seconds.
For example if we go ahead and call the sleep
command for 10 seconds with the timeout
command of 5 seconds you'll see even though the command sleep
will sleep for 10 seconds it actually shuts down after 5. As you can see the timeout
command will kill the subprocess after the specified amount of time has passed. This makes it very useful to constrain the amount of time a process has to execute and complete, preventing it from hanging indefinitely or consuming more resources over a long period of time. This can be rather useful on more resource constrained systems, or just when processing data from an untrusted external source. Therefore we're going to go ahead and implement a very similar feature when it comes to our own code.
In any case let's go about setting up our application. First creating the pguard
directory as follows and then cd
-ing in. Then we can go ahead and use the go mod init
command passing in the name of the application we want to create:
mkdir pguard
cd pguard
go mod init github.com/dreamsOfCode-io/pguard
Then with the go mod init
command ran and our project initialized let's go ahead and create a main.go
function and go ahead and define a package called main
as well as a main function:
package main
func main() {
}
With that we now have our basic application stub up and running.
The next thing to do is to go ahead and pull out our arguments. To do so if we quickly look at the design of our app, we basically want to call pguard
with a timeout variable and then the actual command that we want to run followed by all of the arguments. Therefore in order to capture these variables we can go ahead and define the timeout as being os.Args[1]
which is going to be the first argument (index 0 is the program name, index 1 is the first argument). Then the command will be at index 2 and the arguments will be at index 3 and onwards. So we can go ahead and capture these as well:
package main
import (
"fmt"
"os"
)
func main() {
timeout := os.Args[1]
cmd := os.Args[2]
args := os.Args[3:]
fmt.Println("timeout:", timeout)
fmt.Println("cmd:", cmd)
fmt.Println("args:", args)
}
Let's just go ahead and print these out to make sure we're actually on the right step. Now if I go ahead and run this code and let's go ahead and pass in say 5
, then sleep 10
we'll see that the timeout is five, the command is sleep and the arguments are 10. So far so good.
Next with both our command and arguments defined the next thing we can do is go about setting up our exec command to actually execute them. To do so let's go ahead and import the os/exec
package and create a new command using the Command
function. We could and should use the CommandContext
function but we'll take a look at that shortly:
package main
import (
"fmt"
"os"
"os/exec"
)
func main() {
// 1. Capture arguments
timeout := os.Args[1]
cmdName := os.Args[2]
args := os.Args[3:]
// 2. Create command
cmd := exec.Command(cmdName, args...)
// 3. Run command
_ = cmd.Run()
}
One thing I've made a mistake on is the args themselves actually need to be a variadic parameter or a slice of parameter. So rather than selecting the third value we actually want to go ahead and select the third onwards which we can do as shown above. Then we can go ahead and use the three dots or the ellipsis to expand the arguments slice into our exec command's variadic parameters.
Okay now that we've captured our command in a variable called cmd
let's go ahead and just run it as follows and for the meantime to squash the timeout error we can just go ahead and assign it to the blank identifier. Okay with that if we go ahead and run this code passing in 5 sleep 7
we should see that our application will just sleep for seven seconds which it does.
Next up we then need to go about adding in the timeout functionality. Currently because we're pulling the timeout variable from the os.Args
then it currently has a string type. Instead however we're going to need to turn it into a time.Duration
and this is our chance to have a little bit more functionality when it comes to our own pguard
application instead of using the standard timeout.
In order to turn our timeout from a string to a duration we can use the ParseDuration()
method of the time
package which accepts a string and will return a time.Duration
or an error in the event that the string can't be parsed:
package main
import (
"log"
"os"
"os/exec"
"time"
)
func main() {
// Don't print date/time in logs
log.SetFlags(0)
// 1. Capture arguments
timeoutStr := os.Args[1]
cmdName := os.Args[2]
args := os.Args[3:]
// 2. Parse duration
duration, err := time.ParseDuration(timeoutStr)
if err != nil {
log.Fatal("failed to parse duration:", err)
}
// 3. Create command
cmd := exec.Command(cmdName, args...)
// 4. Run command
_ = cmd.Run()
}
Okay with our duration defined we can now go about actually using it. To do so we have a couple of different approaches whether it's using a channel or a context.Context
. In this case I think the context.Context
is going to be more preferable. Therefore let's go ahead and define one using the context.WithTimeout()
function, passing in our parent context which is going to be the background context as we don't have any parent, as well as the duration which is the time duration that we parsed from the timeout passed in as an argument:
package main
import (
"context"
"log"
"os"
"os/exec"
"time"
)
func main() {
// Don't print date/time in logs
log.SetFlags(0)
// 1. Capture arguments
timeoutStr := os.Args[1]
cmdName := os.Args[2]
args := os.Args[3:]
// 2. Parse duration
duration, err := time.ParseDuration(timeoutStr)
if err != nil {
log.Fatal("failed to parse duration:", err)
}
// 3. Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), duration)
defer cancel()
// 4. Create command with context
cmd := exec.CommandContext(ctx, cmdName, args...)
// 5. Run command
_ = cmd.Run()
}
Then let's go ahead and defer a call to cancel for the meantime just to make sure that we are cancelling at the end of our function and we can go ahead and replace the call to exec.Command
with a call to exec.CommandContext
passing in the context as the first argument.
Now if we go ahead and run this code passing in say 5s
as the argument and we'll sleep
for 10
we should see the application exit after only 5 seconds which it does. So far so good. Our pguard
or timeout function is starting to look very similar to the original behavior.
However if I go ahead and run the timeout function and let's say we pass in the five seconds but also pass in echo hello world
you'll see that hello world is printed to the console. Therefore we need to go ahead and make sure that we're also printing this out to standard output and standard error. The simplest way to achieve this is to go ahead and just pass this into the actual command as follows:
package main
import (
"context"
"log"
"os"
"os/exec"
"time"
)
func main() {
// Don't print date/time in logs
log.SetFlags(0)
// 1. Capture arguments
timeoutStr := os.Args[1]
cmdName := os.Args[2]
args := os.Args[3:]
// 2. Parse duration
duration, err := time.ParseDuration(timeoutStr)
if err != nil {
log.Fatal("failed to parse duration:", err)
}
// 3. Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), duration)
defer cancel()
// 4. Create command with context
cmd := exec.CommandContext(ctx, cmdName, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
// 5. Run command
_ = cmd.Run()
}
Now if we go ahead and call our command go run main.go 5s echo hello world
we should see this printed to the console as well which it is. But what about standard in? Well if we take a look at the timeout
commands and we'll again do five seconds and this might be a bit difficult let's do say wc
command and you can see it does accept standard input and if I manage to close it in time it will actually print out the values. However if I don't print out in time hello one two three four five
you'll see it gets cancelled just beforehand.
Okay so let's go ahead and achieve the same thing when it comes to our code. As shown above, we can just go ahead and pass in the standard input stream using cmd.Stdin = os.Stdin
. If we go ahead and run our code again using something like go run main.go 10s wc
, we can go ahead and type in hello world
and if I close it down (well the five seconds has passed, so let's change this to 10
). Okay so that definitely works, this time let's go ahead and run this with the 10 seconds
again just to make sure that this works and we'll pass it into wc
. Then we can type hello world
and we'll do a Ctrl+D
and as you can see it works, showing us 6 characters. So far so good - our application is starting to run very similar to the timeout
command.
So what next well let's see what happens if we run the timeout
command and we pass in a SIGINT
. Let's say we go ahead and do 10 seconds
and we'll sleep
for 30
and if we press Ctrl+C
you'll see it instantly shuts down. Currently our command also does that as well if I go ahead and build this just so that we don't get the output and we use pguard
and we'll do 10s sleep 30
. If I go ahead and press Ctrl+C
you'll see it also shuts down so we don't need to add a signal handler there. However, there may be some situations where we want to do some graceful termination rather than shutting down straight away; this is a feature we could actually add to the pguard
function in order to separate it from the timeout
command we've been using.
As a reference to show what I mean, here would be a really simple example of how this looks like: we call our pguard
application as follows, but rather than using say timeout
which is what we would specify our current flag to be where it will timeout an application after a period of time, instead we'll define a flag called graceful
which will let the application run as long as it needs to. But instead of defining the amount of time that an application has to run, we'll define the amount of time an application has after the Ctrl+C
has been pressed in order for it to shut down.
In order to do so we're going to need to change our application a little bit further so we can specify either both timeout and graceful shutdown. Let's add support for command line flags:
package main
import (
"context"
"flag"
"log"
"os"
"os/exec"
"time"
)
func main() {
// Don't print date/time in logs
log.SetFlags(0)
// Define flags
var timeout time.Duration
flag.DurationVar(&timeout, "timeout", -1, "Used to specify a timeout on the command")
var graceful time.Duration
flag.DurationVar(&graceful, "graceful", -1, "Used to specify a graceful shutdown time")
// Parse flags
flag.Parse()
// Get command and args
cmdName := flag.Arg(0)
args := flag.Args()[1:]
// Create the appropriate context based on timeout
ctx, cancel := createContext(context.Background(), timeout)
defer cancel()
// Create command with context
cmd := exec.CommandContext(ctx, cmdName, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
// Run command
_ = cmd.Run()
}
// createContext creates an appropriate context based on the timeout
func createContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout >= 0 {
return context.WithTimeout(parent, timeout)
} else {
return context.WithCancel(parent)
}
}
Okay we can pass this in as a reference or as a pointer and we need to set the name which is going to be timeout
and we also need to set the default value. In this case I'm going to set it to be -1
(zero could also work as well although -1
to me just feels a little bit easier to deal with). If this is -1
then we know we haven't set this flag.
With that our code should now work as expected. Let's go ahead and quickly test it using the go run main.go -timeout 5s sleep 10
command and our code should time out after 5 seconds, which it does. So far so good.
However, our code currently doesn't work if we don't pass a timeout argument because by default we're setting it to be -1
. So as soon as the context is created it's been expired. Therefore in order to solve this we're going to need to extract our actual context creation.
To create the context properly, we need to extract this into a helper function as shown above. This function creates an appropriate context based on whether we've specified a timeout.
Now let's implement the graceful shutdown feature. For this we will need to intercept the SIGINT
signal. While we could use signal.NotifyContext
, I think using signal.Notify
with a channel would be better since we don't want to cancel the timeout immediately when we see a signal - instead we want to send a signal to the process and give it some time to shut down before we force a kill:
package main
import (
"context"
"flag"
"log"
"os"
"os/exec"
"os/signal"
"time"
)
func main() {
// Don't print date/time in logs
log.SetFlags(0)
// Define flags
var timeout time.Duration
flag.DurationVar(&timeout, "timeout", -1, "Used to specify a timeout on the command")
var graceful time.Duration
flag.DurationVar(&graceful, "graceful", -1, "Used to specify a graceful shutdown time")
// Parse flags
flag.Parse()
// Get command and args
cmdName := flag.Arg(0)
args := flag.Args()[1:]
// Set up signal handling
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)
// Create the appropriate context based on timeout
ctx, cancel := createContext(context.Background(), timeout)
defer cancel()
// Create command with context
cmd := exec.CommandContext(ctx, cmdName, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
// Start the command instead of running it
err := cmd.Start()
if err != nil {
log.Fatal("failed to start command:", err)
}
// Create done channel for monitoring command completion
doneCh := make(chan error, 1)
// Wait for command in a goroutine
go func() {
doneCh <- cmd.Wait()
close(doneCh)
}()
// Wait for either command completion or signal
select {
case err := <-doneCh:
if err != nil {
os.Exit(1)
}
return
case <-ch:
// Signal received, continue
}
// Handle graceful shutdown
if graceful > 0 {
// Send SIGINT to the process
cmd.Process.Signal(os.Interrupt)
// Wait for either completion or timeout
select {
case <-doneCh:
return
case <-time.After(graceful):
// Exceeded graceful timeout, force kill
cmd.Process.Kill()
}
} else {
// No graceful timeout, kill immediately
cmd.Process.Kill()
}
}
// createContext creates an appropriate context based on the timeout
func createContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout >= 0 {
return context.WithTimeout(parent, timeout)
} else {
return context.WithCancel(parent)
}
}
Now instead of just running the command, we start it asynchronously with cmd.Start()
. Then we create a done channel to monitor when the command completes, and we launch a goroutine that will wait for the command to complete and send the result to the done channel.
In our main routine, we use a select statement to wait for either the command to complete or a signal to be received. If the command completes, we exit. If a signal is received, we proceed to handle the graceful shutdown.
For graceful shutdown, we send a SIGINT to the process and then wait for either the command to complete or our graceful timeout to expire. If the timeout expires, we forcefully kill the process.
Now we can test our application:
go build
./pguard -graceful 5s sleep 30
Then press Ctrl+C
and observe that the program will wait for 5 seconds before terminating.
To verify this works with a process that doesn't respect SIGINT, we can use a special test program called "stubborn" that ignores SIGINT signals:
// stubborn.go
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("Stubborn program started")
fmt.Println("This program ignores SIGINT and will run for 1 minute")
time.Sleep(time.Minute)
fmt.Println("Stubborn program completed")
}
You can install this program using:
go install github.com/dreamsOfCode-io/stubborn@latest
Now if we run stubborn
, it won't respond to Ctrl+C
and will lock your terminal for a full minute. But if we run it with our pguard
command:
./pguard -graceful 5s stubborn
When we press Ctrl+C
, it will attempt to gracefully shut down for 5 seconds, and then forcefully terminate the process.
With that we've managed to create a brand new application called pguard
which has a couple of features associated with it:
- The ability to timeout an application after a specified duration (like the Unix
timeout
command) - The ability to perform graceful shutdown by giving a process time to respond to SIGINT before forcefully killing it
This is actually a very useful tool to have in certain situations especially when it comes to DevOps and infrastructure where you want to be able to constrain applications from being shut down so that they have time to do so gracefully, but at the same time you do want to kill them after a period of time. This is where we're starting to get into some more systems level interfacing or interaction when it comes to working with Go and is what tools such as Kubernetes or Docker do under the hood when it comes to working with containers and other processes.
In any case that wraps up the end of this module where we've taken a look at how to do many different concepts such as executing commands, performing cancellation and listening to signals through the use of channels. In the next module we're going to be taking a deeper look at the file system and moving on to networking before we move on to module number eight which is where we're going to start creating more powerful command line applications like the one we built in this lesson. In any case now's a good time to reflect on the pguard
application and add in some other features that you may feel will be useful for your own situations or just to tinker around with and play a little more. Once you're ready to move on, I'll see you in the next lesson.