Skip to content

Guard CallSession playback against stale or inactive sessions #135

Description

@Justinabox

Summary

CallSession.record() and CallSession.send_dtmf() fail closed when the session is stale or inactive, but the playback helpers (play, play_sequence, and play_loop) do not check self.is_active before writing to the audio pipeline. A stale CallSession object can therefore enqueue audio onto the current audio bridge after another call has become active.

For PBX/IVR use this is a privacy and call-routing boundary issue: a handler holding an old session should not be able to play prompts/hold music into a different caller's live audio path.

Evidence

Baseline gates are healthy on main at bb90684c0957c286dc901cef829f498c2c9418e1:

$ git diff --check
# exit 0

$ PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q
587 passed in 5.97s

Affected code:

  • callstack/voice/service.py:279-281 defines CallSession.is_active as both FSM ACTIVE and service.active_call is self.
  • callstack/voice/service.py:300-309 applies that guard before recording.
  • callstack/voice/service.py:420-445 applies that guard before sending each DTMF digit.
  • callstack/voice/service.py:288-298 forwards play, play_sequence, and play_loop directly to self.service._audio without the same guard.

No-hardware repro:

PYTHONPATH=. uv run --no-project --with pyserial-asyncio python /tmp/callstack_probe_stale_playback.py

Probe output:

stale_is_active_before: False
audio_calls_from_stale_session: [('play_file', 'prompt.wav', False), ('play_sequence', ('a.wav', 'b.wav'), False), ('play_loop', 'hold.wav', True)]

The stale session reports is_active == False, but all three playback methods still invoke the audio layer.

Duplicate check

No existing issue or PR matched these targeted searches:

gh issue list --repo Justinabox/Callstack --state all --search 'stale session playback active_call in:title,body' --limit 20
gh issue list --repo Justinabox/Callstack --state all --search 'CallSession play stale active call audio in:title,body' --limit 20
gh pr list --repo Justinabox/Callstack --state all --search 'CallSession play stale session audio' --limit 20

Issue #41 tracks voicemail features, but it does not cover stale CallSession playback guards.

Expected behavior

All call-audio operations exposed through CallSession should fail closed unless that exact session is still the active call.

Suggested fix direction

Add the same if not self.is_active: raise RuntimeError("Cannot play audio without an active call") style guard to CallSession.play, play_sequence, and play_loop before touching service._audio. Keep AudioPipeline usable directly for lower-level tests if desired; the session boundary is the important public guard.

Acceptance criteria

  • A stale or inactive CallSession.play(...) raises before calling AudioPipeline.play_file.
  • A stale or inactive CallSession.play_sequence(...) raises before calling AudioPipeline.play_sequence.
  • A stale or inactive CallSession.play_loop(...) raises before calling AudioPipeline.play_loop.
  • Existing active-call playback, record, and DTMF tests remain green.

Verification gates

git diff --check
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/test_call_service.py tests/test_call_session_play_and_collect.py -q
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions