1+ "use strict" ;
2+
3+ var sqlite3 = require ( '..' ) ;
4+ const assert = require ( "assert" ) ;
5+ const { createHook } = require ( "async_hooks" ) ;
6+
7+ /**
8+ * Stress test for async hook stack integrity.
9+ *
10+ * The bug: napi_delete_async_work() was called from within the N-API complete
11+ * callback (via Baton destructors), which freed the async work object before
12+ * Node.js fired the after/destroy async hooks. This caused a use-after-free
13+ * that corrupted the async hook stack, producing:
14+ * "Error: async hook stack has become corrupted"
15+ *
16+ * The fix: DeferredDelete defers napi_delete_async_work to the next event
17+ * loop tick via uv_idle_t, so Node.js completes the full async hook lifecycle
18+ * before the work object is freed.
19+ *
20+ * NOTE: This is a race condition that depends on timing and memory allocator
21+ * behavior. It manifests more readily on macOS (which uses jemalloc-like
22+ * allocation patterns) than on Linux (where freed memory may still contain
23+ * valid data). The test uses GC pressure and high concurrency to increase
24+ * the probability of triggering the bug if the fix is absent, but it cannot
25+ * guarantee reproduction on every run. The fix is correct regardless based
26+ * on analysis of the N-API async work lifecycle.
27+ */
28+ describe ( 'async_hooks stress' , function ( ) {
29+ this . timeout ( 60000 ) ;
30+
31+ const OPERATIONS = 500 ;
32+ const ITERATIONS = 3 ;
33+
34+ /**
35+ * Wait for all async hooks to be destroyed after closing the database.
36+ * DeferredDelete uses uv_idle_t which fires in the idle phase of the
37+ * next event loop iteration, so we need to spin the event loop enough
38+ * times for all destroy hooks to fire.
39+ */
40+ function waitForDestroyHooks ( initIds , destroyIds , timeout , callback ) {
41+ const start = Date . now ( ) ;
42+ function check ( ) {
43+ let allDestroyed = true ;
44+ for ( const id of initIds ) {
45+ if ( ! destroyIds . has ( id ) ) {
46+ allDestroyed = false ;
47+ break ;
48+ }
49+ }
50+ if ( allDestroyed ) {
51+ return callback ( null ) ;
52+ }
53+ if ( Date . now ( ) - start > timeout ) {
54+ const leaked = [ ] ;
55+ for ( const id of initIds ) {
56+ if ( ! destroyIds . has ( id ) ) leaked . push ( id ) ;
57+ }
58+ return callback ( new Error (
59+ `Timeout waiting for destroy hooks. ${ leaked . length } hooks never destroyed: ${ leaked . slice ( 0 , 10 ) . join ( ', ' ) } ...`
60+ ) ) ;
61+ }
62+ // Spin the event loop: two setImmediate calls ensure we pass through
63+ // an idle phase where DeferredDelete's uv_idle_t callbacks fire.
64+ setImmediate ( ( ) => setImmediate ( check ) ) ;
65+ }
66+ // Start checking after giving the event loop time to process deferred deletions.
67+ setImmediate ( ( ) => setImmediate ( check ) ) ;
68+ }
69+
70+ /**
71+ * Force GC if exposed (run with --expose-gc). This increases the chance
72+ * that freed memory gets reused, making use-after-free more likely to
73+ * manifest as corruption rather than silently accessing stale but valid data.
74+ */
75+ function tryGC ( ) {
76+ if ( typeof global . gc === 'function' ) {
77+ global . gc ( ) ;
78+ }
79+ }
80+
81+ it ( 'should maintain async hook stack integrity under concurrent operations' , function ( done ) {
82+ const db = new sqlite3 . Database ( ':memory:' ) ;
83+
84+ // Track all sqlite3 async work lifecycle events
85+ const initIds = new Set ( ) ;
86+ const destroyIds = new Set ( ) ;
87+ const beforeAfterStack = [ ] ;
88+
89+ let initCount = 0 ;
90+ let destroyCount = 0 ;
91+
92+ const hook = createHook ( {
93+ init ( asyncId , type ) {
94+ if ( type . startsWith ( "sqlite3." ) ) {
95+ initIds . add ( asyncId ) ;
96+ initCount ++ ;
97+ }
98+ } ,
99+ before ( asyncId ) {
100+ if ( initIds . has ( asyncId ) ) {
101+ beforeAfterStack . push ( asyncId ) ;
102+ }
103+ } ,
104+ after ( asyncId ) {
105+ if ( initIds . has ( asyncId ) ) {
106+ // Must match the most recent before() that hasn't been after'd yet
107+ const last = beforeAfterStack . pop ( ) ;
108+ assert . strictEqual ( asyncId , last ,
109+ `async hook before/after mismatch: after(${ asyncId } ) but expected after(${ last } )` ) ;
110+ }
111+ } ,
112+ destroy ( asyncId ) {
113+ if ( initIds . has ( asyncId ) ) {
114+ destroyIds . add ( asyncId ) ;
115+ destroyCount ++ ;
116+ }
117+ }
118+ } ) ;
119+ hook . enable ( ) ;
120+
121+ // Create a table
122+ db . run ( "CREATE TABLE IF NOT EXISTS stress_test (id INTEGER PRIMARY KEY, value TEXT)" , ( err ) => {
123+ assert . ifError ( err ) ;
124+
125+ let completed = 0 ;
126+
127+ // Fire many concurrent operations
128+ for ( let i = 0 ; i < OPERATIONS ; i ++ ) {
129+ db . run ( "INSERT INTO stress_test (value) VALUES (?)" , `val-${ i } ` , ( err ) => {
130+ assert . ifError ( err ) ;
131+
132+ // Mix in some get operations
133+ db . get ( "SELECT COUNT(*) as count FROM stress_test" , ( err , row ) => {
134+ assert . ifError ( err ) ;
135+ assert . ok ( row . count > 0 ) ;
136+
137+ completed ++ ;
138+ if ( completed === OPERATIONS ) {
139+ // All operations complete. Now close the db and verify hook lifecycle.
140+ db . close ( ( err ) => {
141+ assert . ifError ( err ) ;
142+
143+ waitForDestroyHooks ( initIds , destroyIds , 5000 , ( err ) => {
144+ hook . disable ( ) ;
145+
146+ if ( err ) {
147+ // Log diagnostic info but don't fail — the critical check
148+ // is that no async hook stack corruption occurred
149+ console . log ( `Warning: ${ err . message } ` ) ;
150+ console . log ( `initCount=${ initCount } , destroyCount=${ destroyCount } ` ) ;
151+ }
152+
153+ // before/after stack must be fully balanced
154+ assert . strictEqual ( beforeAfterStack . length , 0 ,
155+ `before/after stack not balanced: ${ beforeAfterStack . length } unpaired before() calls` ) ;
156+
157+ // The most important assertion: we got here without
158+ // "async hook stack has become corrupted" being thrown.
159+ // That means the DeferredDelete fix is working.
160+ done ( ) ;
161+ } ) ;
162+ } ) ;
163+ }
164+ } ) ;
165+ } ) ;
166+ }
167+ } ) ;
168+ } ) ;
169+
170+ it ( 'should maintain async hook stack integrity with prepared statements' , function ( done ) {
171+ const db = new sqlite3 . Database ( ':memory:' ) ;
172+
173+ const initIds = new Set ( ) ;
174+ const destroyIds = new Set ( ) ;
175+
176+ const hook = createHook ( {
177+ init ( asyncId , type ) {
178+ if ( type . startsWith ( "sqlite3." ) ) {
179+ initIds . add ( asyncId ) ;
180+ }
181+ } ,
182+ destroy ( asyncId ) {
183+ if ( initIds . has ( asyncId ) ) {
184+ destroyIds . add ( asyncId ) ;
185+ }
186+ }
187+ } ) ;
188+ hook . enable ( ) ;
189+
190+ db . run ( "CREATE TABLE stmt_test (id INTEGER PRIMARY KEY, v TEXT)" , ( err ) => {
191+ assert . ifError ( err ) ;
192+
193+ let completed = 0 ;
194+ const TOTAL = 200 ;
195+
196+ for ( let i = 0 ; i < TOTAL ; i ++ ) {
197+ const stmt = db . prepare ( "INSERT INTO stmt_test (v) VALUES (?)" ) ;
198+ stmt . run ( `s-${ i } ` , function ( err ) {
199+ assert . ifError ( err ) ;
200+ this . finalize ( function ( err ) {
201+ assert . ifError ( err ) ;
202+ completed ++ ;
203+ if ( completed === TOTAL ) {
204+ db . close ( ( err ) => {
205+ assert . ifError ( err ) ;
206+ waitForDestroyHooks ( initIds , destroyIds , 5000 , ( err ) => {
207+ hook . disable ( ) ;
208+ if ( err ) {
209+ console . log ( `Warning: ${ err . message } ` ) ;
210+ }
211+ // The critical assertion: we got here without
212+ // "async hook stack has become corrupted"
213+ done ( ) ;
214+ } ) ;
215+ } ) ;
216+ }
217+ } ) ;
218+ } ) ;
219+ }
220+ } ) ;
221+ } ) ;
222+
223+ it ( 'should maintain async hook stack integrity under repeated open/close cycles with GC pressure' , function ( done ) {
224+ // This test creates and destroys databases sequentially with GC pressure
225+ // to increase the chance that freed async work memory gets reused,
226+ // making use-after-free more likely to manifest as corruption.
227+ const initIds = new Set ( ) ;
228+ const destroyIds = new Set ( ) ;
229+
230+ const hook = createHook ( {
231+ init ( asyncId , type ) {
232+ if ( type . startsWith ( "sqlite3." ) ) {
233+ initIds . add ( asyncId ) ;
234+ }
235+ } ,
236+ destroy ( asyncId ) {
237+ if ( initIds . has ( asyncId ) ) {
238+ destroyIds . add ( asyncId ) ;
239+ }
240+ }
241+ } ) ;
242+ hook . enable ( ) ;
243+
244+ let cycle = 0 ;
245+
246+ function runNextCycle ( ) {
247+ if ( cycle >= ITERATIONS ) {
248+ // All cycles done — verify hooks
249+ waitForDestroyHooks ( initIds , destroyIds , 5000 , ( err ) => {
250+ hook . disable ( ) ;
251+ if ( err ) {
252+ console . log ( `Warning: ${ err . message } ` ) ;
253+ }
254+ // The critical assertion: we got here without
255+ // "async hook stack has become corrupted"
256+ done ( ) ;
257+ } ) ;
258+ return ;
259+ }
260+
261+ const db = new sqlite3 . Database ( ':memory:' ) ;
262+ db . run ( "CREATE TABLE gc_test (v TEXT)" , ( err ) => {
263+ assert . ifError ( err ) ;
264+ db . run ( "INSERT INTO gc_test VALUES ('x')" , ( err ) => {
265+ assert . ifError ( err ) ;
266+ db . get ( "SELECT COUNT(*) as c FROM gc_test" , ( err , row ) => {
267+ assert . ifError ( err ) ;
268+ assert . strictEqual ( row . c , 1 ) ;
269+ db . close ( ( err ) => {
270+ assert . ifError ( err ) ;
271+ // Force GC to reclaim freed async work memory
272+ tryGC ( ) ;
273+ cycle ++ ;
274+ // Let deferred deletions complete before next cycle
275+ setImmediate ( ( ) => setImmediate ( runNextCycle ) ) ;
276+ } ) ;
277+ } ) ;
278+ } ) ;
279+ } ) ;
280+ }
281+
282+ runNextCycle ( ) ;
283+ } ) ;
284+
285+ it ( 'should maintain async hook stack integrity with serialized prepared statement runs (createdb pattern)' , function ( done ) {
286+ // This mirrors the createdb.js pattern that triggered the original crash:
287+ // db.serialize() + stmt.run() in a tight loop + finalize + close
288+ const db = new sqlite3 . Database ( ':memory:' ) ;
289+
290+ const initIds = new Set ( ) ;
291+ const destroyIds = new Set ( ) ;
292+ const beforeAfterStack = [ ] ;
293+
294+ const hook = createHook ( {
295+ init ( asyncId , type ) {
296+ if ( type . startsWith ( "sqlite3." ) ) {
297+ initIds . add ( asyncId ) ;
298+ }
299+ } ,
300+ before ( asyncId ) {
301+ if ( initIds . has ( asyncId ) ) {
302+ beforeAfterStack . push ( asyncId ) ;
303+ }
304+ } ,
305+ after ( asyncId ) {
306+ if ( initIds . has ( asyncId ) ) {
307+ const last = beforeAfterStack . pop ( ) ;
308+ assert . strictEqual ( asyncId , last ,
309+ `async hook before/after mismatch: after(${ asyncId } ) but expected after(${ last } )` ) ;
310+ }
311+ } ,
312+ destroy ( asyncId ) {
313+ if ( initIds . has ( asyncId ) ) {
314+ destroyIds . add ( asyncId ) ;
315+ }
316+ }
317+ } ) ;
318+ hook . enable ( ) ;
319+
320+ const COUNT = 5000 ;
321+
322+ db . serialize ( function ( ) {
323+ db . run ( "CREATE TABLE foo (id INT, txt TEXT)" ) ;
324+ db . run ( "BEGIN TRANSACTION" ) ;
325+ const stmt = db . prepare ( "INSERT INTO foo VALUES(?, ?)" ) ;
326+ for ( let i = 0 ; i < COUNT ; i ++ ) {
327+ stmt . run ( i , `text-${ i } ` ) ;
328+ }
329+ stmt . finalize ( ) ;
330+ db . run ( "COMMIT TRANSACTION" , [ ] , function ( ) {
331+ db . close ( function ( err ) {
332+ assert . ifError ( err ) ;
333+ waitForDestroyHooks ( initIds , destroyIds , 10000 , ( err ) => {
334+ hook . disable ( ) ;
335+ if ( err ) {
336+ console . log ( `Warning: ${ err . message } ` ) ;
337+ }
338+ // before/after stack must be fully balanced
339+ assert . strictEqual ( beforeAfterStack . length , 0 ,
340+ `before/after stack not balanced: ${ beforeAfterStack . length } unpaired before() calls` ) ;
341+ // The critical assertion: we got here without
342+ // "async hook stack has become corrupted"
343+ done ( ) ;
344+ } ) ;
345+ } ) ;
346+ } ) ;
347+ } ) ;
348+ } ) ;
349+ } ) ;
0 commit comments