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

> Find which process listens on a port on macOS with lsof, map it to a project folder, and stop it safely. Real Swift parsing code, output and limits.

Canonical: https://www.notchstar.com/blog/macos-lsof-ports-view-two-press-stop/
Last updated: 2026-11-04

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

Published 2026-11-04.

## 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.

```sh
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.

```swift
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):

```text
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.

```swift
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.

```swift
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.

```swift
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:

```text
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.

```swift
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.

```swift
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.

```swift
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.

```swift
@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:

```text
$ 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.

```swift
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, `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.

```sh
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

| 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](https://www.notchstar.com/), with a 7 day free trial.

## More posts

- [Why a disk tool should only ever move to the Trash](https://www.notchstar.com/blog/disk-cleaner-only-move-to-trash-swift/)
- [A shadow test that sampled an icon: testing flat design with pixels in Swift](https://www.notchstar.com/blog/swift-pixel-test-shadow-sampled-an-icon/)
