O bug do ano 2038: o int que vai estourar no futuro

Por que um contador de segundos de 32 bits ainda é um problema real, e onde ele se esconde no seu código Java

Foto de Tibe De Kort no Pexels

No dia 19 de janeiro de 2038, um segundo depois das 03:14:07 UTC, todo software que ainda guarda o tempo num inteiro de 32 bits vai olhar para o relógio e concluir que voltou para 1901. Não é profecia nem teoria da conspiração: é aritmética. E diferente do bug do milênio, que foi resolvido (com muito trabalho) antes da virada, esse ainda tem ponta solta espalhada por sistemas embarcados, bancos de dados e código que alguém escreveu ontem.

Faltam pouco mais de onze anos. Parece muito, mas tem sistema rodando em produção hoje que foi escrito há mais tempo que isso, e tem sistema que já precisa calcular datas depois de 2038 agora mesmo.

De onde vem o número

Boa parte dos sistemas representa o tempo como “segundos desde a época Unix”, que é 1º de janeiro de 1970, 00:00:00 UTC. No mundo C e Unix, esse valor mora no tipo time_t, que por muito tempo foi um inteiro com sinal de 32 bits.

O maior valor que cabe em um inteiro com sinal de 32 bits é 2.147.483.647. Somando esse tanto de segundos a partir de 1970, você chega exatamente em 19/01/2038 03:14:07 UTC. Um segundo depois, o contador estoura, o bit de sinal liga e o número vira -2.147.483.648, que corresponde a 13 de dezembro de 1901.

Dá para ver isso direto no jshell:

jshell> int segundos = Integer.MAX_VALUE;
segundos ==> 2147483647

jshell> Instant.ofEpochSecond(segundos)
$2 ==> 2038-01-19T03:14:07Z

jshell> Instant.ofEpochSecond(segundos + 1)
$3 ==> 1901-12-13T20:45:52Z

O Instant em si não tem problema nenhum: ele aceita long. Quem quebra é o int que foi somado antes de chegar nele. E esse é o resumo de todo o bug de 2038: o problema raramente está na API de datas, está no tipo que alguém escolheu para guardar o número no meio do caminho.

“Mas eu uso Java, isso é coisa de C”

É verdade que o Java nasceu do lado certo dessa história. System.currentTimeMillis() devolve um long de milissegundos, java.util.Date guarda um long por dentro e o java.time trabalha com segundos em long mais nanossegundos. Um long de milissegundos só estoura daqui a uns 292 milhões de anos.

O problema é que o código Java conversa com o mundo, e o mundo tem muito int de 32 bits. Alguns lugares onde ele aparece:

Casts para economizar espaço. Alguém precisava de um timestamp em segundos, achou que long era exagero e fez isso:

int criadoEm = (int) (System.currentTimeMillis() / 1000);

Hoje funciona perfeitamente. Em 2038, o cast trunca silenciosamente e o valor vira negativo. Nenhuma exceção, nenhum log, só uma data em 1901 gravada no banco.

Protocolos e formatos binários. Um campo int32 num schema Protobuf, um campo de 4 bytes num protocolo legado, um INT numa tabela. O código Java pode até usar long internamente, mas se o valor passa por um contrato de 32 bits em algum ponto, o limite é esse.

Banco de dados. O tipo TIMESTAMP do MySQL tem limite superior justamente em 2038-01-19 03:14:07 UTC. Tentar gravar uma data depois disso numa coluna TIMESTAMP dá erro ou vira valor zerado, dependendo do modo SQL. DATETIME não tem esse limite. Vale conferir que tipo as suas tabelas usam, especialmente as antigas.

Bibliotecas nativas e sistemas embarcados. Se o seu serviço chama uma lib em C via JNI, roda em um dispositivo ARM de 32 bits ou conversa com um equipamento industrial, o time_t de 32 bits pode estar lá embaixo, fora do seu alcance.

O futuro já chegou para quem calcula datas

O detalhe que muita gente esquece: o bug não espera 2038 para aparecer. Ele aparece no dia em que o sistema precisa representar uma data depois de 2038.

Um financiamento imobiliário de 30 anos contratado hoje termina em 2056. Um certificado digital emitido com validade longa, uma apólice de seguro, um título com vencimento distante, um cálculo de aposentadoria, um token com expiração “praticamente infinita” configurada como hoje + 20 anos. Todos esses casos já cruzam a fronteira, e qualquer int no caminho vai produzir uma data no passado.

E data no passado é o pior tipo de erro, porque não quebra de forma barulhenta. Um token que “já expirou” é rejeitado, um contrato que “já venceu” é cobrado, um registro com data de 1901 vai parar no topo de uma ordenação. O sistema continua de pé, fazendo a coisa errada com convicção.

O primo de 24 dias

Existe uma versão mais imediata do mesmo bug, e essa sim é frequente em Java: guardar milissegundos em int.

Integer.MAX_VALUE milissegundos dá cerca de 24 dias e 20 horas. Qualquer coisa que conte milissegundos num int estoura nesse prazo. Um timeout configurado como “30 dias” em milissegundos, um contador de uptime, um cálculo de diferença entre datas:

int timeoutMs = 30 * 24 * 60 * 60 * 1000;
System.out.println(timeoutMs); // -1702967296

A conta inteira é feita em int antes de ser atribuída, então mesmo declarar a variável como long não salva:

long timeoutMs = 30 * 24 * 60 * 60 * 1000;  // ainda -1702967296
long certo     = 30L * 24 * 60 * 60 * 1000;  // 2592000000

O L no primeiro literal faz toda a conta acontecer em long. Melhor ainda é não fazer essa conta na mão: Duration.ofDays(30).toMillis() é mais legível e não tem como errar o tipo.

Esse padrão de “contador que estoura depois de muito tempo ligado” já apareceu em lugares bem sérios. Em 2015, a FAA americana emitiu uma diretiva para o Boeing 787 porque um contador interno do sistema de geração elétrica estourava depois de 248 dias contínuos ligado, o que podia desligar a geração de energia. A orientação era simples: reiniciar o sistema antes de chegar nesse prazo. É o bug de 2038 em escala de laboratório.

O que o resto do mundo está fazendo

A boa notícia é que a correção de fundo é conhecida: usar 64 bits. Um time_t de 64 bits empurra o limite para daqui a centenas de bilhões de anos.

Em Linux de 64 bits, o time_t já é de 64 bits há muito tempo, então o servidor x86-64 rodando a sua aplicação está tranquilo nessa camada. O trabalho pesado tem sido nos sistemas de 32 bits: o kernel Linux ganhou suporte a tempo de 64 bits nas arquiteturas de 32 bits, a glibc passou a permitir compilar programas com time_t de 64 bits nessas plataformas, e distribuições como o Debian migraram boa parte das suas arquiteturas de 32 bits para time_t de 64 bits. Sistemas de arquivos também tiveram que se ajustar, porque guardam datas de modificação dos arquivos em disco.

O problema que sobra é o que não é atualizado: dispositivos embarcados que ninguém mexe há anos, formatos de arquivo e protocolos que fixaram 32 bits na especificação, e dados já gravados. Trocar o tipo no código é fácil. Migrar uma tabela com bilhões de linhas ou um protocolo usado por centenas de clientes, nem tanto.

Os irmãos do 2038

O 2038 não é o único contador com data marcada para estourar. Alguns parentes próximos:

  • NTP em 2036: o protocolo de sincronização de relógio usa segundos desde 1900 num inteiro sem sinal de 32 bits, que vira em fevereiro de 2036. O protocolo tem o conceito de “eras” para lidar com isso, mas implementações que ignoram esse detalhe podem se confundir.
  • GPS week rollover: o GPS conta semanas num campo de 10 bits, que zera a cada 1024 semanas (cerca de 19,6 anos). Isso já aconteceu em 1999 e em abril de 2019, e alguns receptores antigos passaram a mostrar datas erradas.
  • Unix sem sinal em 2106: alguns sistemas “resolveram” o 2038 trocando o inteiro com sinal por um sem sinal. Isso empurra o problema para 2106 e quebra qualquer data antes de 1970. Funciona, mas é adiar a conta.

O padrão é sempre o mesmo: alguém escolheu um tamanho fixo que parecia mais do que suficiente na época, e o tempo, que só anda para a frente, eventualmente alcança o limite.

Como se proteger

Algumas práticas resolvem quase todos os casos no dia a dia:

  • Use os tipos do java.time (Instant, Duration, LocalDate) em vez de números soltos. Um Instant não tem como estourar em 2038, e um Duration não deixa você confundir segundos com milissegundos.
  • Quando precisar de número, use long. Epoch em segundos ou milissegundos é sempre long. Se em algum lugar o tipo for int, isso deveria chamar atenção num code review.
  • Faça o cast falhar alto. Se uma API externa exige int, use Math.toIntExact(valor), que lança ArithmeticException em vez de truncar em silêncio. Um erro hoje é muito melhor que uma data de 1901 em 2038.
  • Confira os tipos nas fronteiras. Colunas TIMESTAMP no MySQL, campos int32 em schemas, formatos binários legados. É ali que o long bem comportado do Java vira 32 bits sem ninguém perceber.
  • Teste com datas do futuro. Um teste que usa Clock.fixed com uma data em 2040 ou um contrato com vencimento em 2060 pega a maior parte desses problemas. Se o seu código recebe um Clock por injeção em vez de chamar Instant.now() direto, esse teste custa uma linha.
@Test
void calculaVencimentoDepoisDe2038() {
    Clock clock = Clock.fixed(Instant.parse("2037-06-01T00:00:00Z"), ZoneOffset.UTC);
    var contrato = new Contrato(clock, Period.ofYears(5));

    assertEquals(LocalDate.of(2042, 6, 1), contrato.vencimento());
}

O bug do milênio virou piada porque deu tudo certo, e deu tudo certo porque muita gente passou anos corrigindo código antes da virada. O de 2038 tem a vantagem de ser ainda mais previsível: a gente sabe o dia, a hora e o segundo. A diferença entre ele virar uma nota de rodapé ou uma lista de incidentes é a quantidade de int que ainda vai existir no caminho quando o relógio chegar lá.