Pesquisa

Mantendo o app do Cursor estável

Andrew Chan & Kevin Nguyen9 min de leitura

Muitos dos nossos usuários passam o dia inteiro usando o Cursor, o que significa que até falhas raras podem ser extremamente prejudiciais. Ao mesmo tempo, o desafio de manter o app estável cresceu à medida que nossa base de usuários aumentou e lançamos recursos cada vez mais ambiciosos, como subagentes, instant grep, browser use e outros.

A maioria dessas falhas é causada pelo app ficar sem memória (OOM). Nos últimos meses, implementamos sistemas para nos dar observabilidade sobre falhas e pressão de memória, além de correções e otimizações confiáveis para caminhos críticos e proteções para identificar regressões antes que cheguem aos usuários.

Nossa taxa de OOM por sessão, agregada em todas as versões do app do Cursor, caiu 80% desde o pico no fim de fevereiro, enquanto a taxa de OOM por requisição caiu 73% desde 1º de março. Esta postagem detalha os sistemas que desenvolvemos para chegar até aqui.

Taxa de OOM por sessão ao longo do tempo, mostrando uma queda de 80% desde o fim de fevereiroTaxa de OOM por sessão ao longo do tempo, mostrando uma queda de 80% desde o fim de fevereiro

Detectando e medindo a instabilidade

Nosso app para desktop é baseado nos alicerces de código aberto do Visual Studio Code e do Electron, o que resulta em uma arquitetura multiprocesso. Isso significa que falhas podem ocorrer tanto nos processos de renderização que sustentam o editor e a nova janela de agentes quanto nos processos utilitários que dão suporte a extensões, armazenamento e recursos de agente.

As falhas nos processos de renderização são as mais graves porque impedem completamente o uso do editor. Constatamos que, na maioria dos casos, elas acontecem quando os limites de memória do V8 são atingidos e, por isso, têm sido o foco dos nossos esforços mais recentes. Falhas nas extensões também podem comprometer funcionalidades importantes, como serviços de linguagem, mas geralmente se recuperam sem causar tanta interrupção para o usuário.

Toda falha fatal é registrada pela nossa telemetria junto com contexto, como o processo afetado, o tipo de falha, metadados do dispositivo e do app, além de minidumps e stack traces, quando disponíveis.

A partir desses eventos de falha, desenvolvemos métricas que conseguimos segmentar por versão do app, calculando taxas por sessão ou por requisição; a primeira indica, em linhas gerais, quantas sessões sofrem falhas, e a segunda, quão grave é o problema para as sessões afetadas. Esses dashboards são atualizados em poucos minutos após os eventos de falha, o que nos permite acompanhar de perto os lançamentos de novas versões e detectar rapidamente possíveis regressões.

Falhas por OOM ao longo do tempo, mostrando uma queda de 73% desde 1º de marçoFalhas por OOM ao longo do tempo, mostrando uma queda de 73% desde 1º de março

Duas estratégias de depuração

Adotamos uma abordagem em duas frentes para depurar travamentos do app e problemas de falta de memória.

De cima para baixo

A primeira é uma investigação de cima para baixo focada nos recursos que mais consomem memória. Se soubermos que um recurso consome muita memória, podemos vincular métricas de falha à feature flag correspondente no Statsig, nossa plataforma de experimentação, e então fazer um teste A/B para medir sua contribuição para as taxas de falha.

Também podemos acompanhar métricas proxy que se correlacionam fortemente com falhas e podem ser mais fáceis de observar em desenvolvimento. Uma dessas métricas são payloads de mensagem grandes demais. Como nosso app usa uma arquitetura multiprocesso, os dados são constantemente passados entre o editor, extensões e agentes por meio de canais entre processos e de uma camada de persistência. Instrumentamos ambos para rastrear mensagens maiores que um certo limite, o que se correlaciona fortemente com problemas de memória, e anexamos callstacks para que possamos rastrear cada uma até sua origem no código da nossa aplicação.

Para reconstruir o que acontece no momento de uma falha específica, adicionamos breadcrumbs (logs especiais de metadados anexados a erros) para recursos como uso paralelo de agentes, chamadas de ferramenta e terminais, para que cada evento de falha carregue um registro da atividade que o precedeu.

De baixo para cima

Em investigações de baixo para cima, rastreamos eventos individuais de falha até sua causa raiz. O primeiro passo é capturar o que aconteceu no momento em que o processo foi encerrado. Executamos um serviço de monitoramento de falhas no processo principal que usa o Chrome DevTools Protocol (CDP) para detectar erros de falta de memória e capturar stacks de falha em tempo real, e aplicamos um patch no upstream do Electron para tornar possível obter essas stacks sem toda a infraestrutura pesada do CDP. Essas stacks de falha alimentam uma automação executada diariamente, que analisa cada stack em detalhes, cria PRs com otimizações para stacks com correções de alta confiança e verifica, de versão para versão, a resolução dos issues.

Para entender como a memória se acumula ao longo de uma sessão, analisamos snapshots do heap. Quando detectamos que o Cursor está usando memória demais, solicitamos ao usuário que capture e envie um snapshot. Esses snapshots podem conter informações sensíveis, como o conteúdo de editores ou chats abertos, então o envio é totalmente opcional. Mas eles são extremamente valiosos para rastrear o acúmulo de pressão de memória até objetos e retentores específicos, por isso ficamos gratos quando os usuários escolhem participar.

Ferramenta de snapshot do heap no Cursor mostrando retentores de memóriaFerramenta de snapshot do heap no Cursor mostrando retentores de memória

Para entender os padrões de uso de memória em toda a base de usuários, executamos profiling contínuo de alocação no heap com uma baixa taxa de amostragem. Agregamos esses dados por versão do app para construir um detalhamento da pressão de memória por callstack. Isso nos dá uma visão geral da pressão de memória nas sessões do app, e podemos até fazer diffs entre versões para entender se um determinado caminho de alocação em uma versão mais recente do app melhorou ou regrediu em comparação com as anteriores, e em que medida.

Mitigações direcionadas

Com esses dois métodos de investigação, constatamos que as falhas geralmente se enquadram em um de dois padrões.

O primeiro são OOMs agudos, em que o uso de memória dispara de repente e o processo morre. Eles normalmente são identificados por meio de stacks de falha e raramente aparecem em heap dumps ou perfis contínuos. Uma causa muito comum é quando um recurso carrega dados demais de uma só vez, o que pode acontecer porque nosso app trabalha extensivamente com o conteúdo dos espaços de trabalho dos usuários e, por isso, muitas vezes carrega o conteúdo completo de arquivos do disco ou via IPC. Vimos que alguns espaços de trabalho de usuários podem conter arquivos enormes com os quais o app não consegue lidar, e tem sido fundamental adicionar killswitches ou dividir o processamento de blobs grandes em várias partes.

O segundo são OOMs lentos e graduais, em que o uso de memória vai aumentando ao longo de uma sessão até levar o processo além do limite. Isso acontece quando um estado gerenciado manualmente não é descartado corretamente ou quando há vazamento de recursos por causa de referências fortes remanescentes. Eles aparecem de forma confiável em heap dumps e podem ser corrigidos rastreando os retentores e ajustando o ciclo de vida de objetos de longa duração. Já enviamos para o upstream algumas correções de vazamento e outras correções para o VSCode, e estamos buscando adicionar mais.

Falhas de extensões também podem ser causadas por falta de memória, algo que mitigamos em parte por meio do isolamento de processos. Em termos gerais, ao executar extensões em seus próprios processos isolados, evitamos que uma falha ou tarefa demorada em uma extensão afete a funcionalidade de outra. Isso é semelhante à forma como o Chrome isola as abas entre si, com o custo de um uso um pouco maior de memória do sistema.

Prevenindo regressões, sem perder velocidade

Corrigir travamentos do app costuma ser mais simples do que evitar que novos sejam introduzidos, porque as correções são direcionadas. A prevenção exige conscientizar cada desenvolvedor sobre seu impacto na estabilidade sem sacrificar a velocidade que ganhamos com agentes, o que significa investir tanto em processos quanto em ferramentas.

Algumas das formas como estamos abordando isso incluem:

  • Regras do Bugbot para cada uma das principais classes de OOM ou travamento do app que já encontramos
  • Habilidades que nos permitem fazer testes de estresse da nossa aplicação com facilidade por meio do uso agêntico do computador
  • Eliminar armadilhas, como substituir recursos gerenciados manualmente por coleta de lixo para evitar vazamentos
  • Testes automatizados tradicionais de performance executados após cada alteração no código
  • Fechar o ciclo de detecção com métodos como rollbacks automatizados em caso de regressões nas métricas

Estabilidade para uma nova geração de software

Desenvolvimento de software agêntico torna mais fácil do que nunca tanto entregar novos recursos quanto introduzir problemas de performance e bugs. Ao mesmo tempo, alcançar a estabilidade da aplicação exige os mesmos fundamentos da engenharia de software, mas adaptados para uma nova geração, por meio de estratégias agênticas para corrigir e prevenir problemas.

Desenvolver software de alta qualidade sempre foi difícil, e agora isso é mais importante do que nunca. Se você é apaixonado por isso, adoraríamos ouvir de você.

Publicado em: Pesquisa

Autors: Andrew Chan & Kevin Nguyen