await ou fire-and-forgetO EventBus do daemon expõe publish
como método async. A superfície é minúscula, mas esconde uma decisão
arquitetural que costuma passar batido: quem publica escolhe se
awaita ou não o pipeline de subscribers. O bus não escolhe por você — e
essa escolha tem consequências reais em latência, correção de shutdown, e
acoplamento entre módulos.
Este documento existe porque durante a implementação do
FinalizeRecordingUseCase (fechar meta.json quando streamlink morre),
essa exata escolha causou um bug latente no shutdown do daemon e forçou
uma virada de "fire-and-forget" pra "await" no handleExit do recorder.
A confusão que aparece na hora ("mas o bus não é fire-and-forget?") merece
ficar registrada.
Olhando o corpo do publish:
async publish<E extends Event>(event: E): Promise<void> {
const list = this.handlers.get(event.constructor as Ctor) ?? []
for (const handler of list) {
try {
await handler(event) // sequencial, awaitando cada um
} catch (err) {
console.error('[bus] handler failed:', err)
}
}
}Duas propriedades importantes garantidas pelo bus:
Promise.all — é for + await. Handler N+1 só começa depois que N
resolveu.publish rejeitar. publish NUNCA
throw — só retorna quando todos os handlers terminaram (ou joga, e o
bus loga).O que o bus não faz:
Promise<void>
e faz o que quiser com ela| Camada | Comportamento | Configurável? |
|---|---|---|
Bus interno (publish) | Sequential-await de todos os subscribers | ❌ hardcoded |
Publisher (bus.publish(...)) | await ou void — decide caso a caso | ✅ por call site |
| Subscriber (função registrada) | Sync ou async (void | Promise<void>) | ✅ por handler |
Só a camada do meio (publisher) é onde "fire-and-forget vs await"
existe. E a decisão é distribuída — cada bus.publish(...) no código
pode escolher diferente.
void bus.publish(...)Publisher dispara o evento, descarta a Promise, segue em frente
imediatamente. Subscribers processam em background, mas o publisher não
sabe (nem se importa) quando terminam.
Quando faz sentido:
Nota: nenhum publisher no daemon usa esse modo hoje. Todos os
bus.publish são awaitados (Monitor awaita porque quer serializar
ChannelLive/Offline dentro do mesmo tick; Recorder awaita por causa
do shutdown, como descrito abaixo). O modo fire-and-forget está aqui como
opção suportada pelo bus, disponível caso um publisher futuro precise
(webhook não-crítico, métrica de alto volume) — não como pattern
recomendado por padrão.
Cuidado: erros dentro dos subscribers só vão pro console.error do
bus. Se você quiser fail-fast, await + inspecionar o Result (mas o bus
não expõe Results — ver seção "Limitações" abaixo).
await bus.publish(...)Publisher espera todos os subscribers terminarem antes de continuar. Se
algum for lento, o publisher espera. Se algum joga, o bus loga e o
próximo continua — mas o publish só resolve no fim da fila.
Quando faz sentido:
meta.json fechou antes do processo sair)O handleExit do StreamRecorder
começou fire-and-forget:
Fazia sentido: handleExit é chamado de dentro de
proc.exited.then(...), callback puramente reativo. Sem "próximo passo"
do publisher, não tinha razão pra bloquear.
O bug latente: no shutdown do daemon (Ctrl+C), a sequência era:
shutdown() → recorder.stopAll()stopAll envia SIGTERM pra cada streamlink e retorna imediatamenteipc.close(), main() resolve, Bun sai.ts — proc.exited nunca resolve
dentro do nosso processohandleExit nunca roda → bus.publish nunca dispara → finalize
nunca escreve → meta.json preso em "status": "recording"O fix teve 3 passos, todos concentrados no recorder:
handleExit virou async e passou a await this.props.bus.publish(...)
— agora o Promise retornado por handleExit só resolve quando o
finalize terminouproc.exited.then(handleExit) como
exitHandled num campo do ActiveRecordingstopAll faz snapshot desses exitHandled antes de sinalizar,
envia SIGTERMs, e depois await Promise.allSettled(pending) — garante
que Bun não sai antes de todos os meta.json fecharemO que ficou não-bloqueante: o método stopStream do recorder (não
o publish), quando chamado reactive via ChannelOfflineEvent. Ele só
envia SIGTERM e retorna imediatamente — não awaita proc.exited. O
handler do ChannelOfflineEvent executa stopRecording.execute e
resolve rápido; a finalização acontece depois, em background, quando o
streamlink de fato morrer e o handleExit disparar. Se essa cadeia
tivesse virado bloqueante, o bus (que awaita subscribers sequencialmente)
serializaria offlines de múltiplos canais em cima da latência de fecho
do streamlink.
Ou seja: o bus e o publish continuam awaited (Monitor no publish,
Recorder no handleExit). O que não é bloqueante é a operação
stopStream — decisão fora do bus, no design da própria classe recorder.
await publish acopla latênciaNo momento em que o publisher awaita, ele fica escravo da latência do
subscriber mais lento. Um subscriber novo (webhook Discord, métrica
Prometheus) que pendura 30s vai propagar esse atraso pro stopAll →
shutdown → user esperando o daemon fechar.
Mitigações possíveis (nenhuma implementada hoje):
publish:
await Promise.race([handler(event), sleep(5000)])publishSync / publishAsync)Promise.allSettled sem awaitPrematuro agora: só 1 subscriber crítico por evento hoje (finalize),
todo o resto é hipotético.
O try/catch por handler está dentro do bus, não depende de como o
publisher usa o Promise. void publish e await publish têm mesma
garantia: 1 handler ruim não derruba os outros.
O que muda é quando o publisher fica sabendo do erro:
publish resolve mesmo com
handlers que jogaram)await bus.publish(A) seguido de await bus.publish(B) garante que
todos handlers de A rodam antes de qualquer handler de B. Mas se dois
publishers diferentes chamam bus.publish em paralelo, a ordem entre
eles é indefinida (depende do event loop). Não é limitação do bus — é
consequência de JS single-threaded com async.
publish não retorna outcome dos handlers. Nem success/failure,
nem quais rodaram. Se você precisa saber "o handler X escreveu o
meta.json?", não dá pra descobrir pelo bus — precisa de outro canal
(Result no return do use case, log, etc).publish
resolve normal. Publisher que quer fail-fast num handler específico não
consegue via bus — teria que chamar o handler direto.AbortSignal não é propagado.| Situação do publisher | Modo |
|---|---|
| Loop reativo/polling que não pode bloquear | void publish |
| Métrica, notificação, log — subscriber é decorativo | void publish |
| Próximo passo depende de subscriber terminar | await publish |
| Shutdown / cleanup precisa garantir durabilidade | await publish |
| Test que precisa observar side effect do subscriber | await publish |
EventBus.publish sequencial-await + isolamento por handlerhandleExit awaita bus.publish; stopAll captura exitHandled promise no ActiveRecording e awaita Promise.allSettled no shutdownbus.publish de ChannelLiveEvent/ChannelOfflineEvent dentro do loop de canais, garantindo ordem previsível de transições no mesmo tickStreamRecorder.stopStream retorna após enviar SIGTERM sem awaitar proc.exited — evita serializar offlines reactive em cima da latência do streamlink morrervoid bus.publish(new SomeEvent(...))await bus.publish(new SomeEvent(...))
// próximo passo só roda depois que TODOS os subscribers processaram// Versão original — publisher descarta a Promise
void this.props.bus.publish(new RecordingFinishedEvent(...))