xomposite: construindo um compositor X11 em Rust, oito dias sobre o XLibre

Demostenes Albert Por Demostenes Albert • • 12 min de leitura
xomposite: construindo um compositor X11 em Rust, oito dias sobre o XLibre

Escrevi um compositor X11 do zero, em Rust, contra o XLibre




Nos últimos meses fui abrindo, uma por uma, as caixas-pretas do meu desktop Linux: planilha editada no terminal, teclado quebrado resolvido por engenharia reversa, blog inteiro migrado pro R2. Faltava a que quase todo mundo trata como intocável: o compositor. É ele que decide se a tela pisca, se as janelas têm sombra, se o blur atrás do terminal existe de verdade ou é só aquele efeito que você viu no vídeo de alguém rodando KDE.
Então resolvi escrever um. Do zero, em Rust, rodando contra o XLibre, começando de um binário que não sabia nem abrir um contexto EGL.


Por que XLibre

Não foi acaso. Já escrevi sobre o caso Artist, Artix Linux e XLibre, a novela de governança que separou um fork do Xorg da distro que o hospedava. Depois disso, a curiosidade passou da política para a técnica: esse fork novo aguenta o tranco?
Isso não se descobre lendo changelog. Se o servidor tem furo em alguma extensão, um compositor de verdade acha muito mais rápido do que qualquer thread de fórum, porque ele usa praticamente todas as extensões que importam ao mesmo tempo.
O que ele é hoje
O projeto se chama xomposite (no repositório ainda está com o nome de trabalho, xlibre-compositor-poc). Ele roda contra o XLibre XServer 25.1.9 e, em resumo:
  • assume a seleção _NET_WM_CM_S0, ou seja, se declara *o* compositor da tela;
  • redireciona a árvore inteira de janelas em modo manual;
  • importa o conteúdo de cada janela via XComposite + EGLImage;
  • rastreia quais regiões mudaram com XDamage, para não redesenhar o que não mudou;
  • agenda a apresentação com Present, sincronizado com o vsync real;
  • desenha cantos arredondados, bordas, sombras, opacidade e blur de fundo, tudo calculado analiticamente no shader, sem sprite pré-renderizado.
São 18.815 linhas de Rust em src/ e 414 testes (e subindo, porque tem trabalho não commitado enquanto escrevo). A pilha de dependências é propositalmente crua:
# Cargo.toml
[dependencies]
gl = "0.14"
khronos-egl = { version = "6.0", features = ["dynamic"] }
libloading = "0.8"
libc = "0.2"
x11rb = { version = "0.14", features = [
    "allow-unsafe-code", "as-raw-xcb-connection",
    "composite", "damage", "dri3", "present", "randr", "xfixes",
] }
Nenhum toolkit de compositor pronto, nenhum wrapper de alto nível para Composite, Damage ou Present. O x11rb fala o protocolo, o khronos-egl fala EGL, o gl fala OpenGL puro. O resto é meu.
Mas a lista de features não é o que me interessa contar. O que interessa é como isso saiu em oito dias corridos, de 22 a 29 de agosto, sem a dívida técnica que ritmo assim costuma deixar.
A progressão, sem pular etapa
22/08 - o ponto zero. Abrir uma conexão XCB, subir um contexto EGL em cima dela, desenhar um triângulo. Nenhuma composição ainda: só provar que a base engata.
Daí em diante, cada passo foi um commit separado, na ordem:
  1. Uma janela isolada. Capturar via XComposite + EGLImage, ligar o XDamage para saber quando ela muda, tratar resize e ciclo de vida da captura, recusar alvos inseguros.
  2. Ownership. Negociar a posse do compositor e se comportar direito quando outro cliente já é o dono.
  3. A tela inteira. A fase mais delicada: deixar de compor uma janela solta e virar compositing manager da hierarquia toda. Sondar a semântica do Composite sobre a árvore global, acompanhar mudanças de hierarquia ao vivo, fazer preflight do takeover com saída graciosa, validar o overlay e o redirecionamento manual.
  4. A primeira cena, em quatro commits (c1b9c35 → 853e9af → 67f0a90 → 525ed50): primeiro algo estático na tela, depois reagindo a janelas abrindo e fechando, depois ao dano de pixel, e só então com o pipeline EGL real.
  5. Efeitos, de 26 a 29/08. Cantos arredondados, bordas (com estado pré-calculado para não invalidar o dano à toa), sombras, opacidade e, por último, blur de fundo, junto com a fundação do arquivo de configuração.
Dois detalhes desse histórico dizem mais sobre o processo do que as features. A instrumentação de métricas entrou antes de o compositor desenhar a primeira sombra: medir desempenho fez parte do trabalho desde o começo, não foi remendo no final. E experimentos ficam fora do main: quando precisei de uma base combinando três fases para testar um candidato, ela nasceu num worktree isolado, marcada como descartável.
O Xephyr que dizia suportar EGL
Antes de escrever a primeira cena global, a pergunta era: dá pra provar tudo dentro de um Xephyr, sem arriscar o desktop real? Em vez de código, isso virou um relatório de design.
O Xephyr -help lista -glamor. Parecia que sim. Rodei:
Xephyr :2 -glamor -screen 1280x720x24 -ac -no-host-grab
O xdpyinfo confirmou XLibre 25.1.9 com Composite, Damage, GLX e Present, mas sem DRI3. O glxinfo -B respondeu renderer: llvmpipe, ou seja, renderização por software. E o preflight do próprio projeto fechou a questão:
libEGL warning: DRI3 error: Could not get DRI3 device
EGL config: FAIL - required EGL extension is unavailable: EGL_KHR_image
A flag existia; a capacidade, não. Se eu tivesse confiado no -help, ou deixado uma IA implementar partindo do princípio de que "tem -glamor, então tem EGL", teria passado o dia caçando um bug que não estava no meu código, e sim no ambiente de teste.
A decisão, registrada antes de qualquer linha escrita, foi dividir a etapa: provar seleção de superfícies e empilhamento no Xephyr, que dá conta de X11 puro, e só depois ligar o EGL de verdade contra o DISPLAY=:0 real. Mais chato, sim. Muito mais barato do que a alternativa.
Dois problemas que doeram
Tutorial de caminho feliz já tem demais. Estes foram os dois que mais ensinaram.

1. O resize de janela flutuante engasgava, e não era óbvio por quê
No compositor não existe distinção entre janela flutuante e janela em tiling. Um ConfigureNotify segue o mesmo caminho nos dois casos: evento → lote de invalidação → nova geometria → reconstruir a cena e apresentar. Mesmo assim, só o resize flutuante travava.
Em vez de chutar, medi. Numa sessão real de resize flutuante:

FlutuanteTiling
Eventos ConfigureNotify838-
Candidatos de cena iniciados539
Candidatos publicados119
Descartados por já estarem obsoletos42 (79%)0

Quatro em cada cinco cenas montadas iam para o lixo. A causa: o compositor adquiria os recursos caros de cada janela (o lease de damage, o pixmap nomeado, o import EGL) antes de verificar se aquela geometria ainda era a atual. A checagem só acontecia no fim, no portão antes do commit. No tiling, os eventos são poucos e chegam agrupados, então isso não aparecia. No flutuante, o mouse gera geometria nova mais rápido do que o compositor termina a anterior: o candidato já nasce morto.
A correção não foi debounce, nem timer, nem limite de frames. Essas saídas foram descartadas explicitamente. Foi mover a verificação de obsolescência para antes da aquisição de recursos, usando a última geometria já vista no fluxo de eventos, sem nenhuma consulta nova ao servidor X.
Chegar lá levou mais de vinte e cinco iterações num único dia, 29 de agosto, cada uma com auditoria e preview antes da próxima. No fim, com 401 testes passando, mover e redimensionar viraram o que sempre deveriam ter sido: contratos diferentes. O critério de "movimento puro" é pequeno o bastante para caber num comentário de code review: mesma largura, altura e borda, só x/y mudaram, mesmo cliente, mesma raiz.
// src/x11/scene.rs
fn move_only_geometry_is_eligible(
    entry: &SurfaceEntry,
    next_geometry: WindowGeometry,
    expected_root: Window,
    next_override_redirect: bool,
    next_semantic_client: Option<Window>,
) -> bool {
    entry.geometry.width == next_geometry.width
        && entry.geometry.height == next_geometry.height
        && entry.geometry.border_width == next_geometry.border_width
        && (entry.geometry.x != next_geometry.x || entry.geometry.y != next_geometry.y)
        && entry.override_redirect == next_override_redirect
        && entry.semantic_client_xid == next_semantic_client
        && expected_root != x11rb::NONE
}
Qualquer coisa fora disso, resize incluído, volta para o caminho estrutural completo, com reaquisição de recursos e todo o custo que isso implica. Sem meio-termo silencioso.

2. Um mem::forget que funcionava e mesmo assim estava errado
O blur de fundo cria vários recursos na GPU: texturas, framebuffers, shader, VAO, buffer. Enquanto a criação está em andamento, eles ficam num PendingBlurResources, que libera tudo se algo falhar no meio. Quando dá certo, os valores são copiados para o dono definitivo, BackgroundBlurResources, e o objeto pendente recebia um std::mem::forget para não apagar o que acabara de ser entregue.
A política do projeto proíbe mem::forget, então isso ganhou uma auditoria só para si, sem tocar em código. Conclusão: não havia vazamento, nem double-delete, nem use-after-free no caminho atual. O ownership estava correto. O problema era outro: forget é transferência manual disfarçada de RAII. Funciona enquanto ninguém mexer na ordem daquelas duas structs sem lembrar de um contrato que não está escrito em lugar nenhum.
A correção troca o forget por uma transferência que consome o objeto de verdade:
// src/graphics/renderer.rs
fn into_resources(
    mut self,
    texture_size_uniform: i32,
    direction_uniform: i32,
    radius_uniform: i32,
    width: i32,
    height: i32,
) -> BackgroundBlurResources {
    BackgroundBlurResources {
        textures: std::mem::replace(&mut self.textures, [0; 2]),
        framebuffers: std::mem::replace(&mut self.framebuffers, [0; 2]),
        program: std::mem::replace(&mut self.program, 0),
        vao: std::mem::replace(&mut self.vao, 0),
        buffer: std::mem::replace(&mut self.buffer, 0),
        texture_size_uniform,
        direction_uniform,
        radius_uniform,
        width,
        height,
    }
    // Aqui `self` sai de escopo com todos os nomes GL zerados.
    // O Drop roda normalmente e apaga 0 - que a spec do OpenGL trata como no-op.
}
O self entra por valor. Cada recurso é entregue ao novo dono e, no lugar dele, fica um zero. Quando o objeto pendente morre no fim da função, o Drop roda como qualquer outro e não apaga nada. Nenhum forget, nenhuma suposição sobre destrutor que "não vai rodar".
Nenhuma chamada GL mudou, nenhuma ordem de criação mudou, nada muda na tela. Os mesmos 401 testes passando antes e depois. É o tipo de correção que nunca vai aparecer em screenshot, e que só existe porque o processo se recusou a aceitar "funciona, mas viola a política" como resposta final.

O método, não só o código


O repositório tem 36 commits. Ao lado dele, fora do git, há uns 120 patches e uns 200 relatórios de auditoria. Olhando tudo junto, o mesmo ciclo se repete em toda etapa:
design/auditoria → patch candidato → relatório de aplicação → versão canônica → fechamento → verificação pós-release.
Nada vira canônico sem passar por pelo menos uma rodada de candidatura. O git log recebe só o resultado final. Rascunho, candidato descartado, auditoria que não achou nada: fica tudo documentado, só que fora do histórico oficial.
E sim, uso agente de IA pesadamente nisso. Seria hipocrisia esconder depois de ter escrito que o problema não é usar IA, é delegar o raciocínio. Esse ciclo é exatamente o que defendi lá, só que operacionalizado em nome de arquivo:
  • Eu entendo o problema antes de o patch existir. Os relatórios de design e de arquitetura vêm sempre primeiro.
  • O agente escreve o candidato.
  • O agente audita o próprio candidato como adversário. Um relatório que conclui "não há bug, mas viola a política, corrija mesmo assim" é isso.
  • Consolidar, taguear e dar push é decisão minha, registrada com hash do binário e SHA-256 do patch aplicado, não com "parece que funcionou".
A saga do resize mostra o valor disso melhor do que qualquer argumento. Vinte e cinco iterações num dia só são sustentáveis, sem transformar o main numa pilha de código não testado, porque cada uma tinha auditoria prévia, testes passando e um relatório que eu lia antes de deixar seguir. Delegar teria sido aceitar o primeiro patch que "parecia resolver" o engasgo. Entender foi medir 838 eventos e descobrir que o problema não era volume, era a ordem em que os recursos eram adquiridos.

Onde está agora


O HEAD está em 838fcad (compositor: finalize background blur ownership). A configuração com recarga ao vivo está em andamento: 572 linhas novas em src/config.rs, ainda sem commit enquanto escrevo.
O que eu queria provar, está provado: dá para escrever em Rust, contra um servidor X em que a comunidade ainda está decidindo se confia, um compositing manager de verdade (captura, damage, apresentação sincronizada, efeitos analíticos) sem herdar vinte anos de gambiarra de projeto nenhum.
Terminar, não terminou. Compositor nunca termina: sempre tem mais um WM para testar, mais um cliente com coordenada esquisita, mais um caso de borda em multi-monitor. Mas já terminou o bastante para rodar na minha sessão todo dia. E, para qualquer coisa que mexe no pipeline gráfico do seu desktop, essa é a única validação que importa.


Kikito (a maritaca)

Kikito (a maritaca)

Opinião não solicitada • powered by gemini-2.5-flash

Ô, pássaro! Que voo audacioso, hein? "xomposite"... parece nome de ninho aconchegante, mas o trabalho... uau! Oito dias pra levantar um compositor X11 do zero em Rust? Isso é mais rápido que eu devorando um girassol inteiro! Lembro bem da fofoca do Artist e do XLibre, então ver você mergulhar na parte *técnica* pra ver se o tal fork aguentava o tranco é de dar gosto. Não é só falar, é *fazer* e *provar* que aguenta, mostrando o copo cheio de código! Squawk! E que forma sistemática de provar! Adorei a metodologia, o passo a passo, o jeito de ir desvendando as "caixas-pretas". Essa do Xephyr com o `-glamor` que engana, que a flag existia mas a capacidade não... Ah, essa me lembra *muito* daquele dia que o `yazi` não falava nada sobre o `.xlsx`! É o tipo de coisa que faz um pássaro desconfiar do que a documentação *diz* versus o que o código *faz*. E você foi lá, mediu, testou a realidade e não se deixou enganar por promessas furadas. Isso é que é ver o copo meio cheio, sabendo que a 'meia-cheia' é real! Ver como você desvendou o engasgo do resize flutuante, medindo e otimizando sem atalhos, é inspirador. Não é só sobre construir algo, é sobre *entender* cada minúcia e fazer funcionar direito. Poxa, isso me dá uma vontade de pegar um graveto e montar meu próprio comedouro high-tech! Parabéns por encarar o "intocável" e mostrar que, com Rust e muita análise, dá pra fazer um desktop voar alto e sem travessuras. Agora, se me der licença, vou ali beliscar uma semente, mas já tô pensando qual caixa-preta você vai abrir na semana que vem!