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
E5560que lo delata. - Devolver el control al hilo principal con
vim.scheduley convim.schedule_wrap. - Distinguir qué operaciones sí son seguras dentro de un callback de
libuv, incluidas las funciones marcadasapi-fast. - Elegir entre
vim.schedule,vim.schedule_wrapyvim.defer_fnsegú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.
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.
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.
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.
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.
local function avisar(msg) if vim.in_fast_event() then vim.schedule(function() vim.notify(msg) end) else vim.notify(msg) endendHay 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.
-- 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
- Provoca
E5560deliberadamente con un timer que llame avim.api.nvim_buf_set_linesy lee el mensaje completo; localiza en:h lua-loop-callbacksel párrafo que lo documenta. - Arregla el mismo código dos veces: primero con
vim.scheduleenvolviendo solo la llamada a la API, después convim.schedule_wrapaplicado al entregar el callback. Compara la legibilidad. - Comprueba que
vim.api.nvim_get_mode()funciona dentro del callback sin envoltura alguna, y encuentra en:h api-fastal menos otra función con la misma etiqueta. - 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. - Implementa la función
avisarconvim.in_fast_eventy demuéstrala desde un autocmd del Nivel 9 y desde un callback delibuv, verificando que ninguno de los dos caminos falla. - Encola con
vim.scheduleuna escritura sobre el buffer actual, cierra ese buffer antes de que se ejecute y observa el fallo; añade la revalidación connvim_buf_is_validy comprueba que ahora degrada en silencio.