macOS runs out of application memory because your dead dev servers never die
macOS runs out of application memory because your dead dev servers never die
macOS 提示内存不足,因为你那些“死掉”的开发服务器其实还活着
It was past midnight and my Mac threw a box I had not seen in years. Your system has run out of application memory. Force Quit Applications. I had four things open. A browser, an editor, a terminal, and a music app that uses about nothing. None of that should fill 36 gigabytes of RAM and then push into swap until the kernel begs you to start closing things. 午夜已过,我的 Mac 弹出了一个我多年未见的对话框:“您的系统应用程序内存不足。请强制退出应用程序。”当时我只开了四个程序:浏览器、编辑器、终端,以及一个几乎不占内存的音乐软件。按理说,这些东西根本不可能填满 36GB 的内存,甚至还要挤占交换空间,直到内核不得不求你关闭一些程序。
You have seen this popup. You closed the browser, you closed the editor, you felt a little betrayed by your own machine, and you got maybe twenty minutes before it came back. Here is the part nobody tells you. Your memory was not being eaten by the apps you could see. It was being eaten by the ones you could not. 你一定见过这个弹窗。你关掉了浏览器,关掉了编辑器,感觉被自己的电脑背叛了,但不到二十分钟,弹窗又回来了。这里有个没人告诉你的真相:吃掉你内存的并不是你看到的那些应用,而是那些你看不到的。
Dev servers you thought you closed are still running. Every time you run a local dev server, you spawn a process. next dev, vite, webpack, an esbuild watcher, a tsc watcher, nodemon, or a plain pnpm start. It listens on a port, it watches your files, it holds memory. When you press Ctrl+C, it dies. Most of the time. But close the terminal tab instead of pressing Ctrl+C, and the signal that would have killed it never arrives cleanly.
你以为已经关闭的开发服务器其实还在运行。每次你运行本地开发服务器时,都会产生一个进程:next dev、vite、webpack、esbuild 监听器、tsc 监听器、nodemon 或者简单的 pnpm start。它监听端口、监视文件、占用内存。当你按下 Ctrl+C 时,它通常会终止。但如果你直接关闭终端标签页而不是按 Ctrl+C,本该终止进程的信号就无法正常送达。
A wrapper you launched dies and the real worker underneath keeps running. On my machine the actual server runs as a child process, and killing the parent left the child alive, still bound to the port, still holding memory. Do that across a week of work. Open a tab, start a server, close the tab, move to the next project. Each one leaves a quiet survivor behind. 你启动的包装器(wrapper)死掉了,但底层的实际工作进程却在继续运行。在我的机器上,实际的服务器作为子进程运行,杀掉父进程后,子进程依然存活,依然绑定在端口上,依然占用着内存。在一周的工作中重复这个过程:打开标签页、启动服务器、关闭标签页、切换到下一个项目。每一个项目都会留下一个“幸存者”。
By the time the popup fired, I had three production-style servers still running from sessions I had ended days earlier. Each one was small on its own. Together with the file watchers, they were the whole problem. Here is what makes local infrastructure rot nasty. It never shows up in a demo. It shows up at midnight on your own machine, and it looks like your Mac is broken when your Mac is fine. 当弹窗出现时,我有三个几天前就应该结束的生产环境风格的服务器仍在运行。它们单个看起来很小,但加上文件监视器,它们就是问题的全部根源。这就是本地基础设施腐烂的可怕之处:它从不会在演示中出现,只会在午夜你的机器上出现,让你觉得 Mac 坏了,而实际上它好得很。
Why closing the terminal is not the same as stopping the server
为什么关闭终端不等于停止服务器
If you want one line to remember, here it is: A dead terminal does not mean a dead server. 如果你只想记住一句话,那就是:终端死了,不代表服务器也死了。
Three common ways this happens: 这种情况通常有三种发生方式:
-
You close the terminal tab or window instead of stopping the process. A GUI close does not deliver the interrupt the same way Ctrl+C does, so the server is orphaned rather than killed.
-
你直接关闭了终端标签页或窗口,而不是停止进程。图形界面的关闭方式无法像 Ctrl+C 那样发送中断信号,因此服务器变成了“孤儿进程”而不是被杀死。
-
You start a server through a wrapper like
npm start, then Ctrl+C kills the wrapper shell but leaves the node process it spawned running in the background. -
你通过
npm start这样的包装器启动服务器,Ctrl+C 杀死了包装器 Shell,但留下了它在后台生成的 Node 进程。 -
A tool launches the server as a child of a child. Signalling the top process never reaches the leaf, and the leaf is the one holding the port.
-
某个工具将服务器作为“子进程的子进程”启动。发送给顶层进程的信号无法到达末端进程,而正是末端进程占用了端口。
Then you pay the daily tax. You start the server again and the terminal shouts EADDRINUSE, address already in use. A leftover you forgot about is still sitting on port 3000, and now you cannot even start the thing you actually want. So you do what every answer on the internet tells you to do: You run killall node.
然后你就要为此付出代价。当你再次启动服务器时,终端会报错 EADDRINUSE(地址已被占用)。一个你早已遗忘的残留进程依然占据着 3000 端口,导致你无法启动当前的项目。于是你做了网上所有答案都会建议你做的事:运行 killall node。
Why killall node is the worst possible fix
为什么 killall node 是最糟糕的修复方案
killall node feels great for about one second. Then you notice your editor’s language server is dead. Your other project’s dev server is dead. Your database GUI is dead. And if you run AI tooling with local model context servers, those are gone too, because they are node processes, and killall node does not care which node you meant.
killall node 让你爽快了一秒钟。然后你发现编辑器的语言服务器挂了,其他项目的开发服务器挂了,数据库 GUI 挂了。如果你还运行着带有本地模型上下文服务器的 AI 工具,它们也挂了,因为它们都是 Node 进程,而 killall node 根本不在乎你到底想杀掉哪一个。
killall node does not target one thing. It matches by name and stops every match on the entire machine. So does pkill -f node. So does the classic ps | grep | xargs kill. They are all the same weapon pointed at your own foot.
killall node 不会针对单一目标。它通过名称匹配并停止机器上所有匹配的进程。pkill -f node 也是如此,经典的 ps | grep | xargs kill 也是如此。它们都是指向你自己脚部的同一把武器。
Here is my strongest opinion in this whole piece: You should never stop a process by name. Not node, not next, not anything. You stop the exact process by its process ID (PID), so nothing else can get caught in the blast. People reach for the name-based version because finding the one right process ID is annoying, and the popup makes you panic. Panic plus a blunt instrument is how you turn a small leftover into a lost afternoon. 这是我整篇文章中最强烈的观点:永远不要通过名称来停止进程。无论是 node、next 还是其他任何东西。你应该通过进程 ID (PID) 来停止特定的进程,这样就不会误伤其他进程。人们倾向于使用基于名称的方法,是因为查找正确的 PID 很麻烦,而且弹窗会让你恐慌。恐慌加上粗暴的工具,就是你把一个小小的残留问题变成一下午工作丢失的原因。
What a safe cleanup actually has to get right
安全的清理工作必须做到什么
I lost that afternoon. So I stopped hand hunting process IDs and built a small system that finds the genuine leftovers and stops only those. I am not going to hand you the whole recipe, because the value is in the safety reasoning, not the shell script. But here is everything you need to think clearly about it. 我浪费了那个下午。所以我不再手动查找 PID,而是构建了一个小系统,专门寻找真正的残留进程并只停止它们。我不会直接给你完整的代码,因为价值在于安全逻辑,而不是 Shell 脚本。但以下是你需要清晰思考的所有要点:
A safe reaper has to answer five questions before it stops anything: 一个安全的“收割者”在停止任何进程前必须回答五个问题:
-
Is this a leftover, or a server I am using right now? An orphaned process on macOS reparents to the system launcher, which also legitimately owns real daemons. You have to combine it with a dev server signature before you trust it.
-
这是残留进程,还是我正在使用的服务器? macOS 上的孤儿进程会重新挂载到系统启动器下,而系统启动器也合法拥有真正的守护进程。你必须将其与开发服务器的特征结合起来,才能信任它。
-
Is it actually a server, or a one-shot build? Stopping a running build halfway corrupts the artifact. A real server listens on a port. A build does not. That difference is your safest gate.
-
它是服务器,还是单次构建任务? 在构建过程中强行停止会损坏产物。真正的服务器会监听端口,而构建任务不会。这个区别是你最安全的防线。
-
Is anyone using it? A dev server with a browser tab open has live connections. A truly abandoned one has none. Stop the idle one, spare the connected one.
-
有人在使用它吗? 开启了浏览器标签页的开发服务器会有实时连接。真正被遗弃的服务器则没有。停止空闲的,放过正在连接的。
-
Is it something the user set up on purpose? A background service someone installed as a launch agent looks identical to an orphan. Am I about to hit my own tools? Model context servers, editor language servers, browsers, and the agent itself all have to be excluded by design, not by luck.
-
这是用户特意设置的吗? 作为启动代理安装的后台服务看起来和孤儿进程一模一样。我是否会误伤自己的工具?模型上下文服务器、编辑器语言服务器、浏览器以及清理工具本身,都必须通过设计来排除,而不是靠运气。
Only when all of those line up is a process a genuine, safe target. 只有当以上所有条件都满足时,该进程才是一个真正、安全的清理目标。
One more trap: measuring the wrong number
还有一个陷阱:测量了错误的指标
Here is a subtle one that cost me real time. That “out of application memory” popup fires on swap and kernel memory pressure, never on how much RAM looks free. I measured it live during the incident. My machine reported a comfortable percentage of memory free while swap was 96% full with barely a gigabyte left. Trigger your cleanup on free memory percentage and it will fail. 这是一个让我浪费了大量时间的隐蔽陷阱。那个“应用程序内存不足”的弹窗是基于交换空间和内核内存压力触发的,而不是基于看起来还有多少空闲 RAM。我在事故发生时进行了实时测量:我的机器显示有相当比例的空闲内存,但交换空间已经满了 96%,只剩下不到 1GB。如果你根据空闲内存百分比来触发清理,那注定会失败。