The screen glowed with the familiar frustration of another interrupted workflow. Rasmus Krebs stared at his terminal, fingers frozen over the keyboard as he watched his Rails server fail to start—again.
“Port 3000 is already in use,” his colleague Sarah called out from across the shared workspace, not even looking up from her own screen. “Third time today, right?”
Rasmus sighed and typed the incantation he’d memorized by now:
lsof -iTCP -sTCP:LISTEN | grep 3000. The output scrolled
past—a cryptic mixture of process IDs, user names, and network addresses
that told him everything and nothing at once.
ruby 47832 rasmus 12u IPv4 0x8a1b2c3d 0t0 TCP *:3000 (LISTEN)“Process 47832,” he muttered, then had to run another command to
figure out what it actually was. A zombie Rails process from yesterday’s
debugging session. He killed it with kill -9 47832 and
started his server again.
“You know,” Sarah said, spinning her chair around to face him, “you do that dance about five times a day. Isn’t there a better way?”
Rasmus paused, his fingers still resting on the keyboard. She was right. How much time did he waste every day playing port detective? And it wasn’t just him—he watched every developer on the team go through the same ritual. Check what’s using the port. Figure out if it’s a Docker container or an orphaned dev server. Kill it or find its logs. Start over.
“There has to be,” he said, but as he spoke the words, a different thought formed in his mind. What if there isn’t? What if I built one?
That evening, Rasmus sat in his apartment with a cup of coffee
growing cold beside his laptop. The problem crystallized in his mind
like a photograph developing. Developers didn’t just need to see
ports—they needed to understand them. Was that process a critical
service or yesterday’s forgotten test server? If it’s a Docker
container, which one? Can I see its logs without hunting through
docker ps output?
“It’s not just about listing ports,” he said aloud to his empty kitchen. “It’s about making sense of them.”
He opened his editor and started with the foundation—a CLI tool in Go
that would do what lsof did, but better. The port scanner
would be the heart of it:
// ScanPorts discovers all listening ports on the local machine
func ScanPorts(ctx context.Context) ([]Port, error) {
var cmd *exec.Cmd
if runtime.GOOS == "darwin" {
cmd = exec.CommandContext(ctx, "lsof", "-iTCP", "-sTCP:LISTEN", "-P", "-n")
} else {
cmd = exec.CommandContext(ctx, "ss", "-tuln")
}
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to scan ports: %w", err)
}
return parsePorts(output), nil
}His roommate Jake wandered into the kitchen for a late-night snack and peered over his shoulder.
“Still working? What are you building?”
“A tool that shows me what’s running on each port,” Rasmus replied, not looking up from the screen.
“Why not just use lsof?”
Rasmus turned to face him. “Because lsof tells me
process 47832 is using port 3000. But what I really want to know is: is
that my Rails app, a Docker container, or some random service I forgot
about? And if it’s Docker, which container? And can I quickly see its
logs or kill it without hunting through a dozen commands?”
Jake nodded slowly. “Ah. So you’re not just listing ports, you’re making them… understandable.”
“Exactly.”
The next morning, Rasmus tackled the Docker integration. This was where the tool would shine—taking those mysterious port numbers and connecting them to actual containers with names, images, and context.
// EnrichWithDocker adds Docker container information to ports
func EnrichWithDocker(ports []Port) []Port {
cmd := exec.Command("docker", "ps", "--format",
"table {{.ID}}\t{{.Image}}\t{{.Names}}\t{{.Ports}}")
output, err := cmd.Output()
if err != nil {
return ports // Docker not available or no containers
}
containers := parseDockerOutput(string(output))
for i, port := range ports {
if container := findContainerByPort(containers, port.Port); container != nil {
ports[i].DockerContainer = container
}
}
return ports
}“This is where it gets interesting,” he explained to Sarah the next day, showing her the code. “Instead of you having to mentally map port 3000 to some container, the tool does it automatically.”
“What about processes that aren’t Docker containers?” she asked.
“That’s the process enrichment layer.” He switched to another file. “It figures out what the process actually is, filters out desktop apps nobody cares about, and gives you the command line so you can see exactly what’s running.”
But as he built the display layer, Rasmus hit his first real dilemma. How should the output look? A simple table would work, but developers lived in terminals filled with colors and formatting. He wanted something that felt native to that environment.
“I could use a library,” he told Jake over dinner, “but they never handle colors quite right. And I need precise control over the table formatting.”
“So build your own?”
“That’s more code to maintain…”
Jake shrugged. “But you’ll get exactly what you want.”
Rasmus made the call. Custom color handling, custom table rendering, complete control over the experience:
// StripANSI removes ANSI escape codes for width calculation
func StripANSI(s string) string {
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
return re.ReplaceAllString(s, "")
}
// CalculateColumnWidths determines proper spacing for aligned output
func CalculateColumnWidths(rows [][]string) []int {
widths := make([]int, len(rows[0]))
for _, row := range rows {
for i, cell := range row {
if width := len(StripANSI(cell)); width > widths[i] {
widths[i] = width
}
}
}
return widths
}“You’re stripping ANSI codes just to measure text width?” Sarah asked, looking at the code over his shoulder.
“Have to,” Rasmus replied. “A red ‘RUNNING’ is visually 7 characters but actually 17 characters with the color codes. If I don’t account for that, the columns don’t line up.”
“That seems like a lot of work for pretty output.”
Rasmus paused, considering. “Maybe. But when you use this tool dozens of times a day, the details matter. Clean output, proper alignment, colors that actually mean something—it all adds up to a better experience.”
Three weeks later, Rasmus typed sonar list in his
terminal and watched as a perfectly formatted table appeared, showing
every listening port with Docker containers, process details, and
management options. No more lsof incantations. No more
hunting through docker ps output. Just clear, actionable
information.
“Show me,” Sarah said, rolling her chair over.
The output was beautiful in its simplicity:
PORT PROCESS STATUS DOCKER CONTAINER PROJECT
3000 rails server Running - -
5432 postgres Running sonar_postgres_1 sonar
8080 nginx Running web_frontend_1 myapp
9229 node --inspect-brk Running - -
“And if I want to kill port 3000?” she asked.
“Just sonar kill 3000.”
“Logs from the postgres container?”
“sonar info 5432 gives you everything—logs, shell
access, container details.”
Sarah leaned back in her chair, a slow smile spreading across her face. “This is going to change how I work.”
Rasmus saved his changes and prepared to commit. Twenty-one files, 1,534 lines of code, and a solution to a problem that had annoyed him for years. The commit message was simple: “Sonarv1.”
As he typed git push, Rasmus realized something had
shifted in how he thought about developer tools. It wasn’t enough to
just solve the technical problem—you had to solve the human problem too.
The tedious workflows, the mental context switching, the tiny
frustrations that accumulated into major productivity drains.
His phone buzzed with a message from Jake: “Used your port tool today. Saved me 10 minutes finding that stuck Docker container. When are you open-sourcing it?”
Rasmus smiled. The tool was already changing how people worked, one port scan at a time. He’d turned a daily frustration into a moment of clarity, a chaotic system diagnostic into an elegant interface.
The port detective work was over. Now developers could simply ask their computer: “What’s running, and what should I do about it?”
And their computer would finally have a good answer.