Crash SQLite no iOS + fix do XA0141 16 KB do Android (.NET MAUI)
Um crash de iOS (BUG IN CLIENT OF libsqlite3.dylib) e o warning XA0141 de 16 KB do Android têm a mesma correção de raiz. Guia completo de SQLite no .NET MAUI.
Introdução
Dois problemas de produção da mesma base de código .NET MAUI, com a mesma causa de fundo e boa parte da cura em comum:
- Um crash de iOS —
EXC_BREAKPOINT: BUG IN CLIENT OF libsqlite3.dylib: illegal multi-threaded access to database connection— que aborta o processo poucos segundos depois do launch. - O warning de build
XA0141no Android —shared library 'libe_sqlite3.so' does not have a 16 KB page size— uma bomba-relógio de compatibilidade para devices Android 15/16.
Os dois voltam para a mesma coisa: seu app está rodando na biblioteca nativa errada do SQLite, e está fechando/trocando a conexão sem disciplina. Este post cobre a causa raiz de cada um, a correção que resolve os dois de uma vez (migrar para SQLitePCLRaw.bundle_e_sqlite3), a disciplina de concorrência que o crash de iOS também precisa, e um checklist completo para configurar SQLite no .NET MAUI do jeito certo. Toda referência que usei está linkada no final.
Se você usa sqlite-net-pcl (ou EF Core SQLite) num app MAUI que faz sincronização em background, backup ou "limpar dados locais", isso te afeta.
Problema 1: O crash "illegal multi-threaded access" no iOS
A mensagem vem direto do build do SQLite da Apple. O libsqlite3.dylib do iOS embute um watchdog: no instante em que ele detecta que duas threads estão dentro do mesmo handle de conexão sqlite3* ao mesmo tempo, ele não serializa e não devolve erro — ele chama abort(). Seu processo morre com EXC_BREAKPOINT, normalmente com sqlite3_finalize na thread que crashou.
Dois fatos tornam isso fácil de disparar sem querer:
1. SQLITE_OPEN_FULLMUTEX não te salva. No SQLite portável, o modo "serialized" significa que chamadas concorrentes bloqueiam num mutex. O build da Apple, em vez disso, trata a entrada concorrente como bug do cliente e aborta. A própria thread do Apple Developer Forums (externo) deixa claro: não confie nos mutexes do SQLite para acesso concorrente — use uma conexão por thread, ou serialize você mesmo.
2. O sqlite-net-pcl protege operação‑vs‑operação, mas não o CloseAsync(). Uma única SQLiteAsyncConnection serializa as próprias queries com um lock interno. Mas SQLiteAsyncConnection.CloseAsync() passa pelo caminho do connection pool, que não pega esse lock. Então, se qualquer thread chama CloseAsync() enquanto outra thread está no meio de um sqlite3_step, você tem exatamente esse crash — fechar a conexão finaliza todos os prepared statements em cache, e é por isso que sqlite3_finalize é o frame em que o SO aborta.
No nosso código o gatilho era um fluxo de "limpar dados locais e re‑sincronizar":
// Roda logo após o login, enquanto a sync paralela já está inserindo linhas
await Database.CloseDatabaseAsync();
if (File.Exists(Database.PathDB))
File.Delete(Database.PathDB);
await Database.ReopenDatabaseAsync();
O CloseDatabaseAsync() fechava o handle nativo enquanto oito tasks worker do SyncService ainda estavam escrevendo. Boom.
Problema 2: Page sizes de 16 KB do Android e o warning XA0141
Se você builda para Android moderno, seus logs provavelmente já mostram isto:
warning XA0141: Android 16 will require 16 KB page sizes, shared library
'libe_sqlite3.so' does not have a 16 KB page size.
Por que acontece: o Android está migrando de páginas de memória de 4 KB para 16 KB. Devices como o Pixel 8/9 (e a imagem de emulador de 16 KB) impõem isso. Qualquer .so nativo no seu app que foi linkado com alinhamento de segmento de 4 KB pode falhar ao carregar ou crashar nesses devices — e a partir do target Android 16 (API 36), a Google exige código nativo alinhado a 16 KB. O XA0141 é a checagem de build do .NET for Android que aponta os culpados.
Por que o SQLite está na lista: libe_sqlite3.so é a biblioteca C nativa por trás do seu banco. Por anos o conselho padrão foi SQLitePCLRaw.bundle_green, cujos binários antigos — e o SQLite do sistema em muitos devices — foram construÃdos com alinhamento de 4 KB. O SQLitePCLRaw realinhou seus builds nativos na versão 2.1.10, e a linha 3.x é alinhada por completo.
A correção: trocar o provider para SQLitePCLRaw.bundle_e_sqlite3 (2.1.10+ ou, melhor, 3.x). Essa é a correção inteira do XA0141 no lado do SQLite — e é a mesma jogada que tira o iOS do watchdog da Apple. Uma mudança de pacote, dois problemas resolvidos. Escrevi isso separadamente em SQLite vs. page sizes de 16 KB do Android: corrigindo o XA0141 — este post encaixa aquilo no quadro maior.
A causa raiz compartilhada
Os dois problemas são "você está usando o binário errado do SQLite":
| iOS | Android | |
|---|---|---|
| Binário errado | libsqlite3.dylib do sistema da Apple (tem o watchdog que aborta) |
libe_sqlite3.so antigo, alinhado a 4 KB |
| Sintoma | abort EXC_BREAKPOINT na entrada concorrente |
warning XA0141, falha de load / crash em devices de 16 KB |
| Correção | SQLitePCLRaw.bundle_e_sqlite3 (vendorizado, sem watchdog) |
SQLitePCLRaw.bundle_e_sqlite3 2.1.10+ (alinhado a 16 KB) |
Uma pegadinha que faz as pessoas acharem que já corrigiram: o sqlite-net-pcl ainda liga no SQLite da Apple no iOS por padrão. Olhe o .nuspec do sqlite-net-pcl atual (1.11.285 enquanto escrevo isto): o grupo de dependência net8.0-ios18.0 puxa SQLitePCLRaw.provider.sqlite3 — o provider do sistema. Ou seja, só subir a versão do pacote não muda nada no iOS; você precisa da referência explÃcita a bundle_e_sqlite3. E o SQLitePCLRaw.bundle_green nem existe para o SQLitePCLRaw 3.x.
A outra pegadinha, especÃfica do iOS: mesmo com um engine vendorizado, fechar a conexão sob uma query ativa é comportamento indefinido. Você troca um abort() limpo por SQLITE_MISUSE, um use‑after‑free, ou corrupção silenciosa. Então o crash de iOS precisa da troca de engine e de uma disciplina de concorrência.
A Solução, Parte A: troque o engine do SQLite (corrige XA0141, remove o watchdog do iOS)
<ItemGroup>
<PackageReference Include="sqlite-net-pcl" Version="1.11.285" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
</ItemGroup>
Remova SQLitePCLRaw.bundle_green, qualquer SQLitePCLRaw.provider.dynamic_* e qualquer pin manual de SQLitePCLRaw.lib.e_sqlite3.*. Depois seja explÃcito na inicialização no MauiProgram:
public static MauiApp CreateMauiApp()
{
SQLitePCL.Batteries_V2.Init(); // carrega o e_sqlite3 embutido, alinhado, sem watchdog
var builder = MauiApp.CreateBuilder();
// ...
}
Valide:
- Android: clean + rebuild — o
XA0141sumiu. Rode numa imagem de emulador de 16 KB ou num Pixel 8/9 com a opção de desenvolvedor de 16 KB; as operações de banco funcionam. - iOS: num device real, rode os fluxos que crashavam (login + sync, backup, restauração). E confirme que o acesso com device bloqueado ainda funciona se você depende de
ProtectionCompleteUntilFirstUserAuthentication— veja o checklist abaixo.
A Solução, Parte B: serialize o ciclo de vida da conexão (o crash de iOS precisa disso)
Transformei a classe do banco na única dona do ciclo de vida da conexão e coloquei um portão de dois modos na frente dela:
- Posse compartilhada — toda leitura e escrita pega uma. As posses não bloqueiam umas às outras (o lock interno do
sqlite-netcontinua serializando as chamadas nativas de fato); elas só bloqueiam enquanto uma operação de manutenção está pendente ou rodando. - Posse exclusiva — fechar, apagar, substituir e reabrir pegam essa. Ela barra novas operações, espera as em voo drenarem, e segura o portão pelo ciclo inteiro da manutenção.
Passo 1: O portão
private static readonly SemaphoreSlim _exclusiveGate = new(1, 1);
private static readonly object _operationSync = new();
private static int _activeOperations;
private static TaskCompletionSource<bool>? _operationsDrained;
// Permite que chamadas Db.* feitas de *dentro* de um bloco de manutenção exclusivo
// pulem o portão em que dariam deadlock.
private static readonly AsyncLocal<bool> _inExclusiveScope = new();
public static async Task<IDisposable> EnterOperationAsync(CancellationToken ct = default)
{
if (_inExclusiveScope.Value)
return NoOpLease.Instance;
// Passa pelo portão exclusivo só para registrar — não o mantém preso.
await _exclusiveGate.WaitAsync(ct).ConfigureAwait(false);
try
{
lock (_operationSync)
{
_activeOperations++;
}
}
finally
{
_exclusiveGate.Release();
}
return new OperationLease();
}
private static void ExitOperation()
{
lock (_operationSync)
{
_activeOperations--;
if (_activeOperations == 0)
_operationsDrained?.TrySetResult(true);
}
}
O lado exclusivo segura o semáforo pela vida inteira, então nenhuma nova operação consegue se registrar enquanto a manutenção roda:
private static async Task<IDisposable> EnterExclusiveAsync(CancellationToken ct = default)
{
await _exclusiveGate.WaitAsync(ct).ConfigureAwait(false);
Task drained;
lock (_operationSync)
{
if (_activeOperations == 0)
return new ExclusiveLease();
_operationsDrained = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
drained = _operationsDrained.Task;
}
try
{
// Espera limitada — uma posse vazada não pode congelar o banco para sempre.
await drained.WaitAsync(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false);
return new ExclusiveLease();
}
catch
{
lock (_operationSync)
{
_operationsDrained = null;
}
_exclusiveGate.Release();
throw;
}
}
Passo 2: Uma API de manutenção atômica
Todo ponto de "fecha, mexe nos arquivos, reabre" vira uma única chamada que segura a posse exclusiva do inÃcio ao fim. E, crucialmente, todas tratam os arquivos auxiliares do WAL como parte do banco:
public static IReadOnlyList<string> DatabaseFiles => new[]
{
PathDB,
$"{PathDB}-wal",
$"{PathDB}-shm",
$"{PathDB}-journal",
};
public static Task WipeAsync(CancellationToken ct = default) =>
ExecuteExclusiveAsync(async () =>
{
await CloseInternalAsync().ConfigureAwait(false);
DeleteDatabaseFilesInternal(); // apaga .db3 + -wal + -shm + -journal
await ReopenInternalAsync().ConfigureAwait(false);
}, ct);
public static Task ReplaceWithAsync(string sourceDbPath, CancellationToken ct = default) =>
ExecuteExclusiveAsync(async () =>
{
await CloseInternalAsync().ConfigureAwait(false);
DeleteDatabaseFilesInternal();
File.Copy(sourceDbPath, PathDB, overwrite: true);
await ReopenInternalAsync().ConfigureAwait(false);
}, ct);
public static Task WithClosedDatabaseAsync(Func<Task> whileClosed, CancellationToken ct = default) =>
ExecuteExclusiveAsync(async () =>
{
await CloseInternalAsync().ConfigureAwait(false);
try
{
await whileClosed().ConfigureAwait(false); // zip / cópia do arquivo, etc.
}
finally
{
await ReopenInternalAsync().ConfigureAwait(false);
}
}, ct);
Apagar só o
.db3é um bug de corrupção de dados. Com WAL habilitado, o banco real é.db3mais-walmais-shm. Apague só o arquivo principal e, na próxima abertura, o SQLite pode reaplicar um write‑ahead log velho sobre o seu banco novo. Sempre apague — ou faça backup — do conjunto.
Passo 3: Fechar o furo do Table<T>()
A AsyncTableQuery<T> do sqlite-net é preguiçosa: a chamada terminal (ToListAsync, FirstOrDefaultAsync, CountAsync) executa depois, fora de qualquer posse. TÃnhamos ~40 dessas por app. Em vez de mexer em cada call site, o Db.Table<T>() agora devolve um wrapper fino cujos operadores de composição são puros e cujos terminais pegam uma posse e resolvem a conexão no momento da execução:
public sealed class GatedTableQuery<T> where T : new()
{
private readonly Func<AsyncTableQuery<T>, AsyncTableQuery<T>> _build;
public GatedTableQuery<T> Where(Expression<Func<T, bool>> p) => Chain(q => q.Where(p));
public GatedTableQuery<T> OrderBy<TV>(Expression<Func<T, TV>> e) => Chain(q => q.OrderBy(e));
// Take / Skip / OrderByDescending / ThenBy ...
public Task<List<T>> ToListAsync() => ExecuteAsync(q => q.ToListAsync());
public Task<T> FirstOrDefaultAsync() => ExecuteAsync(q => q.FirstOrDefaultAsync());
public Task<int> CountAsync() => ExecuteAsync(q => q.CountAsync());
private async Task<TResult> ExecuteAsync<TResult>(
Func<AsyncTableQuery<T>, Task<TResult>> terminal)
{
using var _ = await Database.EnterOperationAsync().ConfigureAwait(false);
var conn = await Database.GetReadConnectionAsync().ConfigureAwait(false);
return await terminal(_build(conn.Table<T>())).ConfigureAwait(false);
}
}
Passo 4: Cancelar o produtor antes do wipe
Um portão só drena o que já está rodando. Se uma sync em background continua enfileirando inserts, a posse exclusiva pode esperar muito — e pior: essas escritas enfileiradas caem no banco novo com dados da sessão antiga. Então o fluxo de wipe agora cancela a sync primeiro:
// Dentro de "limpar dados locais"
ServiceHelper.GetRequiredService<SyncService>().Cancel(); // sinaliza o CancellationTokenSource dele
foreach (var key in _preferenceKeysToClear)
Preferences.Remove(key);
await Database.WipeAsync(); // atômico: fecha + apaga o conjunto + reabre
await ManutencaoTabelas.CriaOuAtualizaTabelas(); // recria o schema
Setup completo de SQLite no .NET MAUI (o checklist 100%)
Este é o setup que eu usaria num app MAUI novo hoje, e o que cada peça te dá.
1. Pacotes
<ItemGroup>
<PackageReference Include="sqlite-net-pcl" Version="1.11.285" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
</ItemGroup>
sqlite-net-pcl— o ORM do Frank Krueger (praeclarum). É o pacote com[PrimaryKey],SQLiteAsyncConnection,Table<T>().SQLitePCLRaw.bundle_e_sqlite3— traz um build vendorizado e alinhado a 16 KB do SQLite em todas as plataformas, incluindo iOS e macOS. Te tira dolibsqlite3.dylibda Apple (e do watchdog que aborta) e limpa oXA0141do Android.
Não use:
SQLitePCLRaw.bundle_green— SQLite do sistema no Apple (o watchdog), nativos antigos alinhados a 4 KB no resto, e não é publicado para o SQLitePCLRaw 3.x.SQLitePCLRaw.provider.dynamic_*— outra origem do binário desalinhado/incompatÃvel.Microsoft.Data.Sqlitejunto comsqlite-net-pcl— dois ORMs, dois providers, conflitos. Escolha um.- Um pin manual de
SQLitePCLRaw.lib.e_sqlite3.*— deixe o bundle resolver as libs nativas.
2. Inicialize o provider uma vez
O sqlite-net-pcl chama Batteries_V2.Init() por você, mas com um bundle explÃcito é boa prática ser determinÃstico no MauiProgram:
public static MauiApp CreateMauiApp()
{
SQLitePCL.Batteries_V2.Init();
var builder = MauiApp.CreateBuilder();
// ...
}
3. Uma conexão, criada uma vez, mantida para sempre
SQLiteAsyncConnection não é seguro instanciar múltiplas vezes contra o mesmo arquivo. Use um singleton preguiçoso e registre ele (ou o serviço dono dele) como singleton no DI. Nunca dê new numa conexão por página ou por chamada de repositório — uma conexão órfã coletada pela thread do finalizador do GC é mais um jeito de cair no "illegal multi-threaded access".
4. Flags da conexão
const SQLiteOpenFlags Flags =
SQLiteOpenFlags.ReadWrite |
SQLiteOpenFlags.Create |
SQLiteOpenFlags.FullMutex | // modo serialized — reforço, não a solução
SQLiteOpenFlags.ProtectionCompleteUntilFirstUserAuthentication;
_connection = new SQLiteAsyncConnection(DbPath, Flags);
FullMutexainda vale a pena no engine vendorizado — é uma proteção real de modo serialized. Ele não substitui a sua disciplina de manutenção.ProtectionCompleteUntilFirstUserAuthenticationmantém.db3/-wal/-shmacessÃveis com o device bloqueado depois do primeiro unlock — obrigatório para trabalho em background no iOS, e a cura paradisk I/O erroraleatório logo após o resume. É no-op no Android. Essa flag é um recurso de Data Protection da Apple; depois de migrar para um engine totalmente vendorizado, valide o acesso com o device bloqueado num aparelho real.
5. Caminho do banco
public static string DbPath =>
Path.Combine(FileSystem.AppDataDirectory, "app.db3");
Use FileSystem.AppDataDirectory — nunca Environment.GetFolderPath(...). No iOS, o AppDataDirectory entra no backup do iCloud; se o banco é grande ou é puramente cache, exclua do backup (NSURLIsExcludedFromBackupKey) ou coloque em FileSystem.CacheDirectory.
6. Habilite o WAL exatamente uma vez
journal_mode = WAL é persistido no header do arquivo. Setar isso em todo launch é o que causa disk I/O error na primeira query após o resume num device recém-desbloqueado. Proteja com uma preference:
if (!Preferences.Get("db_wal_enabled", false))
{
try
{
await _connection.EnableWriteAheadLoggingAsync();
Preferences.Set("db_wal_enabled", true);
}
catch (SQLiteException ex)
{
// Não-fatal: cai no rollback journal. Tenta de novo no próximo launch.
Debug.WriteLine($"[DB] WAL adiado: {ex.Message}");
}
}
7. busy_timeout a cada launch
Por conexão, não persistido — sete toda vez que abrir:
await _connection.ExecuteAsync("PRAGMA busy_timeout=5000;");
Dá à conexão até 5 segundos para esperar um lock transitório em vez de estourar SQLITE_BUSY na hora. Recomendado para qualquer setup multi-thread com WAL.
8. Básico de performance
- Envolva escritas em lote em
RunInTransactionAsync— centenas deInsertAsyncindividuais são dolorosamente lentas. - Adicione
[Indexed]em foreign keys e em qualquer coluna que você filtra. - Prefira
QueryAsync<T>cru para joins; LINQ encadeado gera SQL pior. - Não jogue uma tabela grande inteira na memória com
ToListAsync()— filtre e pagine.
Dicas
- Trate fechar/reabrir como operação privilegiada. Se mais de um lugar no seu código chama
CloseAsync(), embrulhe todos atrás de uma única API com portão.Close(); File.Delete(); Reopen();avulso é uma race esperando para acontecer. - Leia tudo que precisar antes de fechar. Dentro de um bloco "banco fechado", qualquer
Db.*perdido reabre a conexão silenciosamente e a sua cópia do arquivo captura um banco vivo. - Checkpoint antes do backup.
PRAGMA wal_checkpoint(TRUNCATE);joga o WAL para dentro do arquivo principal, para o backup precisar só do.db3. Levar um-waldentro de um zip é como restaurações corrompem. - Cancele os produtores em background antes da manutenção, aà rode a manutenção, aà reinicie eles.
- Mantenha uma referência estática à conexão. Um
SQLiteConnectionfinalizado pelo GC rodasqlite3_closena thread do finalizador — concorrente com o que mais estiver rodando. ConfigureAwait(false)nos awaits da camada de dados. OTask.Rundosqlite-netcaptura oTaskScheduler.Current; retomar na thread de UI é um deadlock clássico no iOS.- Rode um grep nos
.csprojde todos os apps que você mantém. Os nossos tinham divergido: um app ainda nobundle_green+sqlite-net-pcl1.9.x, dois já modernos. Padronize.
Conclusão
Duas lições principais. No Android, distribua o SQLite nativo moderno alinhado a 16 KB ou o Android 15/16 vai se recusar a carregá-lo — o XA0141 é o seu aviso prévio. No iOS, o SQLite é de entrada única por conexão, imposto por abort(); uma única SQLiteAsyncConnection cuida de query‑vs‑query por você, mas no momento em que o app fecha ou substitui essa conexão — logout, backup, restauração, "limpar dados locais" — você está por conta própria.
A jogada que ajuda os dois: referencie SQLitePCLRaw.bundle_e_sqlite3 explicitamente e tire o bundle_green. Depois, para o iOS, coloque um portão na frente do ciclo de vida da conexão, torne a manutenção atômica, trate .db3 + -wal + -shm como uma unidade só, e cancele os workers em background antes de fazer o wipe.
Se o seu app MAUI já jogou disk I/O error no resume, os posts sobre edge-to-edge no Android 15 e localização (resx-lint) cobrem outros modos de falha só-em-produção da mesma base de código. Dúvidas ou uma história de guerra sua? Entre em contato.
Referências
Fontes primárias que usei ao diagnosticar e corrigir isto:
- Apple Developer Forums — "Handling race conditions with SQLite" (thread 667833) — engenheiro da Apple explica por que o
libsqlite3.dylibdo sistema aborta no uso concorrente da conexão e por que oFULLMUTEXnão resolve. - SQLite — "Using SQLite In Multi-Threaded Applications" — a referência canônica de modos de threading (
SQLITE_THREADSAFE, single-thread / multi-thread / serialized). - praeclarum/sqlite-net — issue #991: "Throwing exception in iOS14 for sqlite-net-pcl" — discussão da comunidade sobre falhas de SQLite especÃficas do iOS com
sqlite-net-pcl. - groue/GRDB.swift — issue #657: "BUG IN CLIENT OF libsqlite3.dylib: illegal multi-threaded access" — exatamente o mesmo abort pelo lado do Swift; confirmação útil do mecanismo.
- ccgus/fmdb — issue #724: mesmo log
illegal multi-threaded access— mais um datapoint cross-language. - Keith Beatty — "SQLite-net-pcl multi-threading has problems on Xamarin; change to use single connection" — o padrão de "uma conexão compartilhada" para Xamarin/MAUI.
- NuGet —
sqlite-net-pcl— li os grupos de dependência do.nuspec; o gruponet8.0-ios18.0puxaSQLitePCLRaw.provider.sqlite3(SQLite do sistema), nãoprovider.e_sqlite3. - NuGet —
SQLitePCLRaw.bundle_e_sqlite3eSQLitePCLRaw.bundle_green— histórico de versões; obundle_greenpara no 2.x, obundle_e_sqlite3segue no 3.x. - ericsink/SQLitePCL.raw — o projeto de providers/bundles; explica para o que cada
bundle_*mapeia por plataforma. - Android Developers — "Support 16 KB page sizes" — a mudança de plataforma por trás do
XA0141. - .NET for Android — build message
XA0141— a descrição oficial do warning. - Meu texto anterior: SQLite vs. page sizes de 16 KB do Android: corrigindo o warning XA0141 no .NET MAUI.
Comentários
Ficou com uma dúvida ou encontrou um problema no código? Comente abaixo — eu leio e respondo pessoalmente.