@@ -31,6 +31,11 @@ public static class SqliteConnectionEnhancer
3131
3232 private static readonly ConcurrentDictionary < string , bool > _initializedDatabases = new ( ) ;
3333
34+ // Tracks databases that have had pre-open file preparation (ReadOnly clearing, stale shm
35+ // deletion) applied. Keyed by the resolved absolute path so that relative and absolute
36+ // forms of the same path map to the same entry.
37+ private static readonly ConcurrentDictionary < string , bool > _preparedDatabases = new ( ) ;
38+
3439 /// <summary>
3540 /// Gets an optimized version of the provided connection string.
3641 /// </summary>
@@ -122,6 +127,94 @@ private static string ComputeOptimizedConnectionString(string originalConnection
122127 return builder . ToString ( ) ;
123128 }
124129
130+ /// <summary>
131+ /// Prepares the database file for opening: clears the read-only file attribute and removes
132+ /// any stale <c>.db-shm</c> file. Must be called <em>before</em> the connection is opened
133+ /// so that SQLite sees a writable file and does not fall back to a read-only pager.
134+ /// </summary>
135+ /// <remarks>
136+ /// <para>
137+ /// On Windows, MSBuild sometimes stamps the database file with <see cref="FileAttributes.ReadOnly"/>
138+ /// when it is included in the project as <c>Content</c> with <c>CopyToOutputDirectory</c>.
139+ /// SQLite's <c>sqlite3_open_v2</c> attempts <c>O_RDWR</c>; if the OS returns
140+ /// <c>EACCES</c>, it silently falls back to <c>O_RDONLY</c>, which permanently marks the
141+ /// pager as read-only. Any subsequent write attempt — including
142+ /// <c>PRAGMA journal_mode = WAL</c> — then returns <c>SQLITE_READONLY</c>. Clearing the
143+ /// attribute before the call to <c>Open()</c> prevents this fallback.
144+ /// </para>
145+ /// <para>
146+ /// The <c>.db-shm</c> file is SQLite's shared-memory hash table that indexes WAL frames.
147+ /// It contains no committed data and is always recreated on the next WAL open. A stale
148+ /// copy left by a previous crashed process can cause <c>SQLITE_READONLY_CANTINIT</c>
149+ /// because SQLite detects a header mismatch.
150+ /// </para>
151+ /// <para>
152+ /// This method runs at most once per database file per process (subsequent calls are
153+ /// no-ops). It is safe to call from multiple threads; an internal lock serializes the
154+ /// first call.
155+ /// </para>
156+ /// </remarks>
157+ /// <param name="connection">The connection that is about to be opened.</param>
158+ public static void PrepareForConnectionOpen ( DbConnection connection )
159+ {
160+ if ( connection is not SqliteConnection sqliteConnection )
161+ return ;
162+
163+ var builder = new SqliteConnectionStringBuilder ( sqliteConnection . ConnectionString ) ;
164+ var dataSource = builder . DataSource ;
165+
166+ if ( string . IsNullOrEmpty ( dataSource ) )
167+ return ;
168+
169+ // Resolve to an absolute path using the same working directory that SQLite will use,
170+ // so that relative forms (e.g. "Data Source=app.db") and absolute forms map to the
171+ // same key. Path.GetFullPath is a no-op when the path is already absolute.
172+ var dbFullPath = Path . GetFullPath ( dataSource ) ;
173+
174+ if ( _preparedDatabases . ContainsKey ( dbFullPath ) )
175+ return ;
176+
177+ var lockObj = _pragmaLocks . GetOrAdd ( dbFullPath , _ => new object ( ) ) ;
178+ lock ( lockObj )
179+ {
180+ if ( _preparedDatabases . ContainsKey ( dbFullPath ) )
181+ return ;
182+
183+ // Clear the read-only attribute from the database file so SQLite can open
184+ // it in read-write mode. This must happen before Open() is called.
185+ TryClearReadOnly ( dbFullPath ) ;
186+
187+ // Clear the read-only attribute from the WAL file so that WAL recovery can
188+ // complete. If the WAL file is read-only, SQLite falls back to a read-only
189+ // pager during recovery, which then blocks PRAGMA journal_mode = WAL.
190+ TryClearReadOnly ( dbFullPath + "-wal" ) ;
191+
192+ // Delete the stale shm file. If another process currently has the database
193+ // open the file will be locked on Windows and File.Delete will throw — we
194+ // leave it untouched in that case and let SQLite sort it out.
195+ var shmPath = dbFullPath + "-shm" ;
196+ if ( File . Exists ( shmPath ) )
197+ {
198+ try { File . Delete ( shmPath ) ; }
199+ catch { /* locked by live process — leave it alone */ }
200+ }
201+
202+ _preparedDatabases . TryAdd ( dbFullPath , true ) ;
203+ }
204+ }
205+
206+ private static void TryClearReadOnly ( string path )
207+ {
208+ try
209+ {
210+ if ( ! File . Exists ( path ) ) return ;
211+ var attrs = File . GetAttributes ( path ) ;
212+ if ( ( attrs & FileAttributes . ReadOnly ) != 0 )
213+ File . SetAttributes ( path , attrs & ~ FileAttributes . ReadOnly ) ;
214+ }
215+ catch { /* best-effort */ }
216+ }
217+
125218 /// <summary>
126219 /// Applies runtime PRAGMAs to the specified connection using default options.
127220 /// </summary>
@@ -144,24 +237,28 @@ public static void ApplyRuntimePragmas(DbConnection connection, SqliteConcurrenc
144237 var builder = new SqliteConnectionStringBuilder ( sqliteConnection . ConnectionString ) ;
145238 var dataSource = builder . DataSource ;
146239
240+ // Resolve to an absolute path so that relative forms ("Data Source=app.db") and
241+ // absolute forms of the same file map to the same _initializedDatabases key.
242+ var dbKey = string . IsNullOrEmpty ( dataSource ) ? dataSource : Path . GetFullPath ( dataSource ) ;
243+
147244 // 1. Database-scoped PRAGMAs — executed once per process per database file.
148245 // These settings are persistent (stored in the database header) and affect all
149246 // connections to the same file.
150- if ( ! _initializedDatabases . ContainsKey ( dataSource ) )
247+ if ( ! _initializedDatabases . ContainsKey ( dbKey ) )
151248 {
152- var lockObj = _pragmaLocks . GetOrAdd ( dataSource , _ => new object ( ) ) ;
249+ var lockObj = _pragmaLocks . GetOrAdd ( dbKey , _ => new object ( ) ) ;
153250 lock ( lockObj )
154251 {
155- if ( ! _initializedDatabases . ContainsKey ( dataSource ) )
252+ if ( ! _initializedDatabases . ContainsKey ( dbKey ) )
156253 {
157254 var logger = options . LoggerFactory ? . CreateLogger ( nameof ( SqliteConnectionEnhancer ) ) ;
158255
159256 // WAL mode is initialized first and in isolation so that a SQLITE_READONLY
160- // failure (most commonly SQLITE_READONLY_CANTINIT on Windows when the .db-shm
161- // file cannot be created or has wrong permissions) is caught and logged as a
162- // warning rather than crashing the entire interceptor. Without WAL the database
163- // still functions; concurrent write performance is reduced because reads and
164- // writes cannot proceed simultaneously .
257+ // failure is logged as a warning rather than crashing the entire interceptor.
258+ // PrepareForConnectionOpen (called from ConnectionOpening before sqlite3_open_v2)
259+ // handles the common root causes proactively. If WAL still fails here, the
260+ // database falls back to the default journal mode — concurrent read/write
261+ // performance is reduced but the database remains functional .
165262 try
166263 {
167264 using var walCommand = sqliteConnection . CreateCommand ( ) ;
@@ -216,7 +313,7 @@ public static void ApplyRuntimePragmas(DbConnection connection, SqliteConcurrenc
216313
217314 // Mark as initialized regardless of partial failures above so that the
218315 // failed PRAGMAs are not retried on every subsequent connection open.
219- _initializedDatabases . TryAdd ( dataSource , true ) ;
316+ _initializedDatabases . TryAdd ( dbKey , true ) ;
220317 }
221318 }
222319 }
0 commit comments