Notes from the work

One email when a tutorial or migration write-up goes live. Nothing else, and an unsubscribe link in every email.

LeanZero

Two people in Romania doing Atlassian migrations, Forge apps and practical AI work for teams that would rather talk to the person doing the job. Most of what we learn ends up on this site.

Services

  • Atlassian Migrations
  • Atlassian FastShift
  • Atlassian Maintenance
  • Forge App Development
  • AI Development Consultation

Topics

  • Jira
  • Jira Service Management
  • Confluence
  • Bitbucket
  • Atlassian Forge
  • Cloud Migration
  • Local AI
  • AI Coding
  • Certifications
  • Atlassian Team
  • All topics

Company

  • Blog
  • Tutorials
  • Contact

Community

  • Join Discord
  • Support this site

© 2026 LeanZero. All rights reserved.

Privacy PolicyTerms of Service24/7 SupportTrust CenterSecurityDPALegal notice
  1. Home
  2. Blog
  3. Lsof Not Found Packaged App Path
lsof: command not found in a packaged Mac app, and the port reclaim that never ran
AI Codinggoose Local EditionLocal AI

lsof: command not found in a packaged Mac app, and the port reclaim that never ran

Mihai Perdum
Mihai Perdum
Author
8 min readSeptember 5, 2026

Key takeaways

  • macOS ships lsof at /usr/sbin/lsof ONLY. A packaged app's PATH ends at /usr/bin:/bin, so a bare-name spawn ENOENTs in the shipped build and works fine on your machine.
  • The tell is not the error. The tell is that the error is the ONLY thing in the log — a branch that always runs is the implementation, not a fallback.
  • Resolve a system binary from known absolute locations first, then walk PATH, and make the failure name every path it looked at.
  • `lsof -i :PORT` lists CLIENTS as well as the listener. Measured: the bare query returned the connected client's pid alongside the listener's. Signal that list and you kill bystanders — possibly yourself.
  • `-sTCP:LISTEN` is not tidiness. It is the difference between freeing a port and shooting the process doing the freeing.

Here is the whole of what the shipped app had to say about a port it could not free:

text
1WARN reclaim: lsof unavailable; port left occupied  error="lsof"

One line, one word of context. error="lsof" is a bare-name ENOENT wearing a coat — the process tried to run a program called lsof, the kernel said there is no such file, and we laundered that into a sentence about ports.

The port stayed occupied — 8090, the one our local MLX engine sidecar mounts on. The next mount refused, because the address was still in use. And the reclaim code that was supposed to fix exactly this had, in the packaged application, never once executed.

macOS keeps lsof somewhere your app cannot see

On this machine there is exactly one lsof:

text
1ls: /opt/homebrew/bin/lsof: No such file or directory
2ls: /usr/bin/lsof: No such file or directory
3-rwxr-xr-x  1 root  wheel  306768 Aug  2 02:15 /usr/sbin/lsof

/usr/sbin. Not /usr/bin, not Homebrew. And /usr/sbin is precisely the directory that a packaged app's environment tends to drop. The PATH our backend ran with under the app, measured, was:

text
1…/Resources/bin:…:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin

Read that list again and look for /usr/sbin. It is not there. So:

text
1$ env -i PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin sh -c 'lsof -ti TCP:1'
2sh: lsof: command not found
3EXIT=127

That is the entire bug. In a terminal your PATH is inherited from a login shell and contains /usr/sbin, so lsof resolves and every test passes. Under the packaged app it does not, so the spawn fails before it has done anything at all. Same code, same machine, two different worlds — and the one you develop in is the one where it works.

The part that should have caught it much earlier

I want to dwell on this, because the PATH detail is a fact you look up once and the other thing is a habit.

The reclaim had two branches: resolve the port's holders and signal them, or warn that lsof was unavailable. In the packaged app, the first branch was unreachable. Not rare, not flaky — unreachable. Every reclaim in every shipped build took the warning arm, one hundred per cent of the time.

Which means the log was not showing us an occasional failure. It was showing us the program's entire behaviour. The warning arm was not a fallback; it was the implementation, and the code we thought was doing the work had no happy path at all in the environment that mattered.

The signal was there and it was legible. It was not "this warning appears sometimes". It was "this warning is the only thing this subsystem has ever said". A branch that always fires is not an edge case, it is the design — and when the always-firing branch is the one that gives up, you have shipped the giving up.

I now read logs with that question in front: not is there an error, but is there anything here except the error. It is the same failure I hit building a benchmark scorer that could not flatter itself — a green result means nothing until you have proved the thing can go red.

Fixing it: look where the thing actually lives

The repair is unglamorous. Try the places the binary is known to live, in order, and only then fall back to walking PATH:

rust
1const LSOF_KNOWN_LOCATIONS: [&str; 3] =
2    ["/usr/sbin/lsof", "/usr/bin/lsof", "/opt/homebrew/bin/lsof"];
3
4fn resolve_lsof_in(
5    known: &[std::path::PathBuf],
6    path_env: Option<&std::ffi::OsStr>,
7) -> Result<std::path::PathBuf, LsofUnavailable> {
8    let from_path = path_env
9        .into_iter()
10        .flat_map(std::env::split_paths)
11        .filter(|dir| !dir.as_os_str().is_empty())
12        .map(|dir| dir.join("lsof"));
13    let mut searched = Vec::new();
14    for candidate in known.iter().cloned().chain(from_path) {
15        if candidate.is_file() {
16            return Ok(candidate);
17        }
18        searched.push(candidate);
19    }
20    Err(LsofUnavailable { searched })
21}

Two things in there are deliberate.

The known locations come before the PATH walk, so the packaged app finds /usr/sbin/lsof without anyone having to repair its environment first. And the failure is a typed value carrying every path that was tried, in order, which renders as:

That is a message you can act on. error="lsof" was not. When a lookup fails, the thing the reader needs is not the name of what you wanted — they know that — it is where you looked.

The regression tests pin both halves, and the first one is named after the bug:

text
1test tests::the_packaged_apps_path_without_usr_sbin_still_resolves_lsof ... ok
2test tests::absence_names_every_path_searched_in_order ... ok
3test engine::tests::unmount_reclaims_an_unsupervised_listener ... ok
4
5test result: ok. 43 passed; 0 failed; 9 ignored; 0 measured; 0 filtered out

We also widened the environment on the way in, appending /usr/sbin:/sbin to the backend's PATH when they are missing, on POSIX only. That is belt and braces on top of the resolver rather than the fix itself. Environment repair is easy to regress silently; the resolver holds even if someone rebuilds the launch environment tomorrow.

The second trap, which is worse than the first

Once lsof resolves, you are about to send signals to whatever it returns. So it matters enormously what it returns.

text
1listener pid (the one to kill) : 42893
2client   pid (a bystander)     : 42894
3lsof -ti TCP:8874              : [42893, 42894]
4lsof -ti TCP:8874 -sTCP:LISTEN : [42893]
5bare list catches the bystander: True
6LISTEN list catches bystander  : False

That is a listener and a separate client process connected to it. lsof -ti TCP:8874 returns both. The bare query does not mean "who owns this port", it means "who currently has a socket touching this port", and a client holding an established connection qualifies.

Now put that in a port reclaim. You want to free port 8090, so you ask which processes are on 8090 and signal them. The list includes every client with an open connection — and in our case one of those clients is the backend's own keep-alive pool. The reclaim would have signalled the process running the reclaim.

-sTCP:LISTEN is the whole defence:

rust
1// `-sTCP:LISTEN` is load-bearing: a bare `lsof -i :port` also lists every process
2// holding a CLIENT connection to the port — goosed's own keep-alive pool included.

I would not have found that by reading. I found it by starting a listener, connecting a separate process to it, and printing both lists side by side. The first version of that probe had the client and the listener in the same process, so both queries returned the same single pid and the test proved nothing while appearing to pass. Splitting them into two processes is what made the difference visible.

What to take from it

Neither of these is exotic. Both are the same shape: the environment you ship into is not the environment you built in, and a check that cannot fail is not a check.

If your app spawns a system binary by bare name, go and look at where that binary actually lives, then look at the PATH your shipped process really gets — print it from inside the packaged build, do not assume it. On macOS the two that go missing are /usr/sbin and /sbin. Checked on this machine: lsof, netstat, dtrace and arp are in /usr/sbin, while ifconfig and route are in /sbin — so appending only one of the two still leaves you a class of binaries short.

And if you are about to signal a list of pids, be certain the list contains only what you think it does. A port reclaim that kills clients is not a reclaim, it is an outage with good intentions — and the day it includes your own connection pool, the thing it takes down is the thing that was trying to help.

ShareXLinkedIn
PreviousI did not think certifications were worth much. Then one of them showed me what I had been missingNextYour migrated workflow rules still run. That is not the same as still working

Tags

#macos#electron#packaging#lsof#debugging#rust

Comments

0

Add a comment

Confirm your address using the email we send. You can unsubscribe at any time. Your email stays private and this does not sign you up for the newsletter.

Enjoyed?

Join our Discord community to discuss this article and get help with your own migration projects.

Discuss in Discord

On this page

  • macOS keeps lsof somewhere your app cannot see
  • The part that should have caught it much earlier
  • Fixing it: look where the thing actually lives
  • The second trap, which is worse than the first
  • What to take from it

Join the Community

Join the community to ask questions, share your work, and connect with other professionals.

Join Discord

Found My Advice Helpful?

If I helped you solve something for free, coffee money is always appreciated (but never expected).

Buy Me a Coffee

Related reading

Mistral Large 4 coding benchmark: the money bug was ours, and DeepSeek V4.1 Flash still won on Forge

Mistral Large 4 scored 0.5462 on our Gauntlet because a check of ours compared its newest payments with our oldest ones. Every amount it showed was right. On Forge 2.0 it was slow and expensive, mostly for reasons of its own, and DeepSeek V4.1 Flash 1017 built the same app better in about a quarter of the time, for about an eighteenth of the bill.

10/21/2026

Best model for agentic coding in October 2026: who won our benchmarks, and who surprised

We ran 44 models through two agentic coding benchmarks, then read their builds line by line. Paying per token I'd use GPT-6.1 Sol, and MiMo V2.6 Flash came 7th on our Jira benchmark at 31 cents a build. The winners mostly did dull things right, and Claude Haiku 5.5 stopped with the app half written.

10/20/2026

DeepSeek V4.1 Flash 1017 benchmark: same task, two builds, 0.24 and 0.66

One Sunday, three Macs, two agentic coding benchmarks. A one-day-old DeepSeek point release scored above its predecessor on both, and its own two Forge builds landed 0.42 apart. NVIDIA's Nemotron 3.5 Super VL lost its context halfway through a build. A local model declared the same name twice and reported the app ready. And our board had been understating what the runs cost.

10/19/2026