vim.uv: timers, repeticiones y el ciclo de vida de un handle
vim.uv es libuv expuesto a Lua sin adornos, y el timer es su puerta de entrada: el handle más pequeño que existe y aun así el modelo completo del concepto. Crear, arrancar, parar y cerrar; la diferencia entre stop y close; again y set_repeat para ajustar en caliente; las referencias que mantienen vivo el bucle; y las fugas de handles que nadie nota hasta que sobran cien.
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 :h luvref.txt y la documentación de libuv te sirven 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.
- Usar
vim.uvconociendo su relación con el alias obsoletovim.loop. - Crear timers de un solo disparo y timers repetidos, y ajustarlos en caliente con
set_repeatyagain. - Recorrer el ciclo de vida completo de un handle: crear, arrancar, parar y cerrar, y saber por qué
closees asíncrono. - Distinguir handles de requests, y entender qué mantiene «con razones para girar» al bucle.
- Reconocer y prevenir una fuga de handles antes de que se acumulen.
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 una sola expresión.
local uv = vim.uv or vim.loopprint(uv.version_string()) -- por ejemplo 1.51.0print(uv.os_uname().sysname) -- Darwin, Linux, Windows_NTLo que hay debajo de uv es la biblioteca completa: timers, procesos, sockets TCP y de dominio Unix, tuberías, vigilancia de ficheros, DNS, información del sistema. Neovim envuelve algunas de esas piezas en APIs más cómodas —vim.system para procesos, que verás en la 13.1; vim.fs para rutas—, pero por debajo siempre está vim.uv, y cuando necesites algo que ninguna envoltura cubre, bajarás aquí.
Casi todas sus funciones tienen dos formas equivalentes: la de función, uv.timer_start(t, ...), con el nombre heredado de C, y la de método, t:start(...). Son la misma llamada; en este curso usamos la de método, pero en luvref.txt las encontrarás documentadas por su nombre de función.
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
Crear un handle no lo pone en marcha; son dos pasos distintos, y confundirlos es el primer error clásico.
local uv = vim.uvlocal t = uv.new_timer() -- crea el handle, aún inactivot:start(1000, 0, function() -- retardo 1000 ms, repetición 0 = un disparo print("una vez, un segundo después") t:stop() t:close() -- imprescindible: libera el handleend)Los dos primeros argumentos de start son el retardo inicial y el intervalo de repetición, ambos en milisegundos. Con repetición cero, el timer dispara una vez y queda inactivo. Con cualquier otro valor se vuelve periódico: dispara primero tras el retardo inicial y después cada intervalo, indefinidamente, hasta que alguien lo pare. Y hay un caso especial útil: un retardo inicial de cero no significa «ahora mismo», significa «en la siguiente vuelta del bucle» —no existe el disparo síncrono, porque los callbacks solo corren cuando el bucle pasa por su fase timers, como viste en la 12.1—.
local t = uv.new_timer()local quedan = 5t:start(0, 500, function() -- siguiente vuelta, y luego cada 500 ms quedan = quedan - 1 if quedan == 0 then t:stop() t:close() endend)Fíjate en que quedan es un upvalue capturado por el callback, exactamente el mecanismo de la 7.3: el estado del timer vive en la closure, no en el handle. En caliente puedes consultar y reajustar el timer sin recrearlo: t:get_due_in() dice cuántos milisegundos faltan para el próximo disparo —relativo a uv.now(), y 0 si ya venció—, t:get_repeat() devuelve el intervalo vigente, t:set_repeat(ms) lo cambia a partir del siguiente vencimiento —no toca el plazo en curso—, y t:again() detiene el timer y lo rearranca usando el intervalo de repetición como retardo. Ese again tiene una condición: si el timer no se ha arrancado nunca antes, falla con EINVAL, porque no hay intervalo del que tirar.
Un handle sin cerrar es una fuga silenciosa
stop detiene los disparos pero no libera el handle: sigue registrado en el bucle y sigue ocupando memoria, suya y de todo lo que su callback capture como upvalue. La disciplina correcta es stop y después close. Y 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 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 |
| cerrándose | t:close() |
t:is_closing() devuelve verdadero |
| liberado | fase close de una vuelta futura |
su callback de cierre, si lo diste, ya corrió |
La fila que más se malinterpreta es la última: close no libera nada en el momento. Es una petición que libuv atiende en la fase close de una vuelta futura del bucle, y por eso acepta su propio callback —el sitio correcto para liberar recursos asociados—. El porqué está en la naturaleza doble del handle, y lo verás en el callout final de la lección.
La transición que más se olvida es el propio close. Un plugin que crea un timer por evento y nunca cierra ninguno acumula handles a ritmo constante; nada falla, nada avisa, y el proceso crece durante horas. El censo de uv.walk que hiciste en la 12.1 es aquí tu detector de humo: si el número de handles de tipo timer crece sin techo mientras usas un plugin, ya sabes qué está pasando. 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 al bucle con trabajo
libuv distingue los handles que cuentan —los que dan al bucle razones para seguir girando— de los que no. No es un contador: cada handle tiene una marca de referenciado que se activa y desactiva de forma idempotente con ref y unref; llamar dos veces a cualquiera de los dos no acumula nada. Todo handle activo nace referenciado. En un programa luv normal, esto decide cuándo termina el proceso: el bucle se apaga al quedarse sin handles activos referenciados. Dentro de Neovim el editor no se cierra por esto, pero la marca sigue existiendo y a veces importa —por ejemplo, al decidir si algo tuyo debe retrasar la salida del editor—.
local t = uv.new_timer()t:start(0, 1000, function() end)t:unref() -- sigue disparando, pero ya no es razón para mantener nada en pie-- t:ref() -- lo vuelve a contar; ambas son idempotentesUn 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.
Con todo esto, el módulo bien escrito tiene una forma reconocible: guarda sus handles en una tabla, ofrece una función de limpieza idempotente y la engancha a VimLeavePre —el autocmd que ya conoces del Nivel 9— 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 tendfunction M.limpiar() for _, t in ipairs(M.timers) do if not t:is_closing() then t:stop() t:close() end end M.timers = {}endvim.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 vim.schedule_wrap que envuelve el callback es un anticipo directo de la 12.3: sin él, fn correría en un contexto donde casi toda la API está prohibida.
El handle es un objeto con dos dueños, y ahí está toda la dificultad
Cuesta ver por qué un simple timer necesita tantos 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 un userdata con métodos, sujeto al recolector de basura, que desaparecerá de tu vista cuando nadie lo referencie. Del lado de libuv es una estructura de C registrada en las listas internas 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 las listas 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 de manual. stop y close no son redundantes: stop habla de comportamiento —deja de llamarme— y close habla de existencia —deja de existir—, y solo el segundo devuelve recursos. Este problema, el de un objeto cuya vida gobiernan dos gestores de memoria que se ignoran mutuamente, es uno de los clásicos de todo lenguaje con FFI: lo tienen los bindings de Python sobre C, los objetos de JavaScript que envuelven recursos del navegador, los punteros de Rust cruzando a C. La solución es siempre la misma y siempre manual: un protocolo explícito de cierre que el lado del recolector se compromete a invocar. 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í.
Drills
El ciclo completo en una línea: crear, arrancar, y dentro del propio callback, parar y cerrar. Como es de un solo disparo, el mejor sitio para el cierre es el callback mismo: nadie más va a necesitar el handle.
true y los milisegundos que faltan, relativos a uv.now(). Ejecuta la segunda parte varias veces y observa la cuenta atrás. Termina con :lua T:stop() T:close() — el drill no está completo hasta que el handle muere.
El segundo close explota: un handle en estado closing no admite ninguna operación más. La defensa es preguntar antes: if not t:is_closing() then t:close() end. En una función de limpieza que puede correr dos veces, ese if es la diferencia entre salir limpio y salir con un error.
Cien timers que se paran pero nunca se cierran: el censo los muestra todos, vivos y ocupando memoria, mucho después de su último disparo. Cambia el t:stop() por t:close() y repite: el número vuelve a bajar. Acabas de ver la diferencia entre parar y cerrar con tus propios ojos.
Domina el ciclo de vida
- Crea un timer de un solo disparo a 2 segundos que imprima algo, y ciérralo correctamente dentro de su propio callback.
- Escribe un timer repetido cada 300 milisegundos que se autodetenga tras diez disparos, y verifica con
is_activeeis_closingen qué estado queda tras elstopy tras elclose. - 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 ni podría hacerlo.
- Usa
get_due_in,get_repeatyset_repeatpara acelerar un timer en marcha sin recrearlo, y comprueba que el nuevo intervalo entra en vigor en el siguiente vencimiento, no en el actual. - Comprueba el
EINVALdeagainllamándolo sobre un timer recién creado que nunca se arrancó; arráncalo, páralo y verifica que ahoraagainsí lo revive. - Reproduce el experimento de la fuga: cien timers con
stopsinclose, censo conuv.walk, y la versión corregida. Escribe después la funciónlimpiarde esta lección en un módulo tuyo y engánchala aVimLeavePre.