@@ -324,6 +324,109 @@ describe(module.id, function () {
324324 //expect(interop.handleof(buffer)).toBe(data.bytes);
325325 } ) ;
326326
327+ it ( "interop.bufferFromData keeps NSData alive across autorelease drain + GC" , function ( done ) {
328+ // The bug requires a MALLOC_LARGE allocation (>= 32 KB on iOS): tiny
329+ // allocations retain their bytes after free, so a stale pointer would
330+ // happen to read valid content. 64 KB plaintext -> ~87 KB base64 puts
331+ // enc squarely in the libmalloc large cache, which deterministically
332+ // reuses freed regions for a matching-size next allocation.
333+ var inputSize = 1 << 16 ;
334+ var plaintext = new Uint8Array ( inputSize ) ;
335+ for ( var i = 0 ; i < inputSize ; i ++ ) plaintext [ i ] = i & 0xff ;
336+
337+ // base64 of [0, 1, 2, 3, 4, 5] starts with "AAECAwQF" (ASCII).
338+ var expectedPrefix = [ 0x41 , 0x41 , 0x45 , 0x43 , 0x41 , 0x77 , 0x51 , 0x46 ] ;
339+
340+ var view ;
341+ ( function ( ) {
342+ var src = NSData . alloc ( ) . initWithBytesLength ( plaintext , inputSize ) ;
343+ // base64EncodedDataWithOptions returns an autoreleased NSData.
344+ var enc = src . base64EncodedDataWithOptions ( 0 ) ;
345+ view = new Uint8Array ( interop . bufferFromData ( enc ) ) ;
346+ // src and enc are pure IIFE locals — no closure captures them — so
347+ // their JS wrappers become unreachable when this function returns.
348+ } ) ( ) ;
349+
350+ // One yield is enough to drain the autorelease pool (removes enc's
351+ // autorelease +1); the gc() then finalizes the src/enc JS wrappers
352+ // (removes their wrapper +1, see ArgConverter::CreateJsWrapper).
353+ // Without the bufferFromData fix, enc is now freed.
354+ setTimeout ( function ( ) {
355+ gc ( ) ;
356+
357+ // Allocate a same-sized NSData filled with a sentinel to push the
358+ // libmalloc large cache into reusing enc's freed VM region.
359+ var fillerSize = view . byteLength ;
360+ var sentinel = new Uint8Array ( fillerSize ) ;
361+ sentinel . fill ( 0xab ) ;
362+ var reused = NSData . alloc ( ) . initWithBytesLength ( sentinel , fillerSize ) ;
363+
364+ // byteLength lives on the ArrayBuffer record, not via the data
365+ // pointer, so it stays correct even with a stale pointer.
366+ expect ( view . byteLength ) . toBe ( Math . ceil ( inputSize / 3 ) * 4 ) ;
367+ // Reading bytes dereferences the data pointer. Pre-fix this reads
368+ // 0xab (sentinel content that landed in enc's freed slot); post-fix
369+ // the BackingStore deleter keeps enc alive and we read the
370+ // original base64 bytes.
371+ for ( var i = 0 ; i < expectedPrefix . length ; i ++ ) {
372+ expect ( view [ i ] ) . toBe ( expectedPrefix [ i ] ) ;
373+ }
374+ // Spot-check a byte the sentinel would have stamped over.
375+ expect ( view [ fillerSize - 1 ] ) . not . toBe ( 0xab ) ;
376+
377+ void reused ; // keep referenced so its wrapper doesn't GC mid-spec
378+ done ( ) ;
379+ } , 0 ) ;
380+ } ) ;
381+
382+ it ( "interop.bufferFromData survives repeated alloc/yield/GC cycles" , function ( done ) {
383+ // Same MALLOC_LARGE shape as the single-shot repro above.
384+ var inputSize = 1 << 16 ;
385+ var plaintext = new Uint8Array ( inputSize ) ;
386+ for ( var i = 0 ; i < inputSize ; i ++ ) plaintext [ i ] = i & 0xff ;
387+
388+ // Production bug surfaced in <4s; with explicit gc() forcing wrapper
389+ // finalization every iteration, a handful of cycles is plenty to
390+ // catch a regression.
391+ var iterations = 4 ;
392+ var i = 0 ;
393+ var pending = null ;
394+
395+ // Scope ns/enc inside this helper so they become unreachable when it
396+ // returns; the next yield + gc() then finalizes their wrappers.
397+ function alloc ( ) {
398+ var ns = NSData . alloc ( ) . initWithBytesLength ( plaintext , inputSize ) ;
399+ var enc = ns . base64EncodedDataWithOptions ( 0 ) ;
400+ return new Uint8Array ( interop . bufferFromData ( enc ) ) ;
401+ }
402+
403+ function step ( ) {
404+ if ( pending !== null ) {
405+ // Previous setTimeout drained the autorelease pool; finalize
406+ // the ns/enc wrappers. Without the fix, enc is now freed.
407+ gc ( ) ;
408+
409+ // First-byte check is enough — a stale pointer post-recycle
410+ // reads the sentinel/new-alloc bytes which won't be 0x41.
411+ expect ( pending [ 0 ] ) . toBe ( 0x41 ) ;
412+ expect ( pending [ pending . byteLength - 1 ] ) . not . toBe ( 0 ) ;
413+ pending = null ;
414+ }
415+
416+ if ( i >= iterations ) {
417+ expect ( i ) . toBe ( iterations ) ;
418+ done ( ) ;
419+ return ;
420+ }
421+
422+ pending = alloc ( ) ;
423+ i ++ ;
424+ setTimeout ( step , 0 ) ;
425+ }
426+
427+ step ( ) ;
428+ } ) ;
429+
327430 it ( "should be possible to marshal an ArrayBuffer as void* parameter" , ( ) => {
328431 var data = NSData . alloc ( ) . initWithBase64EncodedStringOptions ( "MTIzNDU=" , NSDataBase64DecodingIgnoreUnknownCharacters ) ;
329432 var arr = new ArrayBuffer ( 5 ) ;
0 commit comments