@@ -2399,6 +2399,166 @@ def level1():
23992399 lines_no_cache = [f .lineno for f in frames_no_cache ]
24002400 self .assertEqual (lines_cached , lines_no_cache )
24012401
2402+ @skip_if_not_supported
2403+ @unittest .skipIf (
2404+ sys .platform == "linux" and not PROCESS_VM_READV_SUPPORTED ,
2405+ "Test only runs on Linux with process_vm_readv support" ,
2406+ )
2407+ def test_cache_per_thread_isolation (self ):
2408+ """Test that frame cache is per-thread and cache invalidation works independently."""
2409+ script_body = """\
2410+ import threading
2411+
2412+ lock = threading.Lock()
2413+
2414+ def sync(msg):
2415+ with lock:
2416+ sock.sendall(msg + b"\\ n")
2417+ sock.recv(1)
2418+
2419+ # Thread 1 functions
2420+ def baz1():
2421+ sync(b"t1:baz1")
2422+
2423+ def bar1():
2424+ baz1()
2425+
2426+ def blech1():
2427+ sync(b"t1:blech1")
2428+
2429+ def foo1():
2430+ bar1() # Goes down to baz1, syncs
2431+ blech1() # Returns up, goes down to blech1, syncs
2432+
2433+ # Thread 2 functions
2434+ def baz2():
2435+ sync(b"t2:baz2")
2436+
2437+ def bar2():
2438+ baz2()
2439+
2440+ def blech2():
2441+ sync(b"t2:blech2")
2442+
2443+ def foo2():
2444+ bar2() # Goes down to baz2, syncs
2445+ blech2() # Returns up, goes down to blech2, syncs
2446+
2447+ t1 = threading.Thread(target=foo1)
2448+ t2 = threading.Thread(target=foo2)
2449+ t1.start()
2450+ t2.start()
2451+ t1.join()
2452+ t2.join()
2453+ """
2454+
2455+ with self ._target_process (script_body ) as (p , client_socket , make_unwinder ):
2456+ unwinder = make_unwinder (cache_frames = True )
2457+ buffer = b""
2458+
2459+ def recv_msg ():
2460+ """Receive a single message from socket."""
2461+ nonlocal buffer
2462+ while b"\n " not in buffer :
2463+ chunk = client_socket .recv (256 )
2464+ if not chunk :
2465+ return None
2466+ buffer += chunk
2467+ msg , buffer = buffer .split (b"\n " , 1 )
2468+ return msg
2469+
2470+ def get_thread_frames (target_funcs ):
2471+ """Get frames for thread matching target functions."""
2472+ for _ in range (3 ):
2473+ try :
2474+ traces = unwinder .get_stack_trace ()
2475+ for interp in traces :
2476+ for thread in interp .threads :
2477+ funcs = [f .funcname for f in thread .frame_info ]
2478+ if any (f in funcs for f in target_funcs ):
2479+ return funcs
2480+ except RuntimeError :
2481+ pass
2482+ return None
2483+
2484+ # Track results for each sync point
2485+ results = {}
2486+
2487+ # Process 4 sync points: baz1, baz2, blech1, blech2
2488+ # With the lock, threads are serialized - handle one at a time
2489+ for _ in range (4 ):
2490+ msg = recv_msg ()
2491+ self .assertIsNotNone (msg , "Expected message from subprocess" )
2492+
2493+ # Determine which thread/function and take snapshot
2494+ if msg == b"t1:baz1" :
2495+ funcs = get_thread_frames (["baz1" , "bar1" , "foo1" ])
2496+ self .assertIsNotNone (funcs , "Thread 1 not found at baz1" )
2497+ results ["t1:baz1" ] = funcs
2498+ elif msg == b"t2:baz2" :
2499+ funcs = get_thread_frames (["baz2" , "bar2" , "foo2" ])
2500+ self .assertIsNotNone (funcs , "Thread 2 not found at baz2" )
2501+ results ["t2:baz2" ] = funcs
2502+ elif msg == b"t1:blech1" :
2503+ funcs = get_thread_frames (["blech1" , "foo1" ])
2504+ self .assertIsNotNone (funcs , "Thread 1 not found at blech1" )
2505+ results ["t1:blech1" ] = funcs
2506+ elif msg == b"t2:blech2" :
2507+ funcs = get_thread_frames (["blech2" , "foo2" ])
2508+ self .assertIsNotNone (funcs , "Thread 2 not found at blech2" )
2509+ results ["t2:blech2" ] = funcs
2510+
2511+ # Release thread to continue
2512+ client_socket .sendall (b"k" )
2513+
2514+ # Validate Phase 1: baz snapshots
2515+ t1_baz = results .get ("t1:baz1" )
2516+ t2_baz = results .get ("t2:baz2" )
2517+ self .assertIsNotNone (t1_baz , "Missing t1:baz1 snapshot" )
2518+ self .assertIsNotNone (t2_baz , "Missing t2:baz2 snapshot" )
2519+
2520+ # Thread 1 at baz1: should have foo1->bar1->baz1
2521+ self .assertIn ("baz1" , t1_baz )
2522+ self .assertIn ("bar1" , t1_baz )
2523+ self .assertIn ("foo1" , t1_baz )
2524+ self .assertNotIn ("blech1" , t1_baz )
2525+ # No cross-contamination
2526+ self .assertNotIn ("baz2" , t1_baz )
2527+ self .assertNotIn ("bar2" , t1_baz )
2528+ self .assertNotIn ("foo2" , t1_baz )
2529+
2530+ # Thread 2 at baz2: should have foo2->bar2->baz2
2531+ self .assertIn ("baz2" , t2_baz )
2532+ self .assertIn ("bar2" , t2_baz )
2533+ self .assertIn ("foo2" , t2_baz )
2534+ self .assertNotIn ("blech2" , t2_baz )
2535+ # No cross-contamination
2536+ self .assertNotIn ("baz1" , t2_baz )
2537+ self .assertNotIn ("bar1" , t2_baz )
2538+ self .assertNotIn ("foo1" , t2_baz )
2539+
2540+ # Validate Phase 2: blech snapshots (cache invalidation test)
2541+ t1_blech = results .get ("t1:blech1" )
2542+ t2_blech = results .get ("t2:blech2" )
2543+ self .assertIsNotNone (t1_blech , "Missing t1:blech1 snapshot" )
2544+ self .assertIsNotNone (t2_blech , "Missing t2:blech2 snapshot" )
2545+
2546+ # Thread 1 at blech1: bar1/baz1 should be GONE (cache invalidated)
2547+ self .assertIn ("blech1" , t1_blech )
2548+ self .assertIn ("foo1" , t1_blech )
2549+ self .assertNotIn ("bar1" , t1_blech , "Cache not invalidated: bar1 still present" )
2550+ self .assertNotIn ("baz1" , t1_blech , "Cache not invalidated: baz1 still present" )
2551+ # No cross-contamination
2552+ self .assertNotIn ("blech2" , t1_blech )
2553+
2554+ # Thread 2 at blech2: bar2/baz2 should be GONE (cache invalidated)
2555+ self .assertIn ("blech2" , t2_blech )
2556+ self .assertIn ("foo2" , t2_blech )
2557+ self .assertNotIn ("bar2" , t2_blech , "Cache not invalidated: bar2 still present" )
2558+ self .assertNotIn ("baz2" , t2_blech , "Cache not invalidated: baz2 still present" )
2559+ # No cross-contamination
2560+ self .assertNotIn ("blech1" , t2_blech )
2561+
24022562
24032563if __name__ == "__main__" :
24042564 unittest .main ()
0 commit comments