Sometimes in our applications, we need to invoke external processes or commands (e.g CURL, Ping, SSH etc.) to perform some tasks. We can use os/exec Go package to invoke external processes. Most of the time we want to invoke these commands with timeouts.

In this blog post, I am going to talk about different ways in which we can invoke a command with a timeout.

Timeout with timer Link to heading

In this method, we use a timer for the timeout. We start a timer with a given timeout period and pass a function to kill the command if the timer expires. Then after, we invoke the command and check if the timer was expired or it completed within given timeout period.

//CommandTimeoutWithChannel command timeout using channel and goroutine
func CommandTimeoutWithTimer(command string, timeout time.Duration) (string, error) {
	cmd := exec.Command("/bin/bash", "-c", command)

	timer := time.AfterFunc(timeout, func() {
		cmd.Process.Kill()
	})

	out, err := cmd.CombinedOutput()

	isExpired := timer.Stop()
	if isExpired == false {
		fmt.Println("Command timed out")
		return "Command timed out", errors.New("Command Timeout")
	}

	if err != nil {
		log.Printf("Error : %s", err.Error())
		return string(out), err
	}

	return string(out), nil
}

Timeout with goroutine and channel Link to heading

In the second method, we will use a goroutine and a channel. We start the command with cmd.Start()and launch a goroutine which calls cmd.Wait() method and sends the output to a channel. In the main method, we have a select statement which has two cases one with timer for timeout and other to receive from the channel.

//CommandTimeoutWithChannel command timeout using channel and goroutine
func CommandTimeoutWithChannel(command string, timeout time.Duration) (string, error) {
	cmd := exec.Command("/bin/bash", "-c", command)
	var out bytes.Buffer
	cmd.Stdout = &out

	err := cmd.Start()
	if err != nil {
		log.Printf("Error : %s", err.Error())
		return err.Error(), err
	}

	done := make(chan error)

	go func() { done <- cmd.Wait() }()

	timer := time.After(timeout)

	select {
	case <-timer:
		cmd.Process.Kill()
		fmt.Println("Command timed out")
		return "Command timed out", errors.New("Command Timeout")
	case err := <-done:
		if err != nil {
			return err.Error(), err
		}
		return out.String(), nil
	}
}

Timeout with context Link to heading

The third method is to use background context with the timeout. Prior to Go 1.7 context package was not part of the standard library. In Go 1.7 it was included into the standard library. In this approach, we create a context with timeout and execute the command in that context, if it time taken by the external command exceeded the timeout it gives context.DeadlineExceeded error.

//CommandTimeoutWithContext command timeout with background context
func CommandTimeoutWithContext(command string, timeout time.Duration) (string, error) {
	ctx, cancel := context.WithTimeout(context.Background(), timeout)
	defer cancel()

	cmd := exec.CommandContext(ctx, "/bin/bash", "-c", command)
	out, err := cmd.CombinedOutput()

	if ctx.Err() == context.DeadlineExceeded {
		fmt.Println("Command timed out")
		return "Command timed out", errors.New("Command Timeout")
	}

	if err != nil {
		fmt.Println("Error : ", err.Error())
		return string(out), err
	}
	return string(out), nil
}

Here is the code with example to invoke the external command.

package main

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"log"
	"os/exec"
	"time"
)

func main() {
	fmt.Println("Hello World!!")
	command := "ping -c 2 -i 1 8.8.8.8"
	out, err := CommandTimeoutWithTimer(command, 2*time.Second)
	fmt.Println("Output: ", out)
	if err != nil {
		fmt.Println("Error:", err.Error())
	}
}

//CommandTimeoutWithChannel command timeout using channel and goroutine
func CommandTimeoutWithChannel(command string, timeout time.Duration) (string, error) {
	cmd := exec.Command("/bin/bash", "-c", command)
	var out bytes.Buffer
	cmd.Stdout = &out

	err := cmd.Start()
	if err != nil {
		log.Printf("Error : %s", err.Error())
		return err.Error(), err
	}

	done := make(chan error)

	go func() { done <- cmd.Wait() }()

	timer := time.After(timeout)

	select {
	case <-timer:
		cmd.Process.Kill()
		fmt.Println("Command timed out")
		return "Command timed out", errors.New("Command Timeout")
	case err := <-done:
		if err != nil {
			return err.Error(), err
		}
		return out.String(), nil
	}
}

//CommandTimeoutWithContext command timeout with background context
func CommandTimeoutWithContext(command string, timeout time.Duration) (string, error) {
	ctx, cancel := context.WithTimeout(context.Background(), timeout)
	defer cancel()

	cmd := exec.CommandContext(ctx, "/bin/bash", "-c", command)
	out, err := cmd.CombinedOutput()

	if ctx.Err() == context.DeadlineExceeded {
		fmt.Println("Command timed out")
		return "Command timed out", errors.New("Command Timeout")
	}

	if err != nil {
		fmt.Println("Error : ", err.Error())
		return string(out), err
	}
	return string(out), nil
}

//CommandTimeoutWithTimer command timeout with timer
func CommandTimeoutWithTimer(command string, timeout time.Duration) (string, error) {
	cmd := exec.Command("/bin/bash", "-c", command)

	timer := time.AfterFunc(timeout, func() {
		cmd.Process.Kill()
	})

	out, err := cmd.CombinedOutput()

	isExpired := timer.Stop()
	if isExpired == false {
		fmt.Println("Command timed out")
		return "Command timed out", errors.New("Command Timeout")
	}

	if err != nil {
		log.Printf("Error : %s", err.Error())
		return string(out), err
	}

	return string(out), nil
}

Thanks for reading. If found any error or want to suggest something, please leave a comment below.