Skip to content

Commit a9e45bb

Browse files
committed
Protect dicts used in different threads to avoid race conditions
1 parent e9a557b commit a9e45bb

3 files changed

Lines changed: 88 additions & 77 deletions

File tree

controller/src/controller/detections_builder.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,13 @@ def prepareObjDict(scene, obj, update_visibility, include_sensors=False):
7676
if include_sensors:
7777
sensors_output = {}
7878

79+
# Copy sensor data while holding lock, then release
80+
with chain_data._lock:
81+
env_state_copy = dict(chain_data.env_sensor_state)
82+
attr_events_copy = dict(chain_data.attr_sensor_events)
83+
7984
# Environmental sensors: readings + exposure as structured object
80-
for sensor_id, state in chain_data.env_sensor_state.items():
85+
for sensor_id, state in env_state_copy.items():
8186
values = state['readings'] if 'readings' in state and state['readings'] else []
8287

8388
# Calculate total exposure (including current value if present)
@@ -94,7 +99,7 @@ def prepareObjDict(scene, obj, update_visibility, include_sensors=False):
9499
}
95100

96101
# Attribute sensors: events as structured object
97-
for sensor_id, events in chain_data.attr_sensor_events.items():
102+
for sensor_id, events in attr_events_copy.items():
98103
if events:
99104
sensors_output[sensor_id] = {
100105
'values': events

controller/src/controller/moving_object.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class ChainData:
3434
active_sensors: set = field(default_factory=set)
3535
env_sensor_state: Dict = field(default_factory=dict) # {'sensor_id': {'last_reading': (ts, val), 'exposure': {...}}}
3636
attr_sensor_events: Dict = field(default_factory=dict) # {'sensor_id': [(ts, val), ...]}
37+
_lock: Lock = field(default_factory=Lock)
3738

3839
class Chronoloc:
3940
def __init__(self, point: Point, when: datetime, bounds: Rectangle):

controller/src/controller/scene.py

Lines changed: 80 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -339,9 +339,10 @@ def processSensorData(self, jdata, when):
339339
# instead of appending, but frequent value changes can still cause unbounded growth.
340340
cur_value_float = float(cur_value)
341341
for obj in objects_in_sensor:
342-
if sensor_id in obj.chain_data.env_sensor_state:
343-
# Update exposure incrementally
344-
state = obj.chain_data.env_sensor_state[sensor_id]
342+
with obj.chain_data._lock:
343+
if sensor_id in obj.chain_data.env_sensor_state:
344+
# Update exposure incrementally
345+
state = obj.chain_data.env_sensor_state[sensor_id]
345346
last_time = state['exposure']['last_time']
346347
last_value = state['exposure']['last_value']
347348

@@ -352,38 +353,38 @@ def processSensorData(self, jdata, when):
352353
avg_value = (last_value + cur_value_float) / 2.0
353354
state['exposure']['total'] += avg_value * dt
354355

355-
state['exposure']['last_time'] = timestamp_epoch
356-
state['exposure']['last_value'] = cur_value_float
357-
358-
# Update readings array: append if value changed, update timestamp if same
359-
if 'readings' not in state:
360-
state['readings'] = []
361-
if state['readings'] and state['readings'][-1][1] == cur_value_float:
362-
# Value unchanged - update timestamp
363-
state['readings'][-1] = (timestamp_str, cur_value_float)
356+
state['exposure']['last_time'] = timestamp_epoch
357+
state['exposure']['last_value'] = cur_value_float
358+
359+
# Update readings array: append if value changed, update timestamp if same
360+
if 'readings' not in state:
361+
state['readings'] = []
362+
if state['readings'] and state['readings'][-1][1] == cur_value_float:
363+
# Value unchanged - update timestamp
364+
state['readings'][-1] = (timestamp_str, cur_value_float)
365+
else:
366+
# Value changed - append new reading
367+
state['readings'].append((timestamp_str, cur_value_float))
364368
else:
365-
# Value changed - append new reading
366-
state['readings'].append((timestamp_str, cur_value_float))
367-
else:
368-
# First reading - initialize from entry time (or first_seen for scene-wide sensors)
369-
entry_str = obj.chain_data.regions.get(sensor_id, {}).get('entered')
370-
if not entry_str:
371-
# No entry time (scene-wide sensor or pre-existing object) - use first_seen
372-
entry_str = get_iso_time(obj.first_seen)
373-
374-
entry_epoch = get_epoch_time(entry_str)
375-
dt = timestamp_epoch - entry_epoch
376-
# Only calculate initial exposure if time moved forward
377-
initial_exposure = cur_value_float * dt if dt > 0 else 0.0
378-
379-
obj.chain_data.env_sensor_state[sensor_id] = {
380-
'readings': [(timestamp_str, cur_value_float)],
381-
'exposure': {
382-
'total': initial_exposure,
383-
'last_time': timestamp_epoch,
384-
'last_value': cur_value_float
369+
# First reading - initialize from entry time (or first_seen for scene-wide sensors)
370+
entry_str = obj.chain_data.regions.get(sensor_id, {}).get('entered')
371+
if not entry_str:
372+
# No entry time (scene-wide sensor or pre-existing object) - use first_seen
373+
entry_str = get_iso_time(obj.first_seen)
374+
375+
entry_epoch = get_epoch_time(entry_str)
376+
dt = timestamp_epoch - entry_epoch
377+
# Only calculate initial exposure if time moved forward
378+
initial_exposure = cur_value_float * dt if dt > 0 else 0.0
379+
380+
obj.chain_data.env_sensor_state[sensor_id] = {
381+
'readings': [(timestamp_str, cur_value_float)],
382+
'exposure': {
383+
'total': initial_exposure,
384+
'last_time': timestamp_epoch,
385+
'last_value': cur_value_float
386+
}
385387
}
386-
}
387388

388389
elif sensor.singleton_type == "attribute":
389390
# Event history tracking - append discrete events (or update timestamp if value unchanged)
@@ -392,14 +393,15 @@ def processSensorData(self, jdata, when):
392393
# Convert to string for consistent type comparison (attributes can be non-numeric)
393394
cur_value_str = str(cur_value)
394395
for obj in objects_in_sensor:
395-
if sensor_id not in obj.chain_data.attr_sensor_events:
396-
obj.chain_data.attr_sensor_events[sensor_id] = []
397-
398-
events = obj.chain_data.attr_sensor_events[sensor_id]
399-
if events and events[-1][1] == cur_value_str:
400-
# Value unchanged - update timestamp of last event instead of appending
401-
events[-1] = (timestamp_str, cur_value_str)
402-
else:
396+
with obj.chain_data._lock:
397+
if sensor_id not in obj.chain_data.attr_sensor_events:
398+
obj.chain_data.attr_sensor_events[sensor_id] = []
399+
400+
events = obj.chain_data.attr_sensor_events[sensor_id]
401+
if events and events[-1][1] == cur_value_str:
402+
# Value unchanged - update timestamp of last event instead of appending
403+
events[-1] = (timestamp_str, cur_value_str)
404+
else:
403405
# Value changed - append new event
404406
events.append((timestamp_str, cur_value_str))
405407

@@ -617,37 +619,39 @@ def _updateRegionEvents(self, detectionType, regions, now, now_str, curObjects):
617619
if region.singleton_type == "environmental":
618620
# Use 'now' directly (already epoch time) to avoid precision loss from string conversion
619621

620-
if hasattr(region, 'value') and hasattr(region, 'lastWhen'):
621-
# Sensor has cached value - initialize with it
622-
ts_str = get_iso_time(region.lastWhen)
623-
last_value = float(region.value) if region.value is not None else None
624-
625-
# Start exposure accumulation from entry time, not sensor reading time
626-
# Exposure = value * (now - entry_time)
627-
obj.chain_data.env_sensor_state[key] = {
628-
'readings': [(ts_str, region.value)],
629-
'exposure': {
630-
'total': 0.0,
631-
'last_time': now,
632-
'last_value': last_value
622+
with obj.chain_data._lock:
623+
if hasattr(region, 'value') and hasattr(region, 'lastWhen'):
624+
# Sensor has cached value - initialize with it
625+
ts_str = get_iso_time(region.lastWhen)
626+
last_value = float(region.value) if region.value is not None else None
627+
628+
# Start exposure accumulation from entry time, not sensor reading time
629+
# Exposure = value * (now - entry_time)
630+
obj.chain_data.env_sensor_state[key] = {
631+
'readings': [(ts_str, region.value)],
632+
'exposure': {
633+
'total': 0.0,
634+
'last_time': now,
635+
'last_value': last_value
636+
}
633637
}
634-
}
635-
else:
636-
# No cached value yet
637-
obj.chain_data.env_sensor_state[key] = {
638-
'readings': [],
639-
'exposure': {
640-
'total': 0.0,
641-
'last_time': now,
642-
'last_value': None
638+
else:
639+
# No cached value yet
640+
obj.chain_data.env_sensor_state[key] = {
641+
'readings': [],
642+
'exposure': {
643+
'total': 0.0,
644+
'last_time': now,
645+
'last_value': None
646+
}
643647
}
644-
}
645648

646649
elif region.singleton_type == "attribute":
647650
# Attribute sensors only tag objects present when MQTT arrives
648651
# Do NOT initialize with cached values (those belong to other objects)
649-
if key not in obj.chain_data.attr_sensor_events:
650-
obj.chain_data.attr_sensor_events[key] = []
652+
with obj.chain_data._lock:
653+
if key not in obj.chain_data.attr_sensor_events:
654+
obj.chain_data.attr_sensor_events[key] = []
651655

652656
if (len(new) or len(old)) and now - region.when > DEBOUNCE_DELAY:
653657
log.debug("REGION EVENT", key, now_str, regionObjects, len(objects))
@@ -685,18 +689,19 @@ def _updateRegionEvents(self, detectionType, regions, now, now_str, curObjects):
685689
# Always clean up exited objects, even if debounce prevented event emission
686690
for obj in regionObjects:
687691
if obj.gid in old:
688-
obj.chain_data.regions.pop(key, None)
692+
with obj.chain_data._lock:
693+
obj.chain_data.regions.pop(key, None)
689694

690-
# Clean up sensor tracking on exit
691-
if region.singleton_type is not None:
692-
obj.chain_data.active_sensors.discard(key)
695+
# Clean up sensor tracking on exit
696+
if region.singleton_type is not None:
697+
obj.chain_data.active_sensors.discard(key)
693698

694-
# Environmental sensors: clear state on exit (data doesn't persist)
695-
if region.singleton_type == "environmental":
696-
obj.chain_data.env_sensor_state.pop(key, None)
699+
# Environmental sensors: clear state on exit (data doesn't persist)
700+
if region.singleton_type == "environmental":
701+
obj.chain_data.env_sensor_state.pop(key, None)
697702

698-
# Attribute sensors: keep event history (data persists after exit)
699-
# attr_sensor_events[key] intentionally not removed
703+
# Attribute sensors: keep event history (data persists after exit)
704+
# attr_sensor_events[key] intentionally not removed
700705

701706
return updated
702707

0 commit comments

Comments
 (0)