Saltar al contenido

La regla de oro: vim.schedule y la frontera del fast event

Dentro de un callback de libuv casi toda la API de Neovim está prohibida: es el fast event context y su error E5560. vim.schedule encola de vuelta al hilo principal, vim.schedule_wrap envuelve el callback entero, vim.defer_fn añade el retardo, y vim.in_fast_event detecta dónde estás. Qué sí es seguro hacer al otro lado, las excepciones api-fast, y por qué esta frontera es una garantía y no un obstáculo.

Hay una sola regla en este nivel que, si la olvidas, tu plugin no funcionará mal: reventará. Dentro de un callback de libuv no puedes llamar a la API de Neovim. Ni vim.api, ni vim.fn, ni vim.cmd, ni siquiera un vim.notify inocente. Neovim llama a ese contexto fast event context, y el mensaje que recibes al violarlo es tan característico que acabarás reconociéndolo de un vistazo: E5560. La buena noticia es que la salida es una sola función, vim.schedule, y una vez la interiorizas deja de ser una regla que recordar para convertirse en un reflejo.

  • Reconocer el fast event context y el error E5560 que lo delata.
  • Devolver el control al hilo principal con vim.schedule y con vim.schedule_wrap.
  • Distinguir qué operaciones sí son seguras dentro de un callback de libuv, incluidas las funciones marcadas api-fast.
  • Elegir entre vim.schedule, vim.schedule_wrap y vim.defer_fn según el caso.
  • Detectar el contexto en tiempo de ejecución con vim.in_fast_event.

La frontera prohibida

Un callback de libuv se ejecuta en un punto del bucle en el que Neovim puede estar a medio hacer casi cualquier cosa: procesando una tecla, redibujando, resolviendo una cadena de autocmds del Nivel 9. Su estado interno no está necesariamente en un punto consistente, y por eso el núcleo bloquea el acceso. La documentación lo enuncia sin rodeos en :h lua-loop-callbacks: es un error invocar funciones de vim.api —salvo las marcadas api-fast— dentro de callbacks de vim.uv.

LUA
local uv = vim.uvlocal t = uv.new_timer()t:start(100, 0, function()  vim.api.nvim_buf_set_lines(0, 0, -1, false, { "hola" })  -- E5560: nvim_buf_set_lines must not be called in a fast event contextend)

El error no es un aviso ni un consejo: es una comprobación explícita del núcleo antes de ejecutar la función. Y la variante con vim.fn es peor, no mejor: las funciones de Vimscript no tienen esa comprobación uniforme, así que a veces parecen funcionar durante una demo y fallan en producción con un mensaje incomprensible, porque el estado interno era el adecuado por casualidad. Ya te avisó de esto la 8.1 cuando presentó las capas de la API; esta lección es la explicación completa de aquel aviso.

El síntoma tiene además una dificultad añadida: el error se produce dentro de un callback de libuv, es decir, lejos del código que lo registró. La traza no apunta a la función que creó el timer ni al autocmd que la disparó, sino a una línea suelta sin contexto. Por eso los primeros encuentros con E5560 se viven como un error espontáneo que aparece de la nada; en realidad aparece exactamente donde debe, solo que el punto de la culpa y el punto del fallo están separados en el tiempo.

No todas las llamadas prohibidas gritan igual

La comprobación cubre la API pública, pero no todo lo que la rodea. Llamar a una función de tu propio módulo que internamente usa vim.fn no dispara ninguna alarma en el borde: el error saldrá dos niveles más adentro, con un nombre de función que no reconoces. Cuando escribas utilidades reutilizables, decide y documenta en qué contexto pueden llamarse — o hazlas anfibias con vim.in_fast_event, como verás más abajo.

vim.schedule, vim.schedule_wrap y vim.defer_fn

vim.schedule recibe una función y la encola para que Neovim la ejecute más tarde, en el hilo principal y en un momento en el que el estado sí es consistente. No la ejecuta ahora; la aplaza. Con eso basta.

LUA
t:start(100, 0, function()  local calculo = 2 + 2            -- Lua puro: seguro aquí  vim.schedule(function()    vim.api.nvim_buf_set_lines(0, 0, -1, false, { "resultado " .. calculo })  end)end)

Fíjate en que el closure captura calculo sin problema: la función aplazada sigue viendo el entorno donde se creó, por el mecanismo de upvalues de la 7.3. Ese es el patrón fundamental —calcula en el callback, aplica en el schedule— y cubre el noventa por ciento de los casos.

Cuando lo que quieres es que toda la función viva en contexto seguro, vim.schedule_wrap te devuelve una versión envuelta que puedes pasar directamente donde se esperaba el callback. La envoltura, además, reenvía sus argumentos: la función devuelta los pasa intactos a la tuya, lo que la hace perfecta para callbacks que reciben datos, como los on_exit de proceso.

LUA
t:start(100, 0, vim.schedule_wrap(function()  vim.api.nvim_buf_set_lines(0, 0, -1, false, { "todo el cuerpo es seguro" })  vim.notify("y esto también")end))

Existe además un pariente cercano que conviene no confundir. vim.defer_fn(fn, ms) combina un timer de un solo disparo con la envoltura de schedule_wrap: ejecuta en contexto seguro y tras un retardo, y te devuelve el timer de vim.uv por si quieres cancelarlo con close antes de que venza. vim.schedule no espera nada, solo aplaza al primer hueco disponible.

Función Contexto seguro Retardo Devuelve
vim.schedule(fn) sí ninguno, primer hueco libre nada
vim.schedule_wrap(fn) sí ninguno; devuelve función que reenvía argumentos la función envuelta
vim.defer_fn(fn, ms) sí los milisegundos indicados el timer, cancelable

Usar vim.defer_fn(fn, 0) como sustituto de vim.schedule funciona, pero crea y destruye un timer en cada llamada para no ganar nada. En un callback que se dispara cientos de veces por segundo, esa diferencia sí se nota.

Envuelve en el borde, no en el interior

La costumbre más limpia es aplicar vim.schedule_wrap justo donde entregas el callback a libuv, no dispersar vim.schedule por dentro. Así el borde entre los dos mundos queda visible en una sola línea, y cualquiera que lea tu código sabe de inmediato en qué contexto está el cuerpo de la función. Reserva el vim.schedule interno para cuando de verdad quieras repartir: cálculo pesado fuera, aplicación mínima dentro.

Qué sí puedes hacer dentro del callback

La frontera prohíbe la API del editor, no Lua. Dentro de un callback de libuv sigues teniendo a tu disposición todo lo que no toca el estado del editor, y aprovecharlo es lo que hace que el patrón sea eficiente en lugar de un simple rodeo burocrático.

Operación ¿Permitida en fast event?
Lua puro: tablas, cadenas, aritmética sí
Otras llamadas a vim.uv sí
vim.schedule y vim.schedule_wrap sí
vim.inspect, vim.split, vim.tbl_map, print sí
Funciones de la API marcadas api-fast sí
vim.rpcnotify sí
El resto de vim.api, y todo vim.fn no
vim.cmd, vim.notify, vim.opt no

La lista de lo permitido no es arbitraria: todo comparte una propiedad, no muta ni consulta estado del editor que pueda estar a medias. vim.split manipula cadenas, vim.tbl_map recorre tablas, vim.uv habla con el sistema operativo. Esa es la regla de fondo, y sirve para clasificar cualquier función que no aparezca en la tabla: si necesita un estado consistente del editor para responder, está prohibida.

La fila de api-fast merece detenerse. Un puñado de funciones de la API están diseñadas para ser llamadas desde cualquier contexto, y la documentación las marca con la etiqueta api-fast —búscala en :h api-fast, con la técnica de lectura de ayuda de la 8.4—. Son funciones que solo leen datos que siempre son coherentes o que encolan en vez de actuar: nvim_get_mode() te dice el modo actual sin tocar nada, y nvim_input() no ejecuta las teclas, las deposita en la cola de entrada para que se procesen por el cauce normal. Si una función que necesitas está en esa lista, puedes ahorrarte el schedule.

LUA
t:start(100, 0, function()  -- Legal sin schedule: nvim_get_mode está marcada api-fast  local modo = vim.api.nvim_get_mode().mode  if modo == "i" then return end   -- en inserción, ni molestamos  vim.schedule(function() vim.notify("listo") end)end)

Esto significa que el trabajo pesado de procesar datos —parsear la salida de un proceso, filtrar miles de líneas, construir la estructura final— debe ocurrir dentro del callback, y solo el resultado ya masticado cruza la frontera. Si aplazas todo con vim.schedule, has movido el coste al hilo principal y estás congelando el editor exactamente igual que antes. Este reparto será tu pan de cada día en el Nivel 13: el callback on_exit de vim.system corre en fast event, y ahí parsearás; en el schedule, pintarás.

Si escribes una función que puede ser llamada desde ambos lados, vim.in_fast_event() te dice dónde estás y te permite adaptarte sin duplicar código.

LUA
local function avisar(msg)  if vim.in_fast_event() then    vim.schedule(function() vim.notify(msg) end)  else    vim.notify(msg)  endend

Hay dos matices que conviene tener presentes al usar vim.schedule. El primero es que no garantiza inmediatez: tu función entra en una cola que se vacía cuando el editor está en reposo, así que si el usuario mantiene una tecla pulsada puede tardar más de lo que esperabas. El segundo es que el orden se respeta —lo encolado primero se ejecuta primero— pero el contexto puede haber cambiado por completo entre el encolado y la ejecución.

LUA
-- Toda función aplazada debe revalidar sus supuestosvim.schedule(function()  if not vim.api.nvim_buf_is_valid(bufnr) then return end  if vim.api.nvim_get_current_buf() ~= bufnr then return end  vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lineas)end)

Esa doble comprobación parece defensiva de más hasta el día en que un usuario cierra el buffer mientras tu trabajo estaba en vuelo y tu plugin escribe en un identificador que ya no existe. Aplazar es viajar al futuro, y el futuro no está obligado a parecerse al presente. Volverás sobre esta idea en la 12.4, porque debounce la agrava: allí el viaje no es «al primer hueco», sino trescientos milisegundos más lejos.

La frontera no te limita: te informa de dónde estás

Es tentador leer E5560 como un obstáculo que Neovim te pone y vim.schedule como el conjuro para esquivarlo. Esa lectura es exactamente la contraria a la verdad. Recuerda de la 12.1 que Neovim no tiene candados porque un solo hilo posee todo el estado; esa ausencia de sincronización solo es segura si además existe la garantía de que ningún código toca el estado en un instante en que ese estado no es consistente. Un callback de libuv se dispara en mitad de una vuelta del bucle, y en mitad de una vuelta el editor puede estar a medio aplicar un cambio de buffer, a medio resolver una cadena de autocmds o a medio calcular un redibujado. Permitir que ahí dentro llames a nvim_buf_set_lines no produciría un error diagnosticable: produciría corrupción, y de la peor especie, la que aparece una vez cada mil ejecuciones y no se reproduce jamás. E5560 no es una prohibición arbitraria, es la conversión de ese fallo indetectable en un error inmediato, ruidoso y con nombre propio. Y vim.schedule no es un truco para saltarse la norma sino la implementación literal del contrato: encola tu función en la misma cola por la que pasan las teclas y los eventos, de modo que se ejecutará cuando el editor esté en reposo, entre operaciones y no dentro de una. Las funciones api-fast confirman la lectura en vez de contradecirla: son exactamente aquellas que el núcleo puede responder sin estado a medias, y por eso, y solo por eso, cruzan la frontera. Visto así, la regla de oro deja de ser una regla y se convierte en una descripción: hay dos contextos, uno para calcular y otro para aplicar, y vim.schedule es el puente unidireccional entre ellos. El día que este reparto te resulte obvio, habrás dejado de escribir Lua para Neovim y habrás empezado a escribir Neovim.

Drills

E5560: nvim_buf_set_lines must not be called in a fast event context. Fíjate en que el error llega ~100 ms después del comando, desde «ninguna parte»: el punto de la culpa (registrar el callback) y el punto del fallo (ejecutarlo) están separados en el tiempo. Así se ve siempre este error en la vida real.

false inmediato —un comando :lua corre en el hilo principal, contexto seguro— y true cien milisegundos después, desde dentro del callback. La misma sesión, dos mundos. print, por cierto, es de las pocas que funcionan en ambos.

La misma llamada, ahora legal: schedule_wrap aplaza el cuerpo entero al hilo principal. Compara con el drill del error: la única diferencia es una función envolviendo a otra, aplicada justo donde el callback se entrega a libuv.

vim.defer_fn es el timer de un solo disparo ya envuelto: crea el handle, aplica schedule_wrap y lo cierra tras disparar. Devuelve el timer por si te arrepientes: guárdalo y llama a :close() antes de que venza para cancelarlo.

Cruza la frontera con criterio

  1. Provoca E5560 deliberadamente con un timer que llame a vim.api.nvim_buf_set_lines y lee el mensaje completo; localiza en :h lua-loop-callbacks el párrafo que lo documenta.
  2. Arregla el mismo código dos veces: primero con vim.schedule envolviendo solo la llamada a la API, después con vim.schedule_wrap aplicado al entregar el callback. Compara la legibilidad.
  3. Comprueba que vim.api.nvim_get_mode() funciona dentro del callback sin envoltura alguna, y encuentra en :h api-fast al menos otra función con la misma etiqueta.
  4. Escribe un timer que reciba una lista de mil cadenas, la filtre y ordene en el callback, y solo aplace la escritura del resultado; mide con vim.uv.hrtime() cuánto trabajo dejaste fuera del hilo principal.
  5. Implementa la función avisar con vim.in_fast_event y demuéstrala desde un autocmd del Nivel 9 y desde un callback de libuv, verificando que ninguno de los dos caminos falla.
  6. Encola con vim.schedule una escritura sobre el buffer actual, cierra ese buffer antes de que se ejecute y observa el fallo; añade la revalidación con nvim_buf_is_valid y comprueba que ahora degrada en silencio.