Skip to content

Commit 7a36fcf

Browse files
AmitMYautoresearchclaude
authored
Fix PoseHeaderCache race under concurrent Pose.read (#239)
* Fix PoseHeaderCache race under concurrent Pose.read PoseHeaderCache was global mutable state updated non-atomically: check_cache could match the old hash while another thread's set_cache had already replaced the header, and PoseHeader.read re-read end_offset after the check. Concurrent Pose.read calls on files with different headers could crash ("buffer is too small for requested array") or silently parse a pose with another file's header. The cache is now an immutable (hash, header, start_offset, end_offset) tuple swapped atomically; readers take a single snapshot. The legacy class attributes are kept in sync for backward compatibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Use a lock for PoseHeaderCache instead of an atomic entry tuple Per review: a plain threading.Lock over check_cache/set_cache/clear_cache is easier to follow than the snapshot tuple. check_cache still returns (header, end_offset) -- the offset must come from the same critical section, otherwise the caller re-reading the class attribute races again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: autoresearch <autoresearch@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0bbac44 commit 7a36fcf

2 files changed

Lines changed: 54 additions & 16 deletions

File tree

src/python/pose_format/pose_header.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import hashlib
22
import math
33
import struct
4+
import threading
45
from typing import BinaryIO, List, Tuple, Optional, Union
56

67
from .utils.reader import BufferReader, ConstStructs
@@ -230,36 +231,45 @@ def __str__(self):
230231

231232

232233
class PoseHeaderCache:
234+
# All access goes through _lock: an unsynchronized reader races with set_cache and
235+
# can observe a half-updated cache (e.g. the new header with the old hash/offsets).
236+
# check_cache therefore also returns end_offset, so callers position their reader
237+
# from the same consistent snapshot instead of re-reading the class attribute.
233238
start_offset: int = None
234239
end_offset: int = None
235240
hash: str = None
236241
header: 'PoseHeader' = None
242+
_lock = threading.Lock()
237243

238244
@staticmethod
239245
def calc_hash(buffer: bytes):
240246
return hashlib.md5(buffer[PoseHeaderCache.start_offset:PoseHeaderCache.end_offset]).hexdigest()
241247

242248
@staticmethod
243-
def check_cache(buffer: bytes) -> 'PoseHeader':
244-
if PoseHeaderCache.hash is None:
245-
return None
249+
def check_cache(buffer: bytes) -> Optional[Tuple['PoseHeader', int]]:
250+
with PoseHeaderCache._lock:
251+
if PoseHeaderCache.hash is None:
252+
return None
246253

247-
if PoseHeaderCache.hash == PoseHeaderCache.calc_hash(buffer):
248-
return PoseHeaderCache.header
254+
if PoseHeaderCache.hash == PoseHeaderCache.calc_hash(buffer):
255+
return PoseHeaderCache.header, PoseHeaderCache.end_offset
256+
return None
249257

250258
@staticmethod
251259
def clear_cache():
252-
PoseHeaderCache.start_offset = None
253-
PoseHeaderCache.end_offset = None
254-
PoseHeaderCache.hash = None
255-
PoseHeaderCache.header = None
260+
with PoseHeaderCache._lock:
261+
PoseHeaderCache.start_offset = None
262+
PoseHeaderCache.end_offset = None
263+
PoseHeaderCache.hash = None
264+
PoseHeaderCache.header = None
256265

257266
@staticmethod
258267
def set_cache(header: 'PoseHeader', buffer: bytes, start_offset: int, end_offset: int):
259-
PoseHeaderCache.start_offset = start_offset
260-
PoseHeaderCache.end_offset = end_offset
261-
PoseHeaderCache.header = header
262-
PoseHeaderCache.hash = PoseHeaderCache.calc_hash(buffer)
268+
with PoseHeaderCache._lock:
269+
PoseHeaderCache.start_offset = start_offset
270+
PoseHeaderCache.end_offset = end_offset
271+
PoseHeaderCache.header = header
272+
PoseHeaderCache.hash = PoseHeaderCache.calc_hash(buffer)
263273

264274

265275
class PoseHeader:
@@ -317,9 +327,12 @@ def read(reader: BufferReader) -> 'PoseHeader':
317327
PoseHeader
318328
An instance of PoseHeader.
319329
"""
320-
cached_header = PoseHeaderCache.check_cache(reader.buffer)
321-
if cached_header is not None:
322-
reader.read_offset = PoseHeaderCache.end_offset
330+
cached = PoseHeaderCache.check_cache(reader.buffer)
331+
if cached is not None:
332+
# header and end_offset come from the same atomic cache snapshot -- reading
333+
# PoseHeaderCache.end_offset here instead would race with concurrent set_cache
334+
cached_header, end_offset = cached
335+
reader.read_offset = end_offset
323336
return cached_header
324337

325338
start_offset = reader.read_offset

src/python/tests/pose_test.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import random
22
import string
3+
from concurrent.futures import ThreadPoolExecutor
34
from pathlib import Path
45
from typing import Optional, Tuple
56
from unittest import TestCase
@@ -340,6 +341,30 @@ def test_read_empty_pose_body_shape_matches_numpy_pose_body(self):
340341
self.assertEqual(empty_pose.body.confidence.shape, numpy_pose.body.confidence.shape)
341342
self.assertEqual(empty_pose.body.fps, numpy_pose.body.fps)
342343

344+
def test_concurrent_read_of_different_files_is_safe(self):
345+
# Regression test: PoseHeaderCache used to be updated non-atomically, so
346+
# concurrent Pose.read calls on files with different headers could crash
347+
# ("buffer is too small for requested array") or, worse, silently return a
348+
# pose parsed with another file's header.
349+
data_dir = Path(__file__).parent / "data"
350+
buffers = {}
351+
expected = {}
352+
for name in ["mediapipe.pose", "openpose.pose"]:
353+
with open(data_dir / name, 'rb') as f:
354+
buffers[name] = f.read()
355+
pose = Pose.read(buffers[name])
356+
expected[name] = ([c.name for c in pose.header.components], pose.body.data.shape)
357+
358+
def read_one(name):
359+
pose = Pose.read(buffers[name])
360+
return name, ([c.name for c in pose.header.components], pose.body.data.shape)
361+
362+
names = list(buffers.keys()) * 4
363+
with ThreadPoolExecutor(max_workers=8) as executor:
364+
for _ in range(30):
365+
for name, got in executor.map(read_one, names):
366+
self.assertEqual(expected[name], got)
367+
343368
def test_pose_bbox(self):
344369
data_dir = Path(__file__).parent / "data"
345370
with open(data_dir / 'mediapipe.pose', 'rb') as f:

0 commit comments

Comments
 (0)