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
TL;DR
- A list of “which project is listening on which port” needs three system commands, not one:
lsof -iTCP -sTCP:LISTENfor the sockets,lsof -d cwdfor the working directory of each process, andps -o command=for the command line. NotchStar’s Monitor tab runs exactly those three and parses their output inProcessMonitor.swift. - Stop sends
SIGTERMto a process the current user owns, only after a second press on the same row. It never sendsSIGKILL, 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
pscall 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.
| Measurement | Command | Result |
|---|---|---|
| Listener query | lsof -nP -iTCP -sTCP:LISTEN -Fcpn, three runs | 0.07 s each |
| Working directories | lsof -nP -a -d cwd -p <11 pids> -Fpn, three runs | 0.05 s each |
| Command lines | ps -o pid=,command= -p <11 pids>, three runs | 0.34 to 0.36 s |
Listening sockets seen by lsof | lsof ... | wc -l | 21 lines, all owned by the current user |
Listening sockets seen by netstat | netstat -an -p tcp | grep -c LISTEN | 23 lines |
| Unique pid and port pairs | awk over the -F output | 15 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,
lsoflisted 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
shortCommandis an exact, lowercase match on the executable name. The Python that ships with Xcode runs asPythonwith a capital letter, as the throwaway servers did, so it is not in the set and the row would showPython -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.
SIGTERMcan 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 toSIGKILLby 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
lsofbinary 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.
- List listeners. Run
lsof -nP -iTCP -sTCP:LISTEN. Note theNAMEcolumn:*:3000means every interface,127.0.0.1:3000means this Mac only. - Ask for a single port. Run
lsof -nP -iTCP:3000 -sTCP:LISTENand read the pid from the second column. - Find the folder. Run
lsof -nP -a -d cwd -p <pid>. TheNAMEcolumn of thecwdrow is the working directory. - Find the command. Run
ps -o pid=,command= -p <pid>. - Make it parseable. Add
-Fcpnto step 1 and read the one letter prefixes:ppid,ccommand,nname. - Stop it politely. Run
kill -TERM <pid>. Check with step 2 that the port is gone. Usekill -KILLonly 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
-nPsolsofdoes not resolve names - Use
-Ffield output, never the padded table - Split an address on the last colon, so IPv6 survives
- Deduplicate on pid plus port
- Add
-awhen combining-d cwdwith-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, neverSIGKILL, 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
| Command | What it tells you |
|---|---|
lsof -nP -iTCP -sTCP:LISTEN | Every TCP listener you own |
lsof -nP -iTCP:PORT -sTCP:LISTEN | Who owns one port |
lsof -nP -a -d cwd -p PID | The folder the process started in |
ps -o pid=,command= -p PID | The full command line |
kill -TERM PID | A polite request to exit |
kill -0 PID | Whether 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.