Saltar al contenido

Streaming: leer la salida según llega

Consumir stdout en tiempo real en lugar de esperar al final: el callback por trozos de vim.system, el reensamblado de líneas partidas con un buffer de resto, el cruce seguro al hilo del editor con vim.schedule agrupado por lotes, y el patrón de progreso que no congela nada.

Recoger la salida entera al final es cómodo y es mentira. Una compilación de tres minutos ya emitió su primer error en el segundo cuatro, y tenerlo retenido en un búfer invisible hasta que el proceso muera no te ayuda: te frustra. El streaming invierte la relación. En vez de preguntar al final, escuchas mientras ocurre, y cada línea que llega es información utilizable de inmediato.

  • Registrar un callback de stdout que reciba la salida por trozos.
  • Reensamblar líneas completas a partir de fragmentos arbitrarios, con un buffer de resto.
  • Aplazar con vim.schedule todo contacto con buffers y ventanas, agrupado por lotes.
  • Reconocer el búfer del propio hijo como la causa de que «no llegue nada».
  • Construir un indicador de progreso que no congele el editor.

Trozos, no líneas

La opción stdout de vim.system acepta una función. Cuando la pasas, Neovim deja de acumular la salida —el campo stdout del resultado final pasa a ser nil— y te la entrega según sale de la tubería:

LUA
vim.system({ 'cargo', 'build' }, {  text = true,  stdout = function(err, chunk)    if err then return end    if chunk == nil then return end   -- fin de la tubería    -- chunk es un trozo cualquiera, no una línea  end,}, on_exit)

El contrato tiene dos avisos que hay que leer despacio. El primero: chunk vale nil exactamente una vez, cuando la tubería se cierra. Ese es tu EOF, y es donde te toca vaciar lo que quedara pendiente. El segundo, más traicionero: un trozo no es una línea. El kernel entrega lo que tiene cuando lo tiene. Puedes recibir tres líneas juntas, o media línea, o un solo carácter, y nada garantiza que un trozo empiece o acabe en un salto de línea.

Esa es la diferencia esencial con la API antigua. jobstart con on_stdout sí te entrega una lista de líneas, pero con una trampa documentada y olvidada por todos: el primer y el último elemento pueden ser fragmentos incompletos que debes concatenar entre invocaciones —la propia ayuda enseña abc\nefg llegando como ['abc', ''] y luego ['efg'], o incluso como ['ab'] y ['c', 'efg']—. Ambas APIs te obligan al mismo trabajo; vim.system simplemente es honesta sobre ello.

Un trozo puede partir un carácter

Si no usas text = true, los trozos son bytes crudos y el corte puede caer dentro de un carácter multibyte. Un acento partido en dos entregas se convierte en dos bytes inválidos si los procesas por separado. El reensamblado por líneas resuelve esto de paso, porque no interpreta nada hasta tener el salto de línea completo.

El reensamblador

El patrón es siempre el mismo y merece la pena escribirlo una vez bien: guardas un resto pendiente, le pegas cada trozo nuevo y extraes del acumulado todas las líneas completas que haya. La variable pendiente vive capturada en un closure —el mecanismo de la lección 7.3—, así que cada lector tiene su propio resto sin estado global de por medio:

LUA
local function lector_de_lineas(cuando_haya_linea)  local pendiente = ''  return function(err, chunk)    if err then return end    if chunk == nil then      if pendiente ~= '' then cuando_haya_linea(pendiente) end      return    end    pendiente = pendiente .. chunk    while true do      local corte = pendiente:find('\n', 1, true)      if not corte then break end      local linea = pendiente:sub(1, corte - 1):gsub('\r$', '')      pendiente = pendiente:sub(corte + 1)      cuando_haya_linea(linea)    end  endend

Tres detalles justifican cada línea. find con el tercer argumento en true busca literal, sin coste de patrón. El gsub final absorbe el retorno de carro que dejan las herramientas de Windows y los emuladores de terminal. Y la rama de chunk == nil es imprescindible: muchos programas no terminan su última línea con salto, y sin ese vaciado perderías precisamente el mensaje final, que suele ser el resumen.

Cruzar al hilo del editor

El callback de stdout corre en contexto rápido. Ahí no puedes crear buffers, ni escribir líneas, ni mover el cursor: casi toda la API está vetada porque el editor podría estar a mitad de una operación. vim.schedule encola tu función para el siguiente momento seguro del bucle principal, exactamente como fijaste en la lección 12.3.

La tentación es envolver cada línea en su propio vim.schedule. Funciona, y es un error de rendimiento serio: un comando que emite cinco mil líneas encolaría cinco mil tareas y cada una repintaría la pantalla. La solución es agrupar: acumula en una tabla desde el contexto rápido y vuelca por lotes.

LUA
local cola, programado = {}, falselocal function encolar(linea)  cola[#cola + 1] = linea  if programado then return end  programado = true  vim.schedule(function()    programado = false    local lote = cola    cola = {}    vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, lote)  end)end

Este es un debounce natural, primo del que construiste con temporizadores en la lección 12.4 pero sin temporizador: mientras el volcado no se ejecute, las líneas nuevas se suman al mismo lote. Con salida lenta se comporta como línea a línea; con salida torrencial, colapsa miles de eventos en un puñado de escrituras. El editor sigue fluido en ambos extremos sin que tú ajustes nada.

Escribe en un buffer scratch, no en uno real

Crea el destino con vim.api.nvim_create_buf(false, true). Un buffer scratch no pide guardar, no dispara autocomandos de escritura y no contamina la lista de buffers. Si además fijas nvim_buf_set_lines con índices -1, -1 estás anexando, que es la única operación barata cuando el buffer crece.

Cuando «no llega nada» hasta el final, el culpable es el hijo

Hay un caso que parece refutar toda la lección: registras tu callback, el proceso corre, y no llega ni un trozo hasta que muere. No es tu código. Casi todos los programas, al detectar que su salida es una tubería y no una terminal, cambian de búfer de línea a búfer de bloque —cuatro u ocho kilobytes— y no escriben nada hasta llenarlo. La ayuda de Neovim cita el caso de ruby -e, y a Python le pasa igual sin -u.

Las salidas son de la herramienta, no tuyas: banderas como python -u o stdbuf -oL, o variables como PYTHONUNBUFFERED=1 en env. Y la alternativa estructural es darle al hijo lo que quiere: una pseudoterminal. Ese es exactamente el terreno de la lección siguiente.

Progreso que se siente

Con las tres piezas montadas —lector de líneas, agrupación y buffer scratch— un indicador de progreso es casi gratis. Lo importante no es la animación sino qué información destilas de la corriente. Un contador de líneas es ruido; un contador de errores encontrados es señal.

LUA
local errores = 0local total = 0local function on_linea(linea)  total = total + 1  if linea:match('^error') then errores = errores + 1 end  encolar(linea)end

La misma corriente alimenta dos consumidores con vidas distintas: el buffer, que quiere el texto íntegro para consultarlo después, y el contador, que solo quiere un resumen vivo en la línea de estado. Separar transporte de interpretación es lo que permite que más tarde puedas añadir un tercer consumidor —la quickfix, en la lección 13.4— sin tocar nada de lo anterior.

Queda un detalle de presentación que casi nadie resuelve bien: el desplazamiento automático. Si el usuario está mirando el buffer, quieres que siga el final; si se ha ido a leer un error de más arriba, arrastrarle el cursor cada cien milisegundos es intolerable. La condición correcta no es un ajuste, es una pregunta sobre dónde está el cursor:

LUA
local function anexar(bufnr, lote)  local win = vim.fn.bufwinid(bufnr)  local pegado = win ~= -1    and vim.api.nvim_win_get_cursor(win)[1] == vim.api.nvim_buf_line_count(bufnr)  vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, lote)  if pegado then    vim.api.nvim_win_set_cursor(win, { vim.api.nvim_buf_line_count(bufnr), 0 })  endend

Si el cursor estaba en la última línea, el usuario estaba siguiendo la salida y quiere continuar siguiéndola. Si no lo estaba, estaba leyendo, y lo respetas. No es una heurística inventada: es literalmente la que usan los visores de registros del sistema, y también la que aplica el propio buffer de terminal de Neovim, que solo hace tail de la salida cuando el cursor está en la última línea. La reencontrarás desde dentro en la lección siguiente.

El streaming es una decisión sobre latencia, no sobre eficiencia

Se suele vender el streaming como una optimización de memoria: no acumulas la salida entera, luego consumes menos. Es la razón menos interesante y casi nunca la decisiva, porque la salida de un compilador rara vez llega a un megabyte. Lo que el streaming compra de verdad es latencia de la primera información útil, y esa es una magnitud cualitativamente distinta del tiempo total. Un proceso que tarda ciento ochenta segundos y te da su primer error en el segundo cuatro tiene, desde el punto de vista de tu ciclo de trabajo, una latencia de cuatro segundos: puedes empezar a arreglarlo mientras el resto compila. El mismo proceso leído al final tiene una latencia de ciento ochenta. El tiempo de cómputo es idéntico; el tiempo de tu atención bloqueada difiere en dos órdenes de magnitud. De ahí se sigue una consecuencia de diseño que cambia cómo escribes plugins: la unidad correcta de procesamiento no es el proceso, es el evento. En cuanto aceptas eso, tu envoltorio deja de ser una función que devuelve un resultado y pasa a ser una tubería con etapas —trozos, líneas, entidades, presentación— donde cada etapa tiene su propia política de agrupación y su propio destino. Y es entonces cuando aparece la tensión real del diseño, que no es memoria contra velocidad sino granularidad contra coste de sincronización: cada cruce al hilo del editor cuesta un repintado, así que la calidad de tu implementación se mide por cómo agrupa, no por cómo lee. El reensamblador es trivial; el debounce que lo acompaña es donde vive la ingeniería.

Lo esencial

Pasa una función en la opción stdout para recibir la salida por trozos; nil marca el fin y es donde vacías el resto pendiente. Un trozo nunca es una línea: acumula y corta por el salto de línea. Todo contacto con el editor va dentro de vim.schedule, agrupado por lotes para no repintar miles de veces. Y si no llega nada hasta el final, el búfer de bloque del hijo es el sospechoso número uno.

Cada número es un trozo, y verás que no coinciden con líneas: a veces una entrega trae dos respuestas de ping, a veces media. Es el contrato de bytes en acción, en un comando de cinco segundos.

Con printf controlas el final exacto. Sin la rama de chunk == nil, sin_salto desaparece; con ella, llega al cerrar la tubería. Es la prueba más barata de que tu reensamblador está completo.

Todo llega de golpe al final: Python detecta la tubería y pasa a búfer de bloque. Repite con python3 -u y verás un trozo por segundo. Mismo código tuyo, comportamiento opuesto: el streaming también depende del emisor.

Con miles de líneas, la versión sin agrupar encola miles de tareas y el editor se arrastra; la agrupada colapsa todo en unas decenas de volcados. La diferencia no es elegancia: se mide en milisegundos y se nota en la mano.

Una tubería de progreso

  1. Implementa lector_de_lineas y úsalo con ping -c 10 127.0.0.1 para escribir cada respuesta en un buffer scratch conforme llega.
  2. Añade el agrupado con el patrón de programado. Compara el comportamiento contra un vim.schedule por línea usando una salida enorme como find /usr.
  3. Ejecuta un comando cuya última línea no termine en salto —printf te lo da— y comprueba que sin la rama de chunk == nil esa línea se pierde.
  4. Registra también una función en stderr y escribe sus líneas en el mismo buffer con un prefijo distinto. Observa que el orden relativo entre ambos canales no está garantizado: son dos tuberías independientes.
  5. Añade un contador de errores que se refleje en vim.o.statusline y que se actualice sin repintar el buffer entero.
  6. Implementa el autoscroll condicional de anexar y verifica las dos conductas: siguiendo desde la última línea, y leyendo desde una anterior sin que nadie te mueva.