A ports view built on lsof: listeners, working directories and a two press Stop

How a Mac app turns three system commands into a list of which project owns which port, and why Stop asks twice

By Arin Prajapati 11 min read
White headline on black reading A ports view built on lsof, with the kicker macOS and Swift and the NotchStar logo

TL;DR

  • A list of “which project is listening on which port” needs three system commands, not one: lsof -iTCP -sTCP:LISTEN for the sockets, lsof -d cwd for the working directory of each process, and ps -o command= for the command line. NotchStar’s Monitor tab runs exactly those three and parses their output in ProcessMonitor.swift.
  • Stop sends SIGTERM to a process the current user owns, only after a second press on the same row. It never sends SIGKILL, and it refuses process 1.
  • Measured on 4 November 2026 on an M4 Mac running macOS 27.0.1: the listener query took 0.07 s, the working directory query 0.05 s, and the ps call 0.35 s, which was the slowest of the three.

The problem: a port number says nothing about a project

A developer machine collects listeners. A dev server on 3000, a database on 5432, a second dev server that did not shut down, and a stray Python process from an experiment last week. The error message is always the same one: the port is already in use.

The classic answer is one line.

lsof -nP -iTCP:3000 -sTCP:LISTEN

That gives the process name and the pid. It does not say which folder the process was started from, what arguments it was given, or whether it is reachable from the network or only from the Mac itself. A process named node could be any of five projects. The questions a developer actually asks are different: which project is on 3000, can the coffee shop network reach it, and can I close it without hunting for a terminal tab.

NotchStar’s Monitor tab has a Ports view for those questions. Each row shows the port, the project name, a shortened command, the folder, whether the listener is local or on the network, and three buttons: open in the browser, show the folder in Finder, and Stop. The source is Sources/NotchStar/Tools/Disk/ProcessMonitor.swift for the parsing and MonitorView.swift for the buttons. The tests are in PortInspectionTests, inside Tests/NotchStarTests/DiskEnginesTests.swift.

The Ports view shipped in version 1.0.2 (commit 9a6cd38, 7 October 2026). This post reads the code as it is in the repository on 4 November 2026.

Approach: three commands, each with a machine readable mode

Parsing the default lsof table is fragile, because the columns are padded for humans and the command name can contain spaces. lsof has a field output mode, -F, which prints one value per line, each prefixed by a single letter that names the field. The app asks for the pid, the command name and the network address, and nothing else.

static func listeningPorts() -> [ListeningPort] {
    enrich(parseLsof(run("/usr/sbin/lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-Fcpn"])))
}

-n and -P stop lsof from resolving host names and service names, which is slow and unneeded. -iTCP -sTCP:LISTEN keeps only TCP sockets in the listen state. This is real output from a throwaway server started for this post, a Python http.server bound to loopback on 8765 and another on every interface on 8766 (4 November 2026):

p37878
cPython
f4
n127.0.0.1:8765
p37880
cPython
f5
n*:8766

p starts a process record, c is the command, f is the file descriptor number, and n is the name of the socket, which for a listener is address:port. The parser ignores f, because the default branch of its switch does nothing, and it keeps the last p and c it saw.

for line in text.split(separator: "\n") {
    guard let tag = line.first else { continue }
    let value = String(line.dropFirst())
    switch tag {
    case "p": pid = Int(value) ?? 0
    case "c": command = value
    case "n":
        if let split = value.lastIndex(of: ":"), let port = Int(value[value.index(after: split)...]) {
            let host = value[..<split].trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
            let entry = ListeningPort(pid: pid, process: command, port: port, address: host.isEmpty ? "*" : host)
            if seen.insert(entry.id).inserted { out.append(entry) }
        }
    default: break
    }
}

Three details in that block matter.

The split is on the last colon, not the first. An IPv6 listener prints as [::1]:5173, which has colons inside the address. Taking everything after the final colon as the port, and trimming square brackets from what is left, gives the port 5173 and the address ::1.

The address is kept. The first version of this parser, in commit 555affc, took only the last piece after splitting on colons, so a row had a pid, a command and a port, and nothing else. That is enough to list ports. It is not enough to say whether a listener is local, and the address field arrived in 1.0.2 together with the local and network label. A listener on 127.0.0.1, ::1 or localhost is local only. Anything else, including *, is shown as network.

var isLocalOnly: Bool { ["127.0.0.1", "::1", "localhost"].contains(address) }

The result is deduplicated on pid-port. A server that listens on both IPv4 and IPv6 prints two n lines for one port. The test testParseLsofDedupesAndSorts feeds n*:3000 and n[::1]:3000 for the same pid and expects one row. On the Mac used for this post, 21 n lines collapsed to 15 unique pid and port pairs (4 November 2026, 21:04 IST).

The working directory comes from a second lsof call

A listening socket does not know the folder its process started in. The process does, and lsof can print it: the pseudo file descriptor named cwd is the current working directory. The app collects every pid from the first query and asks once, with the pids as a comma separated list.

let list = pids.map(String.init).joined(separator: ",")
let cwds = parseCwd(run("/usr/sbin/lsof", ["-nP", "-a", "-d", "cwd", "-p", list, "-Fpn"]))
let commands = parseCommands(run("/bin/ps", ["-o", "pid=,command=", "-p", list]))

The -a is easy to miss and it matters. Without it, lsof joins its selection options with OR, so -d cwd and -p together would list every process’s working directory plus every file of the listed pids. With -a they are ANDed. The output for the two throwaway servers:

p37878
fcwd
n/private/tmp/claude-501/.../scratchpad/demo
p37880
fcwd
n/private/tmp/claude-501/.../scratchpad/demo

The path is shortened here. The structure is what matters: a p line, an f line that says cwd, and an n line with the folder.

One query for all pids rather than one per row was a deliberate choice. Measured with /usr/bin/time -p three times on 4 November 2026, one lsof -d cwd call for all 11 listening pids took 0.05 s each time.

From a folder to a project name

A dev server is usually started inside a project, but often inside a subfolder: web/, apps/api/, src/. The row should say the project, not src. projectName(cwd:) walks up from the working directory, at most six levels, and stops at the first folder that contains a project marker.

private static let projectMarkers = [".git", "package.json", "Package.swift", "Cargo.toml", "pyproject.toml", "go.mod", "Gemfile", "pom.xml", "build.gradle", "docker-compose.yml"]

It stops early at the home folder and at /, so a process started from ~ never reports the home folder as a project, and it falls back to the working directory’s own name when no marker is found. The test testProjectNameWalksUpToTheMarker checks all three outcomes: /Users/a/code/harbor/web/src becomes harbor, /Users/a/notes becomes notes, and / becomes an empty string. The file system check is injected as a closure, so the test needs no real folders.

Shortening the command

ps -o command= prints the full command line, which for a Node server is a long path to the interpreter, a longer path to a script and then the arguments. shortCommand keeps the executable’s last path component and, for a short list of runtimes, replaces the interpreter and script path with the script name.

private static let runtimes: Set<String> = ["node", "python", "python3", "ruby", "java", "deno", "bun", "php", "perl"]

The test shows the intent: /opt/homebrew/bin/node /Users/a/web/node_modules/.bin/next dev -p 3000 becomes next dev -p 3000, and /usr/sbin/ollama serve becomes ollama serve. Anything longer than 48 characters is cut with an ellipsis.

The wrong turn: a first press that does nothing

Stopping a server from a list is one click away from stopping the wrong one. The rows are close together, the buttons are small, and the list can change order as ports come and go. Two rules came out of that.

The first is the mechanism. The model keeps one optional string, the id of the row whose Stop button has been pressed once.

func stop(_ p: ListeningPort) {
    guard confirming == p.id else { confirming = p.id; return }
    confirming = nil
    if ProcessMonitor.stop(pid: p.pid) { say(String(localized: "Asked \(p.process) to quit")) }
    else { say(String(localized: "Could not stop \(p.process)")) }
}

A first press on any row records its id and returns. The button changes to a red “Stop?” The second press on the same row sends the signal. A first press on a different row moves the pending state to that row, so two presses on different rows never stop anything.

The second rule is what the signal is.

@discardableResult
static func stop(pid: Int) -> Bool { pid > 1 && kill(pid_t(pid), SIGTERM) == 0 }

SIGTERM asks a process to exit and lets it clean up: flush a database, remove a lock file, close connections. SIGKILL cannot be caught and is the signal that leaves a corrupted data file behind. The app sends only the first one. The call fails, and returns false, for any process the current user does not own, because kill(2) returns EPERM for those, and the comparison with 1 rules out launchd before the system gets a say.

Two tests pin this down. testStopRefusesPid1AndNonexistent expects false for pids 1 and 999999. testStopEndsAProcessWeOwn starts a real /bin/sleep 30, stops it through the function, waits for it and checks that it ended because of a signal.

The same behaviour is visible from a shell. These are real results from 4 November 2026, 20:57 IST:

$ kill -TERM 37880      # the :8766 throwaway server
$ lsof -nP -iTCP:8766 -sTCP:LISTEN -Fcpn | wc -l
0
$ kill -TERM 37880      # a second time, the pid is gone
kill: kill 37880 failed: no such process
$ kill -0 1
kill: kill 1 failed: operation not permitted    # output shortened to the message

The port disappeared within half a second of the signal. The app’s own guard stops at 1 before this point, so the user never sees the system’s refusal. The “Could not stop” message covers every other failed case.

What a real test of a real listener looks like

testRealListenerIsInspected is the test that justifies the design. It opens a TCP socket in the test process, binds it to 127.0.0.1 on port 0 so the system picks a free port, reads the chosen port back with getsockname, and then asks the real ProcessMonitor.listeningPorts() to find it.

let found = ProcessMonitor.listeningPorts().first { $0.port == port && $0.pid == Int(getpid()) }
let entry = try XCTUnwrap(found)
XCTAssertTrue(entry.isLocalOnly)
XCTAssertFalse(entry.cwd.isEmpty)
XCTAssertFalse(entry.command.isEmpty)

No parsing is mocked. If a future macOS changes how lsof prints a field, this test fails. The parser tests with fixed strings cannot catch that, and they are still useful for the corner cases, like IPv6 and duplicates, that a real machine does not always produce on demand.

Numbers, with the method

All figures were taken on one Mac (Apple M4, macOS 27.0.1, lsof 4.91) on 4 November 2026 between 21:03 and 21:04 IST. The machine was not idle: the load average was between 3 and 7 from other work, so these are not best case numbers.

MeasurementCommandResult
Listener querylsof -nP -iTCP -sTCP:LISTEN -Fcpn, three runs0.07 s each
Working directorieslsof -nP -a -d cwd -p <11 pids> -Fpn, three runs0.05 s each
Command linesps -o pid=,command= -p <11 pids>, three runs0.34 to 0.36 s
Listening sockets seen by lsoflsof ... | wc -l21 lines, all owned by the current user
Listening sockets seen by netstatnetstat -an -p tcp | grep -c LISTEN23 lines
Unique pid and port pairsawk over the -F output15 from 21 n lines

The ps call was the slowest by a factor of five, which was not expected. It was not investigated further, so no explanation is offered here. The three calls run one after another, so the sum is roughly half a second on this machine. A first measurement of the whole chain in one shell, at 21:04, gave 0.71 s.

The difference between 23 and 21 listening lines was not investigated either. A plausible reading is that netstat sees sockets that lsof cannot attribute to a process visible to this user, but that is a guess, not a result.

What is still bad

  • Only the current user’s processes showed up. Run without root, lsof listed 21 sockets on the test Mac and every one belonged to the current user, so a listener owned by another user may be missing from the view. That was not tested with a second account.
  • The runtime list in shortCommand is an exact, lowercase match on the executable name. The Python that ships with Xcode runs as Python with a capital letter, as the throwaway servers did, so it is not in the set and the row would show Python -m http.server 8765 --bind 127.0.0.1. The shortening is cosmetic, and it is also incomplete.
  • Everything is polled, not pushed. There is no notification when a port opens. The view samples while it is on screen.
  • SIGTERM can be ignored. A process that traps it stays alive, and the app reports “Asked … to quit”, which is true and does not mean the process is gone. There is no escalation to SIGKILL by design.
  • The Open button assumes http://localhost:<port>. A database port opens a browser tab that cannot work.
  • Only TCP is covered. UDP sockets, such as a mDNS or a QUIC listener, never appear.
  • The lsof binary is called by absolute path, /usr/sbin/lsof. If Apple moves it, the call returns an empty string and the list is empty rather than broken.

Try it yourself

None of this needs NotchStar. Run these in Terminal.

lsof -nP -iTCP -sTCP:LISTEN            # 1: every listener you own
lsof -nP -a -d cwd -p 12345            # 3: folder of pid 12345
ps -o pid=,command= -p 12345           # 4: its command line
kill -TERM 12345                       # 6: ask it to exit

The numbers refer to the steps below.

  1. List listeners. Run lsof -nP -iTCP -sTCP:LISTEN. Note the NAME column: *:3000 means every interface, 127.0.0.1:3000 means this Mac only.
  2. Ask for a single port. Run lsof -nP -iTCP:3000 -sTCP:LISTEN and read the pid from the second column.
  3. Find the folder. Run lsof -nP -a -d cwd -p <pid>. The NAME column of the cwd row is the working directory.
  4. Find the command. Run ps -o pid=,command= -p <pid>.
  5. Make it parseable. Add -Fcpn to step 1 and read the one letter prefixes: p pid, c command, n name.
  6. Stop it politely. Run kill -TERM <pid>. Check with step 2 that the port is gone. Use kill -KILL only after a polite attempt failed and you know what the process holds open.

To build the same thing in Swift, run lsof with Process, set standardOutput to a Pipe, read to the end before calling waitUntilExit(), and parse line by line. Reading to the end first avoids a deadlock when the output fills the pipe buffer, and ProcessMonitor.run does it in that order.

Cheat sheet

  • Use -nP so lsof does not resolve names
  • Use -F field output, never the padded table
  • Split an address on the last colon, so IPv6 survives
  • Deduplicate on pid plus port
  • Add -a when combining -d cwd with -p
  • Ask for all pids in one call, comma separated
  • Walk up from the working directory to a project marker, with a depth limit
  • Send SIGTERM, never SIGKILL, and refuse pid 1
  • Require a second press on the same row before sending anything
  • Test against a real listener opened by the test itself
CommandWhat it tells you
lsof -nP -iTCP -sTCP:LISTENEvery TCP listener you own
lsof -nP -iTCP:PORT -sTCP:LISTENWho owns one port
lsof -nP -a -d cwd -p PIDThe folder the process started in
ps -o pid=,command= -p PIDThe full command line
kill -TERM PIDA polite request to exit
kill -0 PIDWhether you may signal it, without sending anything

Closing

The Ports view is one of the small tools in the Monitor tab of NotchStar, a Mac app that puts ten tools in the notch. If you want to try it, it is at notchstar.com, with a 7 day free trial.

NotchStar is a native Mac notch app, $4 once, with a 7 day free trial. Try it, or read the ten tools.