Robustez y ciclo de vida: procesos que mueren cuando deben
Los tres finales de un proceso hijo y cómo distinguirlos con executable, pcall, code y signal; cancelar la ejecución en vuelo con kill antes de relanzar; descartar resultados obsoletos con un contador de generación o con changedtick; y la limpieza en BufWipeout, ExitPre y VimLeavePre para no dejar huérfanos.
Todo lo anterior funciona el día que la herramienta existe, responde rápido y termina bien. El código que separa un experimento de un plugin es el que se escribe para los otros días: el binario que no está instalado, el comando que se cuelga esperando algo que nunca llega, el resultado que aterriza cuando ya nadie lo quiere, el proceso que sigue vivo diez minutos después de que cerraras su buffer. Esta lección cierra el nivel con esos días, y con una idea que los ordena todos: cada proceso que lanzas es un recurso con dueño, y el dueño eres tú.
- Distinguir los tres finales de un proceso y manejar cada uno por separado:
executable,pcall,codeysignal. - Cancelar la ejecución en vuelo con la manija guardada: un proceso por recurso,
killal anterior. - Descartar resultados obsoletos con un contador de generación, y saber cuándo basta
changedtick. - Atar la vida del hijo a la del buffer con autocomandos, y a la del editor con
ExitPreyVimLeavePre. - Evitar huérfanos y zombis entendiendo qué sobrevive a qué.
Los tres finales
Un proceso hijo no falla de una sola manera. Falla de tres, y confundirlas produce mensajes de error inútiles.
No arrancó
El binario no existe, no es ejecutable o el cwd no es un directorio. vim.system lanza un error de Lua —la documentación lo dice en su primera línea: runs a system command or throws an error if cmd cannot be run— y tu callback no llega a existir.
Arrancó y falló
El proceso vivió y devolvió un code distinto de cero. Es un fallo de la herramienta, su explicación está en stderr, y para vim.system es un resultado tan normal como el éxito.
Lo mataron
Murió por señal, con signal distinto de cero. Su code no cuenta la historia real y su salida está probablemente truncada. A veces el asesino eres tú: tu propio timeout o tu kill.
El primer final es el único que no pasa por el callback, y por eso es el que más se olvida: el error salta en la propia llamada, con un ENOENT críptico que revienta lo que hubiera alrededor. La defensa tiene dos capas, y la primera convierte el error en un mensaje accionable antes de que ocurra:
local function ejecutar(cmd, opts, cb) if vim.fn.executable(cmd[1]) ~= 1 then vim.notify(('no encuentro %s en el PATH'):format(cmd[1]), vim.log.levels.WARN) return nil end local ok, obj = pcall(vim.system, cmd, opts, cb) if not ok then vim.notify(tostring(obj), vim.log.levels.ERROR) return nil end return objendvim.fn.executable devuelve 1 si el binario está en el PATH, y es barata. ¿Por qué el pcall además? Porque la comprobación no cubre todo el primer final: un cwd que dejó de existir o un binario borrado entre la comprobación y el lanzamiento siguen lanzando. executable es el diagnóstico amable; pcall es el cinturón.
Los otros dos finales se distinguen dentro del callback, y merecen ramas distintas: un code de 124 con signal a 15 es tu propio timeout disparándose (13.1), y decirle al usuario que la herramienta falló sería mentira; tardó demasiado, y el mensaje correcto es ese.
Un código distinto de cero no siempre es un error
grep devuelve 1 cuando no encuentra nada. diff devuelve 1 cuando hay diferencias. git diff --quiet devuelve 1 cuando hay cambios, que es justo lo que preguntabas. Antes de tratar el code como fallo, lee el manual de la herramienta concreta y codifica su convención, no la tuya: la Predict del grep en la 13.1 era exactamente esta advertencia.
Un proceso por recurso
En cuanto enganchas un proceso a un evento frecuente —guardar, teclear— dejas de tener una ejecución y pasas a tener ejecuciones solapadas. Si no haces nada, todas terminarán y todas publicarán, y el resultado visible será el de la última en acabar, que no tiene por qué ser la más reciente: la carrera con la que cerró la lección 13.4.
La disciplina que la resuelve cabe en una frase: un hueco por recurso, y quien llega mata a quien estaba. El recurso aquí es el buffer; la manija que la 13.1 te enseñó a no tirar es lo que hace posible el desalojo:
local en_curso = {}local function relanzar(bufnr, cmd, cb) local previo = en_curso[bufnr] if previo and not previo:is_closing() then previo:kill('sigterm') end en_curso[bufnr] = vim.system(cmd, { text = true, timeout = 10000 }, function(obj) en_curso[bufnr] = nil if obj.signal ~= 0 then return end -- lo matamos nosotros: no publiques cb(obj) end)endEl descarte por signal es la mitad de la lógica, y la mitad que todo el mundo omite. Un proceso que matas deliberadamente también invoca su callback —con salida truncada y un código sin sentido—, y publicar eso produce diagnósticos que parpadean y listas a medias. La regla: si yo lo maté, su resultado no me interesa. Y delante de todo esto va el debounce de la 12.4, porque matar procesos también cuesta: el temporizador que colapsa la ráfaga de eventos en un disparo hace que relanzar corra una vez por pausa, no una por tecla.
Resultados obsoletos: la generación
Matar al anterior reduce la ventana de la carrera, pero no la cierra: kill es una petición asíncrona, el proceso puede morir con su callback ya en vuelo, y hay casos donde no puedes o no quieres matar —una petición cara que quizá aún sirva—. La solución completa no está en el proceso sino en el resultado: etiquetar cada lanzamiento y descartar todo lo que llegue con etiqueta vieja.
local generacion = {}local function lintar(bufnr) generacion[bufnr] = (generacion[bufnr] or 0) + 1 local mia = generacion[bufnr] -- capturada en el closure (7.3) ejecutar({ 'ruff', 'check', vim.api.nvim_buf_get_name(bufnr) }, { text = true }, function(obj) vim.schedule(function() if generacion[bufnr] ~= mia then return end -- llegó tarde: a la basura if not vim.api.nvim_buf_is_valid(bufnr) then return end publicar(bufnr, obj) end) end)endEl mecanismo es un contador por buffer que solo sube. Cada lanzamiento guarda su valor en un upvalue —el mecanismo de la 7.3— y el callback compara: si el contador global avanzó, alguien lanzó después que yo, y mi resultado describe un mundo que ya no existe. No importa el orden de llegada, no importa si el proceso murió o triunfó: la generación impone un orden lógico donde el kernel no ofrece ninguno.
Neovim trae una variante nativa de esta idea para el caso particular más común: changedtick, el contador de cambios del buffer que el formateador de la 13.4 ya usaba. Las dos herramientas responden preguntas distintas y conviene no confundirlas. changedtick pregunta ¿cambió el texto desde que pregunté? —perfecto para un formateador, cuyo resultado solo es válido sobre el texto exacto que envió—. La generación pregunta ¿alguien lanzó una consulta más nueva que la mía? —lo que importa a un linter, porque el texto puede estar intacto y aun así haber una ejecución más reciente cuyo resultado merece ganar—. Cinturón y tirantes: kill ahorra CPU, la generación garantiza corrección, y ninguno sustituye al otro.
Atar la vida del proceso
Un proceso lanzado desde un buffer no tiene ninguna relación con ese buffer: si el usuario lo cierra, el proceso sigue. Establecer la relación es trabajo tuyo, y su forma canónica en Neovim son los autocomandos, porque cada camino de salida del editor tiene su evento:
vim.api.nvim_create_autocmd({ 'BufWipeout', 'BufDelete' }, { callback = function(ev) local h = en_curso[ev.buf] if h and not h:is_closing() then h:kill('sigterm') end en_curso[ev.buf] = nil end,})vim.api.nvim_create_autocmd('VimLeavePre', { callback = function() for _, h in pairs(en_curso) do if not h:is_closing() then h:kill('sigterm') end end end,})Los dos eventos de buffer cubren el cierre normal. Para la salida global hay dos candidatos que conviene distinguir: ExitPre dispara cuando un :quit o :qall va a cerrar el editor, pero la salida aún puede cancelarse —un buffer modificado sin guardar la aborta—, así que es el sitio para avisar o pedir confirmación, no para destruir. VimLeavePre es el punto sin retorno: de ahí no se vuelve, y ahí van los kill.
¿Y si no haces nada? Neovim mata al salir los trabajos que no lanzaste con detach —la propia documentación de esa opción lo dice del revés: un proceso desligado «no será matado cuando Nvim salga», y los demás sí—. Hacerlo tú antes conserva dos ventajas: eliges la señal, y le das al proceso la oportunidad de recoger sus archivos temporales en vez de morir de golpe.
SIGTERM primero, SIGKILL después
SIGTERM es una petición: el proceso puede atenderla, cerrar sus archivos y salir con dignidad. SIGKILL es una ejecución sumaria que no puede interceptar y que deja tras de sí lo que tuviera abierto. La escalada correcta es la de cualquier gestor de servicios: kill('sigterm'), un temporizador corto, y solo si is_closing() sigue diciendo que no, kill('sigkill'). Ya conoces un sitio donde Neovim escala por ti sin preguntar: wait(timeout) de la 13.1, que al vencer ejecuta directamente con SIGKILL, a diferencia del SIGTERM educado de la opción timeout.
Huérfanos, zombis y grupos
Aquí es donde la intuición falla, porque hay dos patologías distintas que la gente llama con el mismo nombre.
Un zombi es un proceso que ya murió pero cuya entrada en la tabla del kernel sigue ahí porque su padre no ha recogido el código de salida. En Neovim prácticamente no te ocurrirá: libuv, la biblioteca de eventos de la 12.1, recoge a sus hijos automáticamente al completarse el ciclo del callback; basta con no interferir y dejar que el callback se ejecute.
Un huérfano es el problema real y el inverso: un proceso que sigue vivo después de que muriera quien debía vigilarlo. Casi siempre nace de una composición: lanzas un comando que a su vez lanza otros —un script que invoca al compilador, un npm run que abre un servidor— y matas al primero. El muerto era el padre; los nietos quedan sueltos, adoptados por el proceso raíz del sistema, consumiendo CPU sin que nada los reclame.
| Situación | Qué sobrevive | Cómo evitarlo |
|---|---|---|
| Matar un script envoltorio | Los procesos que él lanzó | Evitar el intermediario, o matar el grupo entero |
Lanzar con detach |
Todo, incluso al salir de Neovim | Reservarlo para demonios de verdad (13.1) |
stdin = true sin write(nil) |
El hijo, esperando un EOF que no llega | Cerrar el canal es tu responsabilidad (13.1) |
| Comando que se cuelga | El proceso, indefinidamente | timeout en todo, incluso en lo «instantáneo» |
La defensa práctica tiene tres capas. Primero, evita el shell: la lista de argumentos de vim.system ya elimina un intermediario de la cadena, y con él la fuente más común de nietos; si el intermediario es inevitable y estás en Unix, lanzarlo con detach lo convierte en líder de su propio grupo y una señal a su pid en negativo con vim.uv.kill alcanza al grupo entero, a cambio de que matarlo pase a ser siempre deber tuyo. Segundo, pon timeout a todo, porque el comando que se cuelga es siempre el que no previste. Y tercero, cuida el único caso en que stdin cuelga a un hijo: por defecto no hay tubería de entrada y quien intente leer recibe su EOF al instante, pero si pediste stdin = true, el write(nil) que cierra el canal es una promesa que hiciste en la 13.1 y que aquí se cobra.
La robustez es una propiedad del ciclo de vida, no del manejo de errores
Hay una manera muy extendida y muy pobre de entender la robustez: consiste en imaginarla como una capa de comprobaciones que se añade encima del código que ya funciona —un pcall aquí, un if code ~= 0 allá— y en creer que la calidad se mide por la densidad de esas comprobaciones. Esa concepción falla porque trata el error como un valor que se inspecciona, cuando en el trabajo con procesos el error es un estado del sistema que persiste después de que tu función haya retornado. Un proceso huérfano no es un valor que se te olvidó comprobar: es una entidad viva en el kernel, con su descriptor de tuberías, su memoria y su parte del planificador, que tu programa creó y de la que sigue siendo moralmente responsable aunque haya perdido toda referencia a ella. La pregunta correcta, por tanto, no es qué hago si esto falla, sino para cada proceso que puedo crear, cuál es el conjunto completo de caminos por los que mi programa puede llegar a olvidarse de él. Enumerados sobre el papel, esos caminos son sorprendentemente pocos: el usuario cierra el buffer, sale del editor, lanza otra ejecución encima, el proceso decide no terminar nunca, o su resultado llega a un mundo que ya cambió. Cinco caminos, y cada uno tiene en esta lección su contramedida con nombre: los autocomandos de buffer, VimLeavePre, el kill al anterior, el timeout, la generación. Esa enumeración es la definición operativa de la robustez aquí, y explica por qué su forma canónica en Neovim son los autocomandos: un autocomando es literalmente la materialización de un camino de salida, y registrar uno por camino convierte un problema de vigilancia continua en una lista finita que puedes revisar. La consecuencia de diseño cierra el nivel entero. Cada proceso que lanzas es un recurso con dueño, y la disciplina de propiedad que un lenguaje de sistemas aplica a la memoria es exactamente la que debes aplicar aquí: alguien lo posee, ese alguien tiene un final conocido, y en ese final el recurso se libera. Escribe la limpieza antes que la funcionalidad y no tendrás que salir a cazar huérfanos con el monitor del sistema.
Lo esencial
Tres finales, tres manejos: executable y pcall para el que no arranca, code y stderr para el que falla, signal para el que muere matado —y si signal es 15 con code 124, fuiste tú—. Un hueco por buffer: kill('sigterm') al anterior antes de relanzar, descarta por signal y por generación, y usa changedtick cuando la pregunta sea si el texto cambió. Limpia en BufWipeout y BufDelete, mata en VimLeavePre —no en ExitPre, que aún puede cancelarse— y escala de SIGTERM a SIGKILL solo si hace falta.
Imprime false y el mensaje con su ENOENT: la llamada lanzó, no hubo proceso ni callback. Antepón vim.fn.executable('binario_que_no_existe') y verás el 0 que te habría permitido avisar con un mensaje humano en vez de capturar un error críptico.
A los dos segundos llegan las dos: la primera imprime 1 obsoleto y la segunda 2 vigente. Cada lanzamiento capturó su número en el closure y solo el último coincide con el contador. Acabas de ver el patrón entero en dos líneas.
Lánzalo y teclea algo antes de que venza: buffer cambiado. Repite sin tocar nada: intacto. Es la comprobación exacta del formateador de la 13.4, aislada: la respuesta solo vale si la pregunta sigue siendo la misma.
Para un sleep, SIGTERM basta y is_closing() responde true enseguida. La escalada a kill('sigkill') se reserva para el que ignora la petición: compruébalo antes de disparar, no por sistema.
Un envoltorio que no deja rastro
- Escribe
ejecutarconexecutableypcall, y provoca los tres finales: un binario inexistente, el comandofalse, y unsleeplargo contimeoutcorto. Comprueba que cada uno produce su mensaje distinto. - Implementa el hueco por buffer con
killal anterior y descarte porsignal. Guarda diez veces seguidas y verifica con el monitor del sistema que no se acumulan procesos. - Añade el contador de generación y demuestra que hace falta: comenta el
kill, lanza un análisis lento seguido de uno rápido, y comprueba que sin generación gana el viejo y con ella el nuevo. - Registra la limpieza en
BufWipeout,BufDeleteyVimLeavePre. Cierra un buffer con un proceso en vuelo y luego Neovim entero: nada debe quedar vivo. - Lanza un script que a su vez lance un
sleep 60, mata solo al script y observa con el monitor del sistema que el nieto sobrevive. Explica por qué, y qué papel jugarían el grupo de procesos ydetachen evitarlo. - Junta todo en un
lintarfinal —debounce, kill, generación, limpieza— y úsalo con el linter de la 13.4. Es tu primer envoltorio con ciclo de vida completo: el nivel entero cabe en esa función.