Writing a CLI tool in Go
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.Commandsolution 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:
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.
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.
