Post Image

Writing a CLI tool in Go

Golang CLI Testing

Do I need a CLI to kill a process

If you are like me, a developer who every so often runs into an error that says port already in use, this post is for you.

I found myself repeatedly googling for that specific Stackoverflow post with that nifty oneliner for killing a process on a specific port. Then copy it, paste it, and go on with my life.

However recently I started programming on a Windows machine and suddenly the nifty oneliner did not work anymore. So, once again, I found myself googling for the solution, but now for Windows.

And as any programmer knows: when you have to do something repeatedly, you automate it.

That’s when I decided to build a cross-platform CLI to solve this exact problem: Kill any Process On any Port

The Stackoverflow post in question

Why Go?

First, a disclaimer: I am new to Go. But with its growing popularity, I was eager to try it out. For building a CLI, Go seemed like the right choice for a view reasons:

  • Cross-Platform: A single codebase runs on macOS, Windows, and Linux.
  • Built-in flag handling: Go’s flag package makes command-line parsing straightforward.
  • Static binaries: No dependencies, making distribution simple.

If you’re curious about Go’s full list of supported OS and architectures, check out the Optional environment variables.

Building Kpop CLI

For a simple command line tool like kpop, I didn’t need a full CLI framework. I tried doing everything from scratch.

Find process using port

There is no built-in feature in Go to find which process is blocking which port. So making this myself seemed like the best way.

While focussing on Unix platforms and Windows platforms (not only because I am using those, but also the rest of the world), I decided to use the exec.Command to run the lsof command on Unix and netstat on Windows. Followed by some parsing of the output to get the PID of the process.

Below you’ll find the code snippet for finding the process using a port. The function FindProcessForPort is part of the RealCommandExecutor struct, which implements the CommandExecutor interface. This strategy pattern allows for easy testing and mocking of the command execution. At runtime the right implementation is injected based on the use case (real vs. test).

func (r *RealCommandExecutor) FindProcessForPort(port string) ([]byte, error, *utils.ProcessOutputFormat) {
    switch runtime.GOOS {
    case "darwin", "linux":
        output, err := exec.Command("lsof", "-t", "-i:"+port).Output()
        format := utils.FormatLsof
        return output, err, &format
    case "windows":
        output, err := exec.Command("netstat", "-ano", "|", "findstr", ":"+port).Output()
        format := utils.FormatNetstat
        return output, err, &format
    default:
        panic(fmt.Sprintf("Unsupported OS: %s", runtime.GOOS))
    }
}

Codeblock 1: Command executor for finding process on port.

And here you will find the mock implementation of the CommandExecutor interface. The MockCommandExecutor struct is used in the tests to mock the output of the FindProcessForPort function. You can provide the wanted output, error and format, which are then returned by the mock implementation.

type MockCommandExecutor struct {
    Output []byte
    Err    error
    Format *utils.ProcessOutputFormat
}

func (m *MockCommandExecutor) FindProcessForPort(port string) ([]byte, error, *utils.ProcessOutputFormat) {
    if m.Err != nil {
        return nil, m.Err, nil
    }
    return m.Output, nil, m.Format
}

Codeblock 2: Mocked executor for finding process on port.

Built-in go function

Go has its own built-in function to find a process by its PID (not port). Which returns a process struct that can among other things be used to kill the process. However, the reliability of this function is questionable. As you can see in the documentation below, the function always returns a process, even if the process does not exist.

After some testing on different platforms, I decided to stay with the exec.Command solution for all process related code.

// FindProcess looks for a running process by its pid.
//
// The [Process] it returns can be used to obtain information
// about the underlying operating system process.
//
// On Unix systems, FindProcess always succeeds and returns a Process
// for the given pid, regardless of whether the process exists. To test whether
// the process actually exists, see whether p.Signal(syscall.Signal(0)) reports
// an error.
func FindProcess(pid int) (*Process, error) {
  return findProcess(pid)
} 

Codeblock 3: Built-in FindProcess function in os package.

Kill process

For killing a process on Unix and Windows, I used kill and taskkill respectively with the exec.Command function. In the same manner it implements a mock executor and a real executor.

For all the code see my repository on GitHub: DDaaaaann/kpop-cli - GitHub

Testing

Although its simplicity, I wanted to make sure the code was well tested. Expanding the code with unit tests, integration tests and end-to-end tests.

Unit tests

From Codeblock 2 you could have seen that a mock implementation is used and that gets injected into the process package.

func TestFindProcess_Succes(t *testing.T) {
	mockExecutor := executor.MockCmdExecutor([]byte("9999"), nil, utils.FormatLsof)

	pid, err := process.FindProcessUsingPort("8080", mockExecutor)
	assert.NoError(t, err)
	assert.Equal(t, pid, 9999)
}

Codeblock 4: Unit test for finding process on port with the MockCmdExecutor.

The mockExecutor gets initialized with the wanted output, error, and format, and injected into the FindProcessUsingPort function. This way the test can be run without actually executing the lsof or netstat command.

In this case we expect no error and the PID of the process using port 8080 to be 9999.

Integration tests

The integration tests I wrote are testing the possible flow of the CLI from start to finish. Meaning that I use the MockExecutor to mock the os commands, I give a port as input and expect a certain output.

Using TableDrivenTests you can easily test multiple cases with the same test function.

package internal

func TestKpopCLI(t *testing.T) {
	var stdin, stdout bytes.Buffer
	var tests = []struct {
		name string
		port string
		pid  int
		err  error
		kill bool
		want string
	}{
		{
			name: "Successfully kill process on port",
			port: "8080",
			pid:  9999,
			kill: true,
			want: `Kill process on port 8080 (PID 9999)? (y/n)
                Killed process 9999 on port 8080.`,
		},
		{
			name: "Cancel kill process on port",
			port: "123",
			pid:  98765,
			kill: false,
			want: `Kill process on port 123 (PID 98765)? (y/n)
                Cancelled.`,
		}
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockExecutor := executor.MockCmdExecutor([]byte(strconv.Itoa(tt.pid)), tt.err, utils.FormatLsof)

			if tt.kill {
				confirm(&stdin)
			} else {
				decline(&stdin)
			}

			KPOP(tt.port, false, false, &stdin, &stdout, mockExecutor)
			assert.Contains(t, stdout.String(), tt.want)
		})
	}
}

End-to-end tests

For the end-to-end tests, I created a test script that starts a server on a specific port and then tries to kill it. But since the cli is build for multiple platforms, I wanted to test it on all platforms. The solution was to use GitHub Actions to run the tests on all platforms.

GitHub Actions

Using github actions, I created a workflow that runs a github action runner for ubuntu, windows, and macos. The build step builds the binaries, which on their turn are executed in the integration test step. The e2e-test is run using a custom build flag -tags=e2e.

jobs:
  integration-tests:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
    runs-on: ${{ matrix.os }}

    steps:
      ...
      - name: Build executable
        run: make build

      - name: Run E2E tests
        run: go test -v -tags=e2e ./internal

The e2e test

The actual test is written as a go test file in the internal package. But using the -tags=e2e flag, the test is only run when the flag is set. It starts a python server on a free port and uses the kpop binary of the specific OS to kill the process. Eventually it checks if the process is actually killed.

The whole tes script can be found here: e2e_test.go

When all tests pass, the GitHub action is marked as successful.

Integration test per platform Image 1: Succesful integration tests per platform running in GitHub actions.

Future improvements

Since kpop is a small cli tool, there are not many features to add. However, making it available via a package manager like Homebrew or Scoop would be a nice addition. I could also think of adding a feature to kill multiple processes at once, just to improve my skills.

© 2024 DDaaaaann.

All rights reserved.