Cuando una actualización de macOS tumbó mi servidor… y el bot que debía avisarme se autosaboteó

Cuando una actualización de macOS tumbó mi servidor… y el bot que debía avisarme se autosaboteó

Tengo un Mac mini debajo de la mesa haciendo de servidor 24/7. Sirve sergiocomeron.com, el Meet, el Aula de Moodle, el blog y algún cacharro más, todo detrás de un Cloudflare Tunnel. Lleva meses funcionando como un reloj. Hasta que una noche lancé una actualización del sistema… y empezó la fiesta.

Esto es la crónica de dos incidencias encadenadas en dos días: una que me tuvo el servidor cayéndose como un fantasma, y otra en la que la propia herramienta que debía avisarme de los problemas se convirtió en el problema. Con sus capturas, sus logs y, al final, sus lecciones.

Acto 1: las caídas fantasma

La noche del 1 de junio lancé una actualización de macOS antes de acostarme. A las 22:16 quedó instalada; a las 22:19, el reinicio. Y yo, a dormir tan tranquilo.

$ softwareupdate --history
macOS Tahoe 26.5.1   26.5.1   01/06/2026, 22:16:31

$ sw_vers
ProductVersion: 26.5.1   BuildVersion: 25F80

A la mañana siguiente, el móvil echaba humo. El monitor me había disparado una colección de avisos a cuál más alarmante:

  • “Contenido incorrecto: meet.sergiocomeron.com”
  • “Cabeceras de seguridad ausentes: sergiocomeron.com”
  • “HTTP no redirige a HTTPS — Código HTTP: 000”
  • Y del túnel de Cloudflare: “failed to accept QUIC stream: … network is down”

El primer instinto, reconozcámoslo, es pensar lo peor: me han hackeado, se ha caído algo, alguien está toqueteando la configuración. Pero si te fijas, los avisos son demasiado variados para una sola causa real… y ahí está la pista. Sobre todo ese HTTP 000. Un 404 es “no existe”; un 500 es “el servidor ha petado”; pero un 000 es que no hubo ni conexión. No es que la web respondiera mal: es que no respondió nada. Eso no huele a hackeo, huele a que el servidor, sencillamente, no estaba.

¿Y dónde estaba? Dormido. La respuesta apareció en los logs de energía:

$ pmset -g log | grep -E "Entering Sleep|DarkWake"
2026-06-01 23:35:58  Sleep      Entering Sleep state due to 'Idle Sleep'
2026-06-02 09:57:58  Sleep      Entering Sleep state due to 'Maintenance Sleep':TCPKeepAlive=active  144 secs
2026-06-02 10:00:22  DarkWake   DarkWake from Deep Idle ... Enet.TCPData
2026-06-02 10:01:07  Sleep      Entering Sleep state due to 'Maintenance Sleep'  39 secs
... (98 ciclos sleep/wake desde el arranque)

Noventa y ocho ciclos. El Mac llevaba toda la noche durmiéndose y despertándose como un recién nacido. Repartido por horas, el patrón es de manual:

23h → 5    03h → 5    07h → 9
00h → 8    05h → 6    08h → 5
01h → 3    06h → 4    09h → 30
02h → 4               10h → 12

¿Y por qué no salté ninguna alarma de madrugada? Porque una máquina dormida no ejecuta nada ni tiene red: mientras el Mac entraba y salía del sueño, ni se hacían los chequeos del monitor ni había forma de que saliera un aviso. Los avisos solo llegaron a las 10:00, cuando por fin estuvo despierto el rato suficiente para que el chequeo se ejecutara, viera que no había respuesta y consiguiera mandar la alerta.

¿La causa raíz? La actualización había reseteado la configuración de energía a los valores de fábrica. Un Mac recién salido de caja viene configurado para dormir, porque se asume que es el portátil de alguien, no un servidor. Y ahí estaba el culpable, a la vista:

$ pmset -g custom
sleep      1     ← el culpable
powernap   1
displaysleep 10

sleep 1 significa “duérmete tras un minuto de inactividad”. Para un portátil, perfecto. Para un servidor que tiene que estar despierto 24/7, una sentencia de muerte intermitente. La solución fue blindarlo para que no se duerma jamás, y de forma que persista entre reinicios:

$ sudo pmset -a disablesleep 1
$ sudo pmset -a sleep 0
$ sudo pmset -a powernap 0

$ pmset -g | grep -i SleepDisabled
SleepDisabled   1     ← blindado

Servidor despierto otra vez. Crisis uno, resuelta. O eso creía.

Acto 2: el sabotaje del vigilante

Al día siguiente, nuevo aviso: CPU caliente. Algo llevaba más de 30 horas clavado al 100% de un núcleo.

$ ps -Ao pcpu,time,command -r | head
%CPU   TIME        COMMAND
100.2  1835:50.81  /Users/.../.bun/bin/bun server.ts

Mil ochocientos treinta y cinco minutos de CPU. Más de un día entero quemando un núcleo a tope. ¿El responsable? Vamos a mirar la familia del proceso:

$ ps -Ao pid,ppid,pcpu,lstart,command | grep bun
43741  43732  100.0  Tue Jun 2 12:00:28 2026  bun server.ts
43732      1    0.0  Tue Jun 2 12:00:28 2026  bun run ... claude-plugins/telegram/0.0.6 ... start

Y aquí la ironía empieza a saborearse. El proceso no era de la web, ni del servidor, ni de nada que sirviera a nadie. Era el plugin de Telegram de Claude Code: justo la herramienta que uso para que me avisen de los problemas. El vigilante. Fíjate además en el PPID=1: estaba huérfano. Su proceso padre había muerto y a él lo había adoptado el sistema, dejándolo dando vueltas en el vacío.

¿Por qué se desbocó? Por la incidencia del día anterior. El plugin habla con Telegram por long-polling: pregunta “¿hay mensajes?”, espera, vuelve a preguntar. Con la red inestable de la noche del sueño, esas peticiones empezaron a fallar, y entró en un bucle de reintentos que se comió un núcleo entero.

Pero lo bueno —lo bueno de verdad— es lo siguiente. El plugin tiene un mecanismo de seguridad para esto. Un watchdog que cada 5 segundos comprueba si se ha quedado huérfano y, si es así, se autodestruye limpiamente:

1// del código del plugin (server.ts):
2const bootPpid = process.ppid
3setInterval(() => {
4  const orphaned = process.ppid !== bootPpid
5                || process.stdin.destroyed
6                || process.stdin.readableEnded
7  if (orphaned) shutdown()
8}, 5000).unref()

Un diseño impecable. Salvo por un detalle: ese setInterval necesita que la CPU le dé un hueco para ejecutarse… y la CPU estaba al 100% atrapada en el bucle de reintentos. El temporizador nunca llegó a dispararse. El mecanismo de autoprotección quedó anulado justo por el fallo que tenía que resolver. El vigilante, secuestrado por el mismo incendio que debía apagar.

La solución, una vez identificado, fue trivial:

$ kill -9 43732 43741     # se relanzó solo y limpio
$ ps -Ao pcpu,command -r | grep bun
0.1  bun server.ts        # de 100% a 0.1%

Reconecté el canal de Telegram, mandé un mensaje de prueba, lo recibí. Todo en orden.

Epílogo: el bug ya estaba reportado

Antes de salir corriendo a abrir un issue en GitHub, hice lo que debería ser el primer reflejo de todos: comprobar si el problema ya estaba reportado. Y vaya si lo estaba. El plugin es el oficial de Anthropic (repositorio público anthropics/claude-plugins-official), y había más de una docena de issues abiertos describiendo exactamente lo mismo: el proceso bun huérfano clavado al 100% de CPU, el watchdog que no salta, el bucle de polling desbocado. Varios eran específicos de la versión 0.0.6, justo la que yo tenía.

Y ahí está la primera lección de comunidad: cuando te topas con un bug así, lo primero es buscar duplicados. Abrir el issue número doce, idéntico a los once anteriores, no ayuda a nadie; solo añade ruido al que tiene que arreglarlo. Además, la 0.0.6 era la última versión publicada, así que tampoco había una actualización esperándome que lo resolviera.

Pero una cosa es no duplicar, y otra es no aportar. En lugar de abrir un hilo nuevo, comenté en uno de los issues existentes (#1916) con tres detalles que no estaban en la conversación y que ayudan a reproducir —y a arreglar— el fallo:

  • Pasa también en Apple Silicon. El hilo original era en un Mac Intel; el mío es un Mac mini con chip de Apple, así que el bug no es cosa de la arquitectura.
  • El disparador no era el que creían. El issue lo atribuía a “dos sesiones peleándose por el token”. Pero en mi caso el detonante fue la pérdida de red sostenida del Acto 1. Eso apunta a que el bucle de reintentos también se descontrola ante errores de red, no solo ante los conflictos 409 de dos sesiones a la vez.
  • El detalle más fino, el del watchdog. Ese mecanismo que debía autodestruir el proceso huérfano nunca se disparó… y no solo por la CPU al 100%. Comprueba su proceso padre directo, pero el que se quedó huérfano (adoptado por el PID 1) fue el wrapper intermedio bun run, no el server.ts que ejecuta el watchdog. La orfandad ocurrió un nivel por encima del vigilante, donde este no miraba. (Lo enlacé con el issue #1604, que apunta a la misma raíz.)

Y esa es la moraleja de este epílogo: un buen reporte de bug no es “he encontrado un fallo” —eso ya lo sabían once personas antes que tú—, sino “aquí tenéis un dato nuevo para reproducirlo o arreglarlo”. Contribuir a un issue existente, aunque sea con una sola observación que falte, vale más que abrir el número doce.

Tres lecciones de dos días movidos

  1. Las actualizaciones de macOS pueden resetear tu configuración sin avisar. Si usas un Mac como servidor, no es paranoia revisar pmset (y lo que haga falta) después de cada actualización. El sistema asume que tu servidor es el portátil de alguien.
  2. Un HTTP 000 o un “network is down” casi nunca es un hackeo. Es falta de conectividad. Respira, no te asustes, y diagnostica de lo más simple a lo más rebuscado.
  3. Los fallos se encadenan. Un problema de red puede tumbar herramientas que en teoría no tienen nada que ver entre sí, e incluso anular sus propios mecanismos de autoprotección. Cuando algo raro pase, pregúntate siempre: ¿esto es la causa, o es la consecuencia de otra cosa que pasó antes?

Autoalojar en un Mac mini tiene algo adictivo: control total, cero facturas, y cacharrear con tu propia infraestructura. Pero conviene no olvidar que, por debajo, sigue siendo una máquina de consumo haciéndose pasar por servidor. Y de vez en cuando, te lo recuerda.