1
0
mirror of https://github.com/craigerl/aprsd.git synced 2026-08-16 00:23:34 -04:00

Compare commits

...

9 Commits

Author SHA1 Message Date
hemna e417d3040b chore(deps): upgrade all pinned dependencies to latest
Key security upgrades:
- urllib3: 2.6.3 → 2.7.0 (fixes CVE-2026-decompression bypass)
- requests: 2.32.5 → 2.34.2 (fixes CVE-2026-25645 insecure temp file)
- idna: 3.11 → 3.18 (fixes CVE-2026-45409 crafted input DoS)
- pygments: 2.19.2 → 2.20.0 (fixes CVE-2026-4539 ReDoS)

Also bumps: attrs, bitarray, certifi, charset-normalizer, click,
importlib-metadata, markdown-it-py, oslo-config, oslo-i18n, packaging,
pytz, rich, stevedore, update-checker, wrapt, zipp
2026-06-10 15:57:21 -04:00
hemna d06390add8 Merge pull request #228 from craigerl/fix/dupe-filter-processed-flag
Fix DupePacketFilter checking wrong packet's processed flag
2026-06-02 15:47:38 -04:00
hemna db3a0428c0 Fix DupePacketFilter checking wrong packet's processed flag
DupePacketFilter.filter() was checking packet.processed (the newly
arrived duplicate) instead of found.processed (the previously stored
packet). Since all freshly decoded packets have processed=False by
default, the dupe detection branch was never reachable — every
duplicate message was passed through and re-processed.

This caused KM6LYW's retransmit of msg:9028 (via a different
digipeater path 63s after first receipt) to trigger NearestPlugin
a second time, sending 4 reply packets instead of 2 and resetting
the AckPacket retry counter back to (1 of 3).

Fix: check found.processed instead of packet.processed.
Update existing tests to reflect the correct variable under test.
Add regression test test_filter_aprs_retransmit_via_different_digi
that reproduces the exact production scenario.
2026-06-02 11:17:24 -04:00
hemna b064ac97a4 feat: add APRS Chat bulletin script 2026-05-21 14:52:03 -04:00
hemna 5cc918e5c2 fix: add socket lock to prevent TX/RX race causing stream corruption on retransmits
The RX reader thread sets setblocking(0) and the TX writer (via aprslib
sendall) sets setblocking(1) on the same socket without synchronization.
This race condition causes partial writes where other stations' APRS-IS
stream data gets concatenated onto retransmitted packets.

Add a shared _socket_lock between send() and _socket_readlines() so the
socket blocking mode is never changed by one thread while the other is
mid-operation.
2026-05-14 16:55:57 -04:00
hemna 9146ff76c9 fix: remove stale .client attribute access in send_message command
APRSDClient no longer has a .client property after the driver refactor
(commit 1c39546). Instantiating APRSDClient() is sufficient to trigger
connection via auto_connect=True.
2026-05-13 23:17:38 -04:00
hemna 0c515d45fe fix: filter stale BeaconPackets from PacketTrack on load from disk
Older versions persisted BeaconPackets to packettrack.json. On restart
these zombie beacons would be retransmitted by the scheduler. Now
PacketTrack.load() strips any BeaconPackets from the persisted data.

Workaround: delete ~/.config/aprsd/packettrack.json before restarting.
2026-05-13 12:14:21 -04:00
hemna d3281cff0b test: add tests for beacon/ack flood prevention and scheduler timing guards
Tests cover:
- BeaconPacket skipped in PacketTrack.tx() (fire-and-forget)
- AckPacket send_count not reset when same ack already tracked
- Heavy traffic scenario with 5 digi paths for same message
- Scheduler timing guards prevent threadpool race conditions
- Scheduler cleanup of max-retry packets
- MessagePacket still allows re-send (existing behavior preserved)
2026-05-13 11:44:49 -04:00
hemna d8134c4531 fix: prevent beacon and ack packet floods from PacketTrack retries
BeaconPackets are now skipped in PacketTrack — they are fire-and-forget
and never receive an ack, so tracking them caused the scheduler to
re-transmit them as unwanted duplicates.

AckPackets already being tracked are no longer reset when the same
message arrives via multiple digipeater paths, which was restarting
the retry counter and flooding RF with duplicate acks.

Added timing guards in both scheduler loops to prevent threadpool race
conditions where multiple workers could fire before send_count was
incremented.
2026-05-13 11:36:25 -04:00
13 changed files with 715 additions and 34 deletions
+23 -3
View File
@@ -44,6 +44,13 @@ class APRSLibClient(aprslib.IS):
select_timeout = 1
lock = threading.Lock()
# Shared lock between RX (reader) and TX (writer) to prevent
# socket state races. The reader sets setblocking(0) and the
# writer's sendall (in aprslib) sets setblocking(1). Without
# synchronization, these can race causing partial writes and
# stream corruption on retransmits.
_socket_lock = threading.Lock()
def stop(self):
self.thread_stop = True
LOG.warning('Shutdown Aprsdis client.')
@@ -54,8 +61,15 @@ class APRSLibClient(aprslib.IS):
@wrapt.synchronized(lock)
def send(self, packet: core.Packet):
"""Send an APRS Message object."""
self.sendall(packet.raw)
"""Send an APRS Message object.
Uses _socket_lock to prevent racing with the reader thread's
setblocking(0) call. The upstream aprslib sendall() sets
setblocking(1) before writing, which can corrupt in-progress
recv() calls if unsynchronized.
"""
with self._socket_lock:
self.sendall(packet.raw)
def is_alive(self):
"""If the connection is alive or not."""
@@ -141,7 +155,13 @@ class APRSLibClient(aprslib.IS):
continue
try:
short_buf = self.sock.recv(4096)
with self._socket_lock:
# Re-ensure non-blocking mode inside the lock.
# The send() path (via aprslib sendall) sets
# setblocking(1), so we must restore non-blocking
# before recv() to avoid blocking indefinitely.
self.sock.setblocking(0)
short_buf = self.sock.recv(4096)
# sock.recv returns empty if the connection drops
if not short_buf:
+1 -1
View File
@@ -130,7 +130,7 @@ def send_message(
sys.exit(0)
try:
APRSDClient().client # noqa: B018
APRSDClient() # noqa: B018
except LoginError:
sys.exit(-1)
+3 -2
View File
@@ -53,8 +53,9 @@ class DupePacketFilter:
# We haven't seen this packet before, so we process it.
return packet
if not packet.processed:
# We haven't processed this packet through the plugins.
if not found.processed:
# The previously seen packet hasn't been processed yet,
# so let this one through too.
return packet
elif abs(packet.timestamp - found.timestamp) < CONF.packet_dupe_timeout:
# If the packet came in within N seconds of the
+40 -1
View File
@@ -88,9 +88,25 @@ class PacketTrack(objectstore.ObjectStoreMixin):
self._remove(packet.ackMsgNo)
def tx(self, packet: type[core.Packet]) -> None:
"""Add a packet that was sent."""
"""Add a packet that was sent.
BeaconPackets are skipped they are fire-and-forget and never
receive an ack, so tracking them only causes the scheduler to
re-transmit them as unwanted duplicates.
AckPackets that are already being tracked are NOT reset this
prevents digipeated duplicates of the same message from restarting
the ack retry counter, which caused ack floods on RF.
"""
if isinstance(packet, core.BeaconPacket):
return
with self.lock:
key = packet.msgNo
if key in self.data and isinstance(packet, core.AckPacket):
# Already tracking this ack — don't reset send_count.
# This happens when the same message arrives via multiple
# digipeater paths and each copy triggers an ack send.
return
packet.send_count = 0
self.data[key] = packet
self.total_tracked += 1
@@ -98,6 +114,29 @@ class PacketTrack(objectstore.ObjectStoreMixin):
def remove(self, key):
self._remove(key)
def load(self):
"""Load tracked packets from disk, filtering out stale BeaconPackets.
BeaconPackets should never be retried (they are fire-and-forget),
but older versions persisted them to disk. Strip them on load so
they don't get retransmitted after a restart.
"""
super().load()
with self.lock:
stale = [
key
for key, pkt in self.data.items()
if isinstance(pkt, core.BeaconPacket)
or (isinstance(pkt, dict) and pkt.get('_type') == 'BeaconPacket')
]
for key in stale:
del self.data[key]
if stale:
LOG.info(
f'PacketTrack: removed {len(stale)} stale BeaconPacket(s) '
f'from persisted data.',
)
def _remove(self, key):
with self.lock:
try:
+19 -2
View File
@@ -267,9 +267,18 @@ class PacketSendSchedulerThread(aprsd_threads.APRSDThread):
# Check if packet is still being tracked (not acked)
if packet.send_count >= packet.retry_count:
# Max retries reached, will be cleaned up by worker
# Max retries reached, clean up
pkt_tracker.remove(msg_no)
continue
# Don't submit if we sent recently (prevents threadpool race
# where multiple workers fire before send_count is incremented)
if packet.last_send_time:
now = int(round(time.time()))
sleeptime = (packet.send_count + 1) * 31
if now - packet.last_send_time < sleeptime:
continue
# Submit send task to threadpool
# The worker will check timing and send if needed
self.executor.submit(_send_packet_worker, msg_no)
@@ -318,9 +327,17 @@ class AckSendSchedulerThread(aprsd_threads.APRSDThread):
# Check if ack is still being tracked
if packet.send_count >= self.max_retries:
# Max retries reached, will be cleaned up by worker
# Max retries reached, clean up
pkt_tracker.remove(msg_no)
continue
# Don't submit if we sent recently (prevents threadpool race
# where multiple workers fire before send_count is incremented)
if packet.last_send_time:
now = int(round(time.time()))
if now - packet.last_send_time < 31:
continue
# Submit send task to threadpool
self.executor.submit(_send_ack_worker, msg_no, self.max_retries)
@@ -0,0 +1,106 @@
# APRS Chat Bulletin Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a standalone bulletin script that announces APRS Chat on Google Play and includes short follow-up bulletin lines.
**Architecture:** Add one new shell script in `tools/` that matches the existing bulletin-script pattern already used in this repo, with a minimal `set -e` safety guard so failures are not masked by later `sleep` commands. Protect the behavior with one focused Python regression test that checks the script path, executable bit, and exact bulletin command lines.
**Tech Stack:** Bash, pytest, Python `pathlib`
---
## File Structure
| File | Action | Responsibility |
|------|--------|----------------|
| `tools/bulletin-aprschat.sh` | Create | Standalone APRS Chat Google Play bulletin sender |
| `tests/test_bulletin_scripts.py` | Create | Regression test for bulletin script presence and content |
---
## Chunk 1: APRS Chat Bulletin Script
### Task 1: Add the bulletin script with a focused regression test
**Files:**
- Create: `tests/test_bulletin_scripts.py`
- Create: `tools/bulletin-aprschat.sh`
- [ ] **Step 1: Write the failing test**
Create `tests/test_bulletin_scripts.py` with:
```python
import stat
from pathlib import Path
def test_bulletin_aprschat_script_contents():
repo_root = Path(__file__).resolve().parents[1]
script = repo_root / 'tools' / 'bulletin-aprschat.sh'
assert script.exists()
assert script.stat().st_mode & stat.S_IXUSR
lines = script.read_text().splitlines()
send_lines = [line for line in lines if line.startswith('aprsd send-message -n')]
assert send_lines == [
'aprsd send-message -n BLN0 "APRS Chat now on Google Play Store!"',
'aprsd send-message -n BLN1 "Install: https://tinyurl.com/APRSChat"',
'aprsd send-message -n BLN2 "Android app for APRS chat and messaging"',
'aprsd send-message -n BLN3 "Search Google Play for APRS Chat"',
]
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_bulletin_scripts.py -v`
Expected: FAIL because `tools/bulletin-aprschat.sh` does not exist yet.
- [ ] **Step 3: Write the minimal implementation**
Create `tools/bulletin-aprschat.sh` with:
```bash
#!/bin/bash
set -e
# Send APRS bulletins announcing APRS Chat on Google Play
source ~/devel/mine/hamradio/aprsd/.venv/bin/activate
export APRS_LOGIN=WB4BOR
export APRS_PASSWORD=24496
aprsd send-message -n BLN0 "APRS Chat now on Google Play Store!"
sleep 2
aprsd send-message -n BLN1 "Install: https://tinyurl.com/APRSChat"
sleep 2
aprsd send-message -n BLN2 "Android app for APRS chat and messaging"
sleep 2
aprsd send-message -n BLN3 "Search Google Play for APRS Chat"
sleep 2
```
- [ ] **Step 4: Make the script executable**
Run: `chmod +x tools/bulletin-aprschat.sh`
- [ ] **Step 5: Run test to verify it passes**
Run: `pytest tests/test_bulletin_scripts.py -v`
Expected: PASS
- [ ] **Step 6: Run a second verification pass**
Run: `bash -n tools/bulletin-aprschat.sh && pytest tests/test_bulletin_scripts.py -v`
Expected: PASS
- [ ] **Step 7: Commit**
```bash
git add tests/test_bulletin_scripts.py tools/bulletin-aprschat.sh
git commit -m "Add APRS Chat bulletin script"
```
@@ -0,0 +1,66 @@
# APRS Chat Google Play Bulletin Script
**Date:** 2026-05-21
**Status:** Draft
**Scope:** `tools/` bulletin helper scripts
## Overview
Add a new standalone bulletin script that announces APRS Chat on the Google Play Store and follows with a few short explanatory bulletin lines.
## Goals
1. Add a dedicated script for the APRS Chat Play Store announcement.
2. Match the existing `tools/bulletin-*.sh` style and operational pattern.
3. Keep bulletin text short, direct, and suitable for APRS bulletin usage.
## Current State
- Existing bulletin scripts in `tools/` are small standalone shell wrappers.
- They activate the local virtual environment, export APRS credentials, send a few `BLN` messages, and pause with `sleep 2` between lines.
- There is no dedicated APRS Chat Google Play bulletin script today.
## Design
### Script Shape
Create `tools/bulletin-aprschat.sh` using the same pattern as `tools/bulletin-aprsthursday.sh`, with one small safety improvement so the script exits immediately if activation or any bulletin send fails:
- `#!/bin/bash`
- `set -e`
- brief header comment describing purpose
- `source ~/devel/mine/hamradio/aprsd/.venv/bin/activate`
- export `APRS_LOGIN=WB4BOR` and `APRS_PASSWORD=24496`
- send four numbered bulletin messages with `aprsd send-message -n BLN* ...`
- pause with `sleep 2` between each message
### Bulletin Content
The script will send these lines:
1. `BLN0 APRS Chat now on Google Play Store!`
2. `BLN1 Install: https://tinyurl.com/APRSChat`
3. `BLN2 Android app for APRS chat and messaging`
4. `BLN3 Search Google Play for APRS Chat`
This keeps the first line focused on the announcement, the second on the install URL, and the remaining lines as short follow-up guidance.
## Files to Modify
1. `tools/bulletin-aprschat.sh` - new standalone bulletin script
2. `tests/test_bulletin_scripts.py` - regression test for script content and expected bulletin lines
## Testing Strategy
1. Write a failing test first asserting:
- the new script exists
- it is executable
- it contains the expected ordered `BLN0`-`BLN3` send lines
2. Add the script with the approved content.
3. Re-run the focused test to confirm it passes.
## Non-Goals
- Refactoring existing bulletin scripts into a shared helper
- Folding the APRS Chat announcement into `tools/bulletin.sh`
- Adding scheduling or automation around bulletin execution
+20 -20
View File
@@ -1,45 +1,45 @@
# This file was autogenerated by uv via the following command:
# uv pip compile --resolver backtracking --annotation-style=line requirements.in -o requirements.txt
aprslib @ git+https://github.com/hemna/aprs-python.git@09cd7a2829a2e9d28ee1566881c843cc4769e590 # via -r requirements.in
attrs==25.4.0 # via ax253, kiss3, rush
attrs==26.1.0 # via ax253, kiss3, rush
ax253==0.1.5.post1 # via kiss3
bitarray==3.8.0 # via ax253, kiss3
certifi==2025.11.12 # via requests
charset-normalizer==3.4.4 # via requests
click==8.3.1 # via -r requirements.in
bitarray==3.8.1 # via ax253, kiss3
certifi==2026.5.20 # via requests
charset-normalizer==3.4.7 # via requests
click==8.4.1 # via -r requirements.in
dataclasses-json==0.6.7 # via -r requirements.in
haversine==2.9.0 # via -r requirements.in
idna==3.11 # via requests
importlib-metadata==8.7.0 # via ax253, kiss3
idna==3.18 # via requests
importlib-metadata==9.0.0 # via ax253, kiss3
kiss3==8.0.0 # via -r requirements.in
loguru==0.7.3 # via -r requirements.in
markdown-it-py==4.0.0 # via rich
markdown-it-py==4.2.0 # via rich
marshmallow==3.26.2 # via dataclasses-json
mdurl==0.1.2 # via markdown-it-py
mypy-extensions==1.1.0 # via typing-inspect
netaddr==1.3.0 # via oslo-config
oslo-config==10.1.0 # via -r requirements.in
oslo-i18n==6.7.1 # via oslo-config
packaging==25.0 # via marshmallow
oslo-config==10.4.0 # via -r requirements.in
oslo-i18n==6.8.0 # via oslo-config
packaging==26.2 # via marshmallow
pbr==7.0.3 # via oslo-i18n
pluggy==1.6.0 # via -r requirements.in
pygments==2.19.2 # via rich
pygments==2.20.0 # via rich
pyserial==3.5 # via pyserial-asyncio
pyserial-asyncio==0.6 # via kiss3
pytz==2025.2 # via -r requirements.in
pytz==2026.2 # via -r requirements.in
pyyaml==6.0.3 # via oslo-config
requests==2.32.5 # via oslo-config, update-checker, -r requirements.in
requests==2.34.2 # via oslo-config, -r requirements.in
rfc3986==2.0.0 # via oslo-config
rich==14.2.0 # via -r requirements.in
rich==15.0.0 # via -r requirements.in
rush==2021.4.0 # via -r requirements.in
setuptools==82.0.1 # via pbr
stevedore==5.6.0 # via oslo-config
stevedore==5.8.0 # via oslo-config
thesmuggler==1.0.1 # via -r requirements.in
timeago==1.0.16 # via -r requirements.in
typing-extensions==4.15.0 # via typing-inspect
typing-inspect==0.9.0 # via dataclasses-json
tzlocal==5.3.1 # via -r requirements.in
update-checker==0.18.0 # via -r requirements.in
urllib3==2.6.3 # via requests
wrapt==2.0.1 # via -r requirements.in
zipp==3.23.0 # via importlib-metadata
update-checker==1.0.0 # via -r requirements.in
urllib3==2.7.0 # via requests
wrapt==2.2.1 # via -r requirements.in
zipp==4.1.0 # via importlib-metadata
+59 -4
View File
@@ -70,18 +70,22 @@ class TestDupePacketFilter(unittest.TestCase):
self.assertEqual(result, packet)
def test_filter_duplicate_within_timeout(self):
"""Test filter() with duplicate within timeout."""
"""Test filter() with duplicate within timeout.
The found (previously stored) packet is already processed.
The new incoming duplicate should be dropped.
"""
from oslo_config import cfg
CONF = cfg.CONF
CONF.packet_dupe_timeout = 60
packet = fake.fake_packet(msg_number='123')
packet.processed = True
packet.timestamp = 1000
mock_list_instance = mock.MagicMock()
found_packet = fake.fake_packet(msg_number='123')
found_packet.processed = True # the stored packet was already processed
found_packet.timestamp = 1050 # Within 60 second timeout
mock_list_instance.find.return_value = found_packet
self.filter.pl = mock_list_instance
@@ -92,18 +96,23 @@ class TestDupePacketFilter(unittest.TestCase):
mock_log.warning.assert_called()
def test_filter_duplicate_after_timeout(self):
"""Test filter() with duplicate after timeout."""
"""Test filter() with duplicate after timeout.
The found (previously stored) packet is already processed,
but it arrived long ago (outside the dupe timeout window).
The new incoming duplicate should be re-processed with a warning.
"""
from oslo_config import cfg
CONF = cfg.CONF
CONF.packet_dupe_timeout = 60
packet = fake.fake_packet(msg_number='123')
packet.processed = True
packet.timestamp = 2000
mock_list_instance = mock.MagicMock()
found_packet = fake.fake_packet(msg_number='123')
found_packet.processed = True # the stored packet was already processed
found_packet.timestamp = 1000 # More than 60 seconds ago
mock_list_instance.find.return_value = found_packet
self.filter.pl = mock_list_instance
@@ -112,3 +121,49 @@ class TestDupePacketFilter(unittest.TestCase):
result = self.filter.filter(packet)
self.assertEqual(result, packet) # Should pass
mock_log.warning.assert_called()
def test_filter_aprs_retransmit_via_different_digi(self):
"""Regression test for the production duplicate-reply bug.
Scenario (observed 2026-06-02 in aprsd-REPEAT logs):
09:49:14 RX MessagePacket:9028 KM6LYW...qAOKM6LYW-2REPEAT "N 2"
processed, NearestPlugin replies sent (msg 2385, 2386)
09:49:47 TX AckPacket:9028 (2 of 3) KM6LYW never got the ack
09:50:17 RX MessagePacket:9028 KM6LYW...qARGTOWNREPEAT "N 2"
same msgNo, different digipeater path, 63 seconds later
BUG: DupePacketFilter passed it through because it was
checking packet.processed (new packet, always False)
instead of found.processed (stored packet, True)
NearestPlugin ran again 4 replies sent instead of 2
The fix: check found.processed, not packet.processed.
"""
from oslo_config import cfg
CONF = cfg.CONF
CONF.packet_dupe_timeout = 300 # 5 minute default
# The retransmit arrives 63 seconds after the first receipt.
first_timestamp = 1000.0
retransmit_timestamp = first_timestamp + 63
# Incoming duplicate — freshly decoded, processed=False (always)
retransmit = fake.fake_packet(msg_number='9028')
retransmit.timestamp = retransmit_timestamp
# What PacketList holds from the first receipt — already processed
first_receipt = fake.fake_packet(msg_number='9028')
first_receipt.processed = True
first_receipt.timestamp = first_timestamp
mock_list_instance = mock.MagicMock()
mock_list_instance.find.return_value = first_receipt
self.filter.pl = mock_list_instance
with mock.patch('aprsd.packets.filters.dupe_filter.LOG') as mock_log:
result = self.filter.filter(retransmit)
self.assertIsNone(result) # Must be dropped — no duplicate reply
mock_log.warning.assert_called_once()
warning_msg = mock_log.warning.call_args[0][0]
self.assertIn('9028', warning_msg)
self.assertIn('already tracked', warning_msg)
+135 -1
View File
@@ -1,6 +1,6 @@
import unittest
from aprsd.packets import tracker
from aprsd.packets import core, tracker
from tests import fake
@@ -218,3 +218,137 @@ class TestPacketTrack(unittest.TestCase):
pt.tx(fake.fake_packet(msg_number='456'))
self.assertEqual(len(pt), 2)
def test_tx_skips_beacon_packet(self):
"""Test tx() does not track BeaconPackets.
BeaconPackets are fire-and-forget they never receive an ack,
so tracking them would cause the scheduler to retransmit them
as unwanted duplicates flooding RF.
"""
pt = tracker.PacketTrack()
beacon = core.BeaconPacket(
from_call='KFAKE',
to_call='APRS',
latitude=38.0,
longitude=-121.0,
comment='Test Beacon',
)
beacon.prepare(create_msg_number=True)
initial_total = pt.total_tracked
pt.tx(beacon)
# Beacon should NOT be tracked
self.assertEqual(len(pt), 0)
self.assertEqual(pt.total_tracked, initial_total)
def test_tx_beacon_not_tracked_even_with_retry_count(self):
"""Test tx() skips BeaconPacket regardless of retry_count setting."""
pt = tracker.PacketTrack()
beacon = core.BeaconPacket(
from_call='KFAKE',
to_call='APDW16',
latitude=38.0,
longitude=-121.0,
comment='WebChat Beacon',
)
beacon.retry_count = 3 # Even with retries set, don't track
beacon.prepare(create_msg_number=True)
pt.tx(beacon)
self.assertEqual(len(pt), 0)
def test_tx_ack_not_reset_when_already_tracked(self):
"""Test tx() does not reset send_count for an ack already being tracked.
When the same message arrives via multiple digipeater paths, each
copy triggers an ack send. The tracker must NOT reset send_count
on the existing ack, otherwise the retry counter restarts and
floods RF with duplicate acks.
"""
pt = tracker.PacketTrack()
# First ack for msgNo '8817'
ack1 = core.AckPacket(
from_call='KM6LYW',
to_call='KM6LYW-9',
msgNo='8817',
)
pt.tx(ack1)
self.assertIn('8817', pt.data)
self.assertEqual(pt.data['8817'].send_count, 0)
# Simulate ack being partially sent (scheduler incremented send_count)
pt.data['8817'].send_count = 2
# Second ack for the same msgNo (from a digi copy of the message)
ack2 = core.AckPacket(
from_call='KM6LYW',
to_call='KM6LYW-9',
msgNo='8817',
)
pt.tx(ack2)
# send_count must NOT be reset to 0
self.assertEqual(pt.data['8817'].send_count, 2)
# total_tracked should not have incremented again
self.assertEqual(pt.total_tracked, 1)
def test_tx_ack_tracked_on_first_occurrence(self):
"""Test tx() properly tracks an ack on first occurrence."""
pt = tracker.PacketTrack()
ack = core.AckPacket(
from_call='KM6LYW',
to_call='KM6LYW-9',
msgNo='100',
)
pt.tx(ack)
self.assertIn('100', pt.data)
self.assertEqual(pt.data['100'].send_count, 0)
self.assertEqual(pt.total_tracked, 1)
def test_tx_message_packet_still_resets_on_duplicate(self):
"""Test that non-ack packets still get reset if sent again.
MessagePackets may legitimately need to be re-sent with fresh
retry state (e.g., user re-sends a message).
"""
pt = tracker.PacketTrack()
pkt = fake.fake_packet(msg_number='999')
pt.tx(pkt)
pt.data['999'].send_count = 2
# Re-sending the same message should reset
pkt2 = fake.fake_packet(msg_number='999')
pt.tx(pkt2)
self.assertEqual(pt.data['999'].send_count, 0)
def test_heavy_traffic_multiple_digi_paths(self):
"""Simulate heavy traffic: same message arrives via 5 digipeater paths.
Each arrival triggers an ack. Only the first ack should be tracked.
Subsequent ack sends for the same msgNo must not reset the tracker,
preventing an ack flood on RF.
"""
pt = tracker.PacketTrack()
# Simulate 5 copies of the same message arriving via different paths
for i in range(5):
ack = core.AckPacket(
from_call='KM6LYW',
to_call='KM6LYW-9',
msgNo='8817',
)
pt.tx(ack)
# After first add, simulate partial sending
if i == 0:
pt.data['8817'].send_count = 1
# Only tracked once, send_count preserved from after first send
self.assertEqual(pt.total_tracked, 1)
self.assertEqual(pt.data['8817'].send_count, 1)
+20
View File
@@ -0,0 +1,20 @@
import stat
from pathlib import Path
def test_bulletin_aprschat_script_contents():
repo_root = Path(__file__).resolve().parents[1]
script = repo_root / 'tools' / 'bulletin-aprschat.sh'
assert script.exists()
assert script.stat().st_mode & stat.S_IXUSR
lines = script.read_text().splitlines()
send_lines = [line for line in lines if line.startswith('aprsd send-message -n')]
assert send_lines == [
'aprsd send-message -n BLN0 "APRS Chat now on Google Play Store!"',
'aprsd send-message -n BLN1 "Install: https://tinyurl.com/APRSChat"',
'aprsd send-message -n BLN2 "Android app for APRS chat and messaging"',
'aprsd send-message -n BLN3 "Search Google Play for APRS Chat"',
]
+205
View File
@@ -822,3 +822,208 @@ class TestBeaconSendThread(unittest.TestCase):
mock_log.error.assert_called()
mock_client_class.return_value.reset.assert_called()
thread.stop()
class TestSchedulerTimingGuards(unittest.TestCase):
"""Tests for scheduler timing guards that prevent threadpool race conditions.
These tests verify that the scheduler does NOT submit workers to the
threadpool when a packet was recently sent, preventing the race where
multiple workers fire before send_count is incremented.
"""
def setUp(self):
"""Set up test fixtures."""
from oslo_config import cfg
CONF = cfg.CONF
CONF.default_ack_send_count = 3
tracker.PacketTrack._instance = None
tracker.PacketTrack.data = {}
tracker.PacketTrack.total_tracked = 0
def tearDown(self):
"""Clean up after tests."""
tracker.PacketTrack._instance = None
tracker.PacketTrack.data = {}
tracker.PacketTrack.total_tracked = 0
def test_ack_scheduler_skips_recently_sent(self):
"""AckSendSchedulerThread must not re-submit if sent < 31 seconds ago.
This prevents the threadpool race where multiple workers fire for
the same ack before send_count is incremented, causing rapid-fire
duplicate acks on RF.
"""
scheduler = tx.AckSendSchedulerThread(max_workers=2)
try:
ack_packet = fake.fake_ack_packet()
ack_packet.send_count = 1
ack_packet.last_send_time = int(round(time.time())) # Just sent
mock_tracker = mock.MagicMock()
mock_tracker.keys.return_value = ['12']
mock_tracker.get.return_value = ack_packet
with mock.patch(
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
):
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
result = scheduler.loop()
self.assertTrue(result)
# Should NOT submit — sent too recently
mock_submit.assert_not_called()
finally:
scheduler.stop()
scheduler.executor.shutdown(wait=False)
def test_ack_scheduler_submits_after_31_seconds(self):
"""AckSendSchedulerThread submits worker after 31 second cooldown."""
scheduler = tx.AckSendSchedulerThread(max_workers=2)
try:
ack_packet = fake.fake_ack_packet()
ack_packet.send_count = 1
ack_packet.last_send_time = int(round(time.time())) - 32 # 32 seconds ago
mock_tracker = mock.MagicMock()
mock_tracker.keys.return_value = ['12']
mock_tracker.get.return_value = ack_packet
with mock.patch(
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
):
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
result = scheduler.loop()
self.assertTrue(result)
# Should submit — enough time has passed
mock_submit.assert_called_once()
finally:
scheduler.stop()
scheduler.executor.shutdown(wait=False)
def test_ack_scheduler_submits_first_send(self):
"""AckSendSchedulerThread submits worker on first send (no last_send_time)."""
scheduler = tx.AckSendSchedulerThread(max_workers=2)
try:
ack_packet = fake.fake_ack_packet()
ack_packet.send_count = 0
ack_packet.last_send_time = None # Never sent
mock_tracker = mock.MagicMock()
mock_tracker.keys.return_value = ['12']
mock_tracker.get.return_value = ack_packet
with mock.patch(
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
):
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
result = scheduler.loop()
self.assertTrue(result)
# Should submit — first time, no last_send_time
mock_submit.assert_called_once()
finally:
scheduler.stop()
scheduler.executor.shutdown(wait=False)
def test_packet_scheduler_skips_recently_sent(self):
"""PacketSendSchedulerThread must not re-submit if sent recently.
Similar to ack scheduler, prevents threadpool race for message
packets where multiple workers could fire before send_count updates.
"""
scheduler = tx.PacketSendSchedulerThread(max_workers=2)
try:
packet = fake.fake_packet(msg_number='123')
packet.send_count = 0
packet.retry_count = 3
packet.last_send_time = int(round(time.time())) # Just sent
mock_tracker = mock.MagicMock()
mock_tracker.keys.return_value = ['123']
mock_tracker.get.return_value = packet
with mock.patch(
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
):
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
result = scheduler.loop()
self.assertTrue(result)
# Should NOT submit — sent too recently
mock_submit.assert_not_called()
finally:
scheduler.stop()
scheduler.executor.shutdown(wait=False)
def test_packet_scheduler_submits_after_backoff(self):
"""PacketSendSchedulerThread submits after exponential backoff elapses."""
scheduler = tx.PacketSendSchedulerThread(max_workers=2)
try:
packet = fake.fake_packet(msg_number='123')
packet.send_count = 1 # Second send: backoff = (1+1)*31 = 62s
packet.retry_count = 3
packet.last_send_time = int(round(time.time())) - 63 # 63 seconds ago
mock_tracker = mock.MagicMock()
mock_tracker.keys.return_value = ['123']
mock_tracker.get.return_value = packet
with mock.patch(
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
):
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
result = scheduler.loop()
self.assertTrue(result)
# Should submit — backoff period has elapsed
mock_submit.assert_called_once()
finally:
scheduler.stop()
scheduler.executor.shutdown(wait=False)
def test_ack_scheduler_cleans_up_max_retries(self):
"""AckSendSchedulerThread removes packets that hit max retries."""
scheduler = tx.AckSendSchedulerThread(max_workers=2)
try:
ack_packet = fake.fake_ack_packet()
ack_packet.send_count = 3 # At max
mock_tracker = mock.MagicMock()
mock_tracker.keys.return_value = ['12']
mock_tracker.get.return_value = ack_packet
with mock.patch(
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
):
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
result = scheduler.loop()
self.assertTrue(result)
mock_submit.assert_not_called()
# Should have called remove to clean up
mock_tracker.remove.assert_called_once_with('12')
finally:
scheduler.stop()
scheduler.executor.shutdown(wait=False)
def test_packet_scheduler_cleans_up_max_retries(self):
"""PacketSendSchedulerThread removes packets that hit max retries."""
scheduler = tx.PacketSendSchedulerThread(max_workers=2)
try:
packet = fake.fake_packet(msg_number='123')
packet.send_count = 3
packet.retry_count = 3
mock_tracker = mock.MagicMock()
mock_tracker.keys.return_value = ['123']
mock_tracker.get.return_value = packet
with mock.patch(
'aprsd.threads.tx.tracker.PacketTrack', return_value=mock_tracker
):
with mock.patch.object(scheduler.executor, 'submit') as mock_submit:
result = scheduler.loop()
self.assertTrue(result)
mock_submit.assert_not_called()
# Should have called remove to clean up
mock_tracker.remove.assert_called_once_with('123')
finally:
scheduler.stop()
scheduler.executor.shutdown(wait=False)
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# Send APRS bulletins announcing APRS Chat on Google Play
set -e
source ~/devel/mine/hamradio/aprsd/.venv/bin/activate
export APRS_LOGIN=WB4BOR
export APRS_PASSWORD=24496
aprsd send-message -n BLN0 "APRS Chat now on Google Play Store!"
sleep 2
aprsd send-message -n BLN1 "Install: https://tinyurl.com/APRSChat"
sleep 2
aprsd send-message -n BLN2 "Android app for APRS chat and messaging"
sleep 2
aprsd send-message -n BLN3 "Search Google Play for APRS Chat"
sleep 2