wandres.dev
EL EVENT LOOP · libuv y vim.uv

vim.uv: timers, repeticiones y el ciclo de vida de un handle

vim.uv es libuv expuesto a Lua sin adornos. La puerta de entrada natural es el timer, que sirve además como modelo completo del concepto de handle: un objeto vivo dentro del bucle con estados propios. Init, start, stop, close, la diferencia entre parar y cerrar, el contador de referencias que mantiene vivo el proceso, y las fugas de handles que nadie nota hasta que sobran cien.

⏱ 19 min

vim.uv no es una abstracción amable sobre libuv: es libuv casi en crudo, con sus nombres en C y su semántica exacta. Esa crudeza es una ventaja, porque significa que la documentación de libuv te sirve tal cual. La puerta de entrada es el timer, el handle más simple que existe, y sin embargo suficiente para aprender el concepto que gobierna todos los demás: un handle es un objeto vivo dentro del bucle, con un ciclo de vida propio que tú abres y tú tienes que cerrar.

🎯 Al terminar esta lección sabrás
  • Usar vim.uv y conocer su relación con el antiguo vim.loop.
  • Crear timers de un solo disparo y timers repetidos, y ajustarlos en caliente.
  • Recorrer el ciclo de vida completo de un handle: crear, arrancar, parar y cerrar.
  • Entender el contador de referencias del bucle y cuándo conviene usar unref.

De vim.loop a vim.uv

Durante años la API se llamó vim.loop. Desde Neovim 0.10 el nombre canónico es vim.uv, y vim.loop queda como alias obsoleto que sigue funcionando pero que ya no debes escribir en código nuevo. En un plugin que quiera soportar versiones antiguas, la línea de compatibilidad es de una sola expresión.

local uv = vim.uv or vim.loop
print(uv.version_string())   -- por ejemplo 1.48.0
print(uv.os_uname().sysname) -- Darwin, Linux, Windows_NT

Lo que hay debajo de uv es la biblioteca completa: timers, procesos, sockets TCP y de dominio Unix, tuberías, vigilancia de ficheros, entrada y salida sobre descriptores, DNS, información del sistema. Neovim envuelve algunas de esas piezas en APIs más cómodas —vim.system para procesos, vim.fs para rutas—, pero por debajo siempre está vim.uv, y cuando necesites algo que ninguna envoltura cubre, bajarás aquí.

Conviene separar desde el principio dos familias de objetos que libuv trata de forma distinta, porque la confusión entre ambas es la fuente de la mayoría de los errores de gestión de recursos.

🔄

Handles

Objetos de vida larga registrados en el bucle: timer, process, tcp, pipe, fs_event, async, signal. Se abren, se arrancan, se paran y hay que cerrarlos.

📨

Requests

Operaciones puntuales que nacen y mueren con su callback: fs_open, fs_read, getaddrinfo. No se cierran porque no sobreviven a su resultado.

Todo lo que aprendas sobre el ciclo de vida de un timer se transfiere íntegro a cualquier otro handle. Un fs_event que vigila un directorio se abre, se arranca con una ruta, se para y se cierra exactamente igual; lo único que cambia es la forma del callback. Por eso vale la pena estudiar el timer despacio: no es un caso particular, es el patrón entero en su versión más pequeña.

Un timer es un handle

Un handle es un objeto de larga vida registrado en el bucle: mientras exista y esté activo, el bucle sabe de él y puede llamarlo. Crearlo no lo pone en marcha; son dos pasos distintos y confundirlos es el primer error clásico.

local uv = vim.uv

local t = uv.new_timer()          -- crea el handle, aun inactivo
t:start(1000, 0, function()       -- retardo 1000 ms, repeticion 0 = una sola vez
  print("una vez, un segundo despues")
  t:stop()
  t:close()                       -- imprescindible: libera el handle
end)

Los dos primeros argumentos de start son el retardo inicial y el intervalo de repetición, ambos en milisegundos. Un intervalo de cero significa disparo único. Cualquier otro valor convierte el timer en periódico, y entonces el callback volverá indefinidamente hasta que alguien lo pare.

local t = uv.new_timer()
local quedan = 5

t:start(0, 500, function()        -- ahora mismo y luego cada 500 ms
  quedan = quedan - 1
  if quedan == 0 then
    t:stop()
    t:close()
  end
end)

En caliente puedes consultar y reajustar el timer sin recrearlo: t:get_due_in() dice cuántos milisegundos faltan para el próximo disparo, t:set_repeat(ms) cambia el intervalo a partir del siguiente ciclo, y t:again() reinicia la cuenta usando el intervalo vigente. Ese again es la primitiva exacta sobre la que se construye un debounce.

⚠️
Un handle sin cerrar es una fuga silenciosa

stop detiene los disparos pero no libera el handle: sigue registrado en el bucle, sigue ocupando memoria y sigue contando como referencia viva. La disciplina correcta es siempre stop y después close. Como close sobre un handle ya cerrado lanza un error, protégete con if not t:is_closing() then t:close() end.

El ciclo de vida completo

Todo handle de libuv recorre los mismos cuatro estados, y vim.uv ofrece predicados para interrogarlos en cualquier momento. Interiorizar esta tabla te ahorra la mayoría de los errores de este nivel.

Estado Cómo se entra Cómo se comprueba
creado uv.new_timer() existe, pero is_active es falso
activo t:start(...) t:is_active() devuelve verdadero
parado t:stop() is_active falso, is_closing falso
cerrado t:close() t:is_closing() devuelve verdadero
stateDiagram-v2
[*] --> Creado: new_timer
Creado --> Activo: start
Activo --> Activo: callback repetido
Activo --> Parado: stop
Parado --> Activo: start o again
Activo --> Cerrado: close
Parado --> Cerrado: close
Cerrado --> [*]: memoria liberada

La transición que más se olvida es la última. Un plugin que crea un timer por evento y nunca cierra ninguno acumula handles a un ritmo constante; nada falla, nada avisa, y el proceso crece durante horas. :lua print(vim.inspect(vim.uv.uptime())) no te lo dirá, pero un recuento manual de los timers que tu módulo mantiene vivos sí. La regla operativa es sencilla: quien crea un handle es responsable de cerrarlo, y ese cierre debe existir también en los caminos de error.

Referencias: qué mantiene vivo al bucle

libuv lleva la cuenta de cuántos handles activos hay. Mientras ese contador sea mayor que cero, el bucle tiene razones para seguir girando. En un programa normal esto decide cuándo termina el proceso; en Neovim el editor no acaba por esto, pero el mecanismo sigue ahí y a veces importa.

local t = uv.new_timer()
t:start(0, 1000, function() end)
t:unref()   -- sigue funcionando, pero ya no cuenta para mantener vivo el bucle
-- t:ref()  -- lo vuelve a contar

Un timer de fondo puramente decorativo —refrescar un indicador de estado, por ejemplo— es un candidato razonable para unref: quieres que dispare mientras haya algo que hacer, pero no que sea la razón por la que algo sigue en pie. Un timer que sostiene una operación pendiente, en cambio, jamás debe desreferenciarse: si lo haces, el bucle puede decidir que ya no queda trabajo relevante justo cuando tu operación estaba a medias.

Con todo esto, el módulo bien escrito tiene una forma reconocible: guarda sus handles en una tabla, ofrece una función de limpieza y la engancha a VimLeavePre para no dejar nada abierto al salir.

local M = { timers = {} }

function M.programar(ms, fn)
  local t = uv.new_timer()
  M.timers[#M.timers + 1] = t
  t:start(ms, 0, vim.schedule_wrap(fn))
  return t
end

function M.limpiar()
  for _, t in ipairs(M.timers) do
    if not t:is_closing() then
      t:stop()
      t:close()
    end
  end
  M.timers = {}
end

vim.api.nvim_create_autocmd("VimLeavePre", { callback = M.limpiar })

Ese is_closing antes de cerrar no es paranoia: en un plugin real la limpieza puede dispararse dos veces —al recargar la configuración y al salir— y el segundo close sobre el mismo handle aborta con un error que, encima, ocurre en un momento en que ya nadie está mirando la pantalla.

El handle es un objeto con dos dueños, y ahí está toda la dificultad

Cuesta ver por qué un simple timer necesita cuatro estados y dos verbos distintos para apagarlo, hasta que reparas en que un handle pertenece simultáneamente a dos mundos. Del lado de Lua es una tabla con métodos, sujeta al recolector de basura, que desaparecerá cuando nadie la referencie. Del lado de libuv es una estructura de C registrada en una lista intrusiva del bucle, con memoria reservada que solo se libera en la fase close de una vuelta futura —no en el instante en que llamas a close, sino después, cuando al bucle le toque—. Esos dos ciclos de vida son independientes y no se hablan: el recolector de Lua no sabe nada de la lista de libuv, y libuv no sabe nada de tus referencias. Por eso perder la variable Lua que apunta a un timer activo no lo detiene ni lo libera; simplemente pierdes la capacidad de pararlo mientras sigue disparando para siempre. Y por eso close es asíncrono y admite su propio callback: en el momento en que lo pides, libuv puede tener el handle a medio procesar en la vuelta actual, y liberar su memoria en ese instante sería un uso después de liberar en toda regla. stop y close no son redundantes: stop habla de comportamiento —deja de llamarme— y close habla de existencia —déjame de existir—, y solo el segundo devuelve recursos. Entender esta doble propiedad convierte una lista de reglas memorizadas en una consecuencia obvia, y es exactamente el mismo razonamiento que aplicarás a procesos, sockets y vigilantes de ficheros, porque todos son handles y todos viven así.

⚔️ Domina el ciclo de vida
  1. Crea un timer de un solo disparo a 2 segundos que imprima algo, y ciérralo correctamente dentro de su propio callback.
  2. Escribe un timer repetido cada 300 milisegundos que se autodetenga tras diez disparos y verifica con is_active e is_closing en qué estado queda.
  3. Arranca un timer repetido sin guardar la referencia en ninguna variable, observa que sigue disparando y explica por qué el recolector de Lua no lo detiene.
  4. Usa get_due_in y set_repeat para acelerar un timer en marcha sin recrearlo, y comprueba cuándo entra en vigor el nuevo intervalo.
  5. Compara el efecto de llamar solo a stop frente a stop seguido de close creando cien timers en bucle, y razona qué recurso se acumula en el primer caso.