- Guides
Hold, re-INVITE, and resume without losing audio ownership
Hold and re-INVITE need separate negotiation and playback state. Run SDP direction and post-wait ownership fixtures before resume.
- Author
Udit Goenka- Editorial date
- Reading time
- 27 min

TL;DR: Hold intent, negotiated SDP directions, and playback ownership are different state. Let the SIP stack establish the effective offer-answer result, invalidate old speech when ownership changes, and recheck generation, age, clear barrier, and send permission after every wait before local audio submission.
A call resumes after hold, and an old spoken answer plays before the application responds to the caller's new request. The SIP exchange succeeded, so the integration treats all buffered audio as eligible again. The missing boundary is between permission to send media and ownership of the particular response being sent.
Begin with the effective media contract and the current response generation. The SIP troubleshooting guide provides the signaling and media checks. A successful re-INVITE can change directions, codecs, or destinations, but it cannot make an obsolete application response current.
This guide gives a small direction-compatibility function and a normalized ownership reference. Its complete standard-library fixture is synthetic. It does not implement SIP transactions, parse arbitrary SDP, operate a carrier, or prove remote acoustic playback.
The public offer-answer rules come from RFC 3264. Read them with RFC 6337, which explains SIP offer-answer usage. The stack owns exchange timing and validation; an application should consume a supported normalized result rather than rebuilding the protocol from callback names.
Table of contents
- Keep three kinds of state separate
- Read direction from the SDP author's perspective
- Distinguish hold from stream rejection
- Respect re-INVITE transaction boundaries
- Run the normalized ownership reference
- Recheck held audio after waiting
- Diagnose resume without borrowing old evidence
- Evaluate the supported integration path
- Keep protocol revisions and response generations distinct
- Reproduce a hold and resume incident
- Frequently asked questions
- Resume from current facts
Keep three kinds of state separate
Local hold intent is the application's decision about what interaction it wants. It can remain true while the stack processes an exchange or while a remote reoffer proposes different media permissions. The application must not report its intent as though it were already the negotiated session state.
Negotiated permissions describe which media directions a validated exchange permits. They are per stream and depend on the offer and answer. They can differ from the application's desired interaction policy. A session can permit sending while the application deliberately suppresses its own speech because local hold intent remains active.
Playback ownership identifies which response generation may submit audio. It changes when the application invalidates old content, changes the effective media context, or closes the session. A resumed send direction does not transfer an old response into the current generation.
Use separate fields and meaningful dispositions. A send-blocked result can mean the current local policy or negotiated direction disallows application audio. A clear-pending result means the current output barrier is not satisfied. A stale-generation result means the held chunk belongs to an obsolete response owner.
The interruption-safe guide develops the same distinction for caller interruption. Canceling an old response and changing a transport permission are separate operations. An integration needs both when hold overlaps generation, buffering, and resumed playback.
Do not equate pause with retention. Keeping a buffer in memory does not mean it remains appropriate to play later. The caller may have corrected a field, changed the task, or ended the call while the network exchange was in progress. Preserve the current conversation facts and decide whether the response must be regenerated.
The post-interruption recovery guide provides a framework for choosing whether to continue, summarize, or restart. A hold/resume path needs a similarly explicit content policy. Blindly replaying old speech is one possible behavior, but it should not emerge accidentally from a queue surviving a pause.

Keep state immutable at the local controller boundary. A snapshot of negotiated permissions should not contain a mutable dictionary that another callback can rewrite after validation. The reference uses frozen dataclasses and tuple history so returned state cannot change through an aliased nested configuration object.
Read direction from the SDP author's perspective
SDP direction attributes describe the author of that SDP. A sendonly offer says the offerer proposes to send, while a recvonly answer says the answerer proposes to receive. Reading both strings as local endpoint instructions without identifying their authors reverses the meaning of one side.
For the offerer, a sendonly offer with a recvonly answer permits sending but not receiving under that direction pair. For the answerer, those local permissions are reversed. The reference accepts a local role so the same validated pair can be interpreted from either endpoint's perspective.
RFC 3264 constrains the answer. A sendonly offer allows recvonly or inactive. A recvonly offer allows sendonly or inactive. An inactive offer allows inactive. A sendrecv offer allows the four ordinary direction choices. The complete fixture exercises every pair in that small domain.
The table uses ✓ to mean a direction pair is allowed under this bounded RFC 3264 unicast direction check. A ✗ means the pair is invalid for that check. It does not validate codecs, addresses, ports, transport security, or the entire SDP exchange.
| Offer / answer | sendrecv | sendonly | recvonly | inactive |
|---|---|---|---|---|
| sendrecv | ✓ | ✓ | ✓ | ✓ |
| sendonly | ✗ | ✗ | ✓ | ✓ |
| recvonly | ✗ | ✓ | ✗ | ✓ |
| inactive | ✗ | ✗ | ✗ | ✓ |

A missing direction attribute has a defined interpretation within the full SDP rules, including session-level and media-level context. The reference expects the parser to resolve that context into an effective direction before calling it. Passing an empty string and guessing inside the application would conceal a parsing boundary.
The current SDP base specification is RFC 8866. A normalized record should identify the media section and effective attributes it represents. The small function deliberately excludes multicast and other negotiation dimensions rather than pretending four strings describe every supported SDP session.
For a call containing more than one stream, hold can affect streams differently. Do not use one global send flag to summarize unrelated audio and video sections or multiple audio paths. This reference owns one unicast audio stream; a broader controller needs a stream identity and independent effective permissions for each supported stream.
The transport comparison guide helps locate which adapter owns those mappings. Keep protocol parsing in a maintained stack and expose a small validated contract to the application. That boundary is easier to test than a permissive controller that accepts arbitrary SDP text.
Distinguish hold from stream rejection
Directional restriction and port-zero rejection have different meanings. A directional hold can leave a negotiated media stream present with restricted send or receive behavior. A zero port rejects or removes the corresponding stream under the offer-answer rules. It is not an interchangeable spelling for ordinary hold.
The reference accepts a normalized rejected flag from the stack and disables both local permissions for that stream. It does not attempt to reintroduce the stream automatically. A later supported negotiation must establish the effective stream state before application audio can be considered for admission.
Do not infer hold solely from silence. A receiver can observe no packets because of a network fault, destination mismatch, unsupported codec, or an actual restricted direction. Signaling evidence and media evidence help distinguish those cases. The absence of sound is an observation, not a complete explanation.
The call-progress evidence guide makes a related distinction between accepted signaling and usable media. That separation remains useful after initial setup. An established call can lose or change a media path while the session still exists.

A local user interface can also display hold based on application intent before negotiation completes. That display choice should not overwrite the effective media record. Keep intent, pending transaction, and validated result available for diagnosis so an operator can tell whether the stack accepted the requested change.
A remote reoffer can arrive while local hold intent remains true. A recent public JsSIP issue about a remote re-INVITE and local hold state illustrates that integration boundary, but it does not establish behavior for every version or make its proposed patch a general solution. The application needs an explicit policy for the overlap. In the reference, validated negotiated permissions remain truthful, but local hold still blocks application speech. The fixture allows the state to contain local_hold true and negotiated send true without treating that as an impossible condition.
When the application clears local hold intent, it invalidates the previous response generation again. That is conservative for this example because content may have become stale during the pause. A different continuation policy needs its own current-facts check and a clear account of which buffered material remains eligible.
Respect re-INVITE transaction boundaries
RFC 3261 defines the SIP session and transaction mechanics, while later specifications refine offer-answer behavior. The application should not treat a re-INVITE callback as a complete effective media result before the stack validates the relevant exchange.
An ACK is not a universal offer-answer commit point. The exchange can place an offer or answer in different messages under the supported SIP rules. A valid answer in a successful response can supply negotiation evidence before a later ACK observation. The exact timing belongs to the stack and transaction context.
The reference models a validated completed offer-answer observation independently from an ACK observation. Its ACK helper does not change permissions, clear barriers, or response generation. A late ACK after termination returns the terminal disposition and cannot restore the old owner.
This separation also handles a concrete synthetic failure sequence: the stack supplies a validated SDP result in a successful response, the ACK is delayed or lost, and the session later terminates under the stack's policy. Negotiation evidence can exist in the historical record while local playback remains gated and eventually closed. No ACK can resurrect the closed session.
Do not publish a blanket rule that every failed re-INVITE means the media state never changed. RFC 6141 updates RFC 3261's re-INVITE handling and addresses cases involving changes and error responses. Interpret the offer-answer history through the stack instead of resetting an application flag from a response class alone.
The reference consequently excludes unsuccessful, partially applied, or unresolved exchanges from its input. Those remain stack outcomes with explicit integration dispositions. The small function is useful because its promise is bounded; adding a fake rollback rule would make it less correct rather than more complete.
Re-INVITE glare or request-pending behavior is a transaction concurrency issue. It does not create a second application audio owner. Let the stack handle its retry and transaction rules, and keep the current playback generation gated according to the actual validated state and application policy.
The network-resume guide describes another transition where reconnection and content freshness diverge. A path becoming available again does not establish that its old queued work should resume. Apply the same explicit ownership boundary after renegotiation.
Run the normalized ownership reference
The complete Python 3.11-or-newer fence uses only the standard library. It checks the bounded direction matrix, records immutable normalized exchange history, maintains local hold intent separately, and demonstrates final admission of one owned chunk after an awaited barrier operation.
The adapter must supply a validated completed exchange for the supported stream, a local total revision order, and a transaction identity scoped to that stream. The controller does not parse wire SIP, establish remote transaction order, implement retransmission, or decide whether an unsuccessful exchange changed session state.
from dataclasses import dataclass, replace, FrozenInstanceError
import asyncio
DIRECTIONS = ('sendrecv', 'sendonly', 'recvonly', 'inactive')
ALLOWED = {'sendrecv': DIRECTIONS,
'sendonly': ('recvonly', 'inactive'),
'recvonly': ('sendonly', 'inactive'),
'inactive': ('inactive',)}
def permissions(offer, answer, role='offerer'):
if (type(offer) is not str or type(answer) is not str or type(role) is not str
or offer not in ALLOWED or answer not in ALLOWED[offer]
or role not in ('offerer', 'answerer')):
raise ValueError('invalid direction pair or local role')
offerer_send = offer in ('sendrecv', 'sendonly') and answer in ('sendrecv', 'recvonly')
offerer_receive = offer in ('sendrecv', 'recvonly') and answer in ('sendrecv', 'sendonly')
return (offerer_send, offerer_receive) if role == 'offerer' else (offerer_receive, offerer_send)
@dataclass(frozen=True)
class Exchange:
transaction: str
revision: int
offer: str
answer: str
role: str = 'offerer'
rejected: bool = False
@dataclass(frozen=True)
class Media:
status: str = 'active'
generation: int = 0
clear_generation: int = -1
revision: int = -1
local_hold: bool = False
send: bool = False
receive: bool = False
transaction: str = ''
history: tuple = ()
def record_exchange(state, exchange):
# Stack supplies a validated completed offer/answer observation for this
# one unicast stream; this is NOT a SIP transaction implementation.
if state.status != 'active':
return state, state.status
if type(exchange.revision) is not int or exchange.revision < 0:
return state, 'invalid'
if exchange.revision < state.revision:
return state, 'stale-revision'
if (type(exchange.transaction) is not str or not exchange.transaction
or type(exchange.rejected) is not bool
or any(type(x) is not str for x in (exchange.offer, exchange.answer, exchange.role))):
return state, 'invalid'
signature = (exchange.revision, exchange.offer, exchange.answer,
exchange.role, exchange.rejected)
old = dict(state.history)
if exchange.transaction in old:
return state, 'duplicate' if old[exchange.transaction] == signature else 'conflict'
if exchange.revision == state.revision:
return state, 'conflict'
try:
send, receive = permissions(exchange.offer, exchange.answer, exchange.role)
except ValueError:
return state, 'invalid'
if len(old) >= 64:
return replace(state, status='overloaded', generation=state.generation + 1), 'overloaded'
old[exchange.transaction] = signature
return replace(state, generation=state.generation + 1, clear_generation=-1,
revision=exchange.revision, transaction=exchange.transaction,
send=send and not exchange.rejected,
receive=receive and not exchange.rejected,
history=tuple(old.items())), 'recorded'
def hold_intent(state, enabled):
if type(enabled) is not bool:
raise ValueError('boolean hold intent required')
if state.status != 'active' or state.local_hold == enabled:
return state
return replace(state, local_hold=enabled,
generation=state.generation + 1, clear_generation=-1)
def clear_done(state, generation):
if state.status != 'active' or state.generation != generation:
return state
return replace(state, clear_generation=generation)
def terminate(state, status='closed'):
if status not in ('closed', 'canceled'):
raise ValueError('unsupported terminal disposition')
if state.status != 'active':
return state
return replace(state, status=status, generation=state.generation + 1,
clear_generation=-1)
def observe_ack(state, transaction):
if state.status != 'active':
return state, state.status
if transaction != state.transaction:
return state, 'stale-transaction'
return state, 'ack-observed-not-playback-proof'
@dataclass(frozen=True)
class Chunk:
generation: int
created: int
payload: bytes
def own_chunk(generation, created, payload):
if (type(generation) is not int or generation < 0 or type(created) is not int or created < 0
or not isinstance(payload, (bytes, bytearray, memoryview))):
raise ValueError('invalid chunk')
owned = bytes(payload)
if not owned:
raise ValueError('empty payload')
return Chunk(generation, created, owned)
async def submit_after_wait(get_state, chunk, wait, now, submit):
await wait()
state = get_state()
# Single local owner; submit is synchronous local admission, no await.
if state.status != 'active':
return state.status
if chunk.generation != state.generation:
return 'stale-generation'
age = now() - chunk.created
if age < 0 or age > 20:
return 'expired-chunk'
if state.clear_generation != state.generation:
return 'clear-pending'
if state.local_hold or not state.send:
return 'send-blocked'
submit(chunk.payload)
return 'locally-submitted'
def fixtures():
for offer in DIRECTIONS:
for answer in DIRECTIONS:
if answer in ALLOWED[offer]:
a = permissions(offer, answer)
assert permissions(offer, answer, 'answerer') == tuple(reversed(a))
else:
try:
permissions(offer, answer)
except ValueError:
pass
else:
raise AssertionError('invalid pair accepted')
assert permissions('sendonly', 'recvonly') == (True, False)
assert permissions('inactive', 'inactive') == (False, False)
initial = Media()
exchange = Exchange('synthetic-tx-a', 0, 'sendrecv', 'sendrecv')
current, _ = record_exchange(initial, exchange)
assert current.send and current.receive and current.generation == 1
assert record_exchange(current, exchange)[1] == 'duplicate'
assert record_exchange(current, replace(exchange, answer='inactive'))[1] == 'conflict'
assert record_exchange(current, replace(exchange, revision=-1))[1] == 'invalid'
next_state, _ = record_exchange(current, Exchange('synthetic-tx-b', 1, 'sendonly', 'recvonly'))
assert record_exchange(next_state, replace(exchange, answer='inactive'))[1] == 'stale-revision'
rejected, _ = record_exchange(current, Exchange('rejected', 1, 'sendrecv', 'sendrecv', rejected=True))
assert not rejected.send and not rejected.receive
held = hold_intent(current, True)
remote, _ = record_exchange(held, Exchange('remote', 1, 'sendrecv', 'sendrecv', role='answerer'))
assert remote.local_hold and remote.send # Negotiated truth and app policy differ.
assert observe_ack(remote, 'synthetic-tx-a')[1] == 'stale-transaction'
# Validated 2xx SDP evidence can exist before ACK. ACK never grants playback.
assert observe_ack(current, current.transaction)[0] == current
ended = terminate(current)
assert observe_ack(ended, current.transaction)[1] == 'closed'
assert record_exchange(ended, exchange)[1] == 'closed'
assert clear_done(ended, current.generation) == ended
assert terminate(ended, 'canceled') == ended
raw = bytearray(b'ab')
chunk = own_chunk(current.generation, 1, raw)
raw[0] = 99
assert chunk.payload == b'ab'
try:
chunk.payload = b'xx'
except FrozenInstanceError:
pass
else:
raise AssertionError('mutable chunk')
async def race(kind):
box = [current]
submissions = []
async def wait():
if kind == 'new-generation': box[0] = hold_intent(box[0], True)
elif kind == 'close': box[0] = terminate(box[0])
elif kind == 'cancel': box[0] = terminate(box[0], 'canceled')
elif kind == 'hold': box[0] = clear_done(replace(box[0], local_hold=True), chunk.generation)
elif kind == 'receive-only': box[0] = clear_done(replace(box[0], send=False), chunk.generation)
elif kind != 'pending': box[0] = clear_done(box[0], chunk.generation)
await asyncio.sleep(0)
outcome = await submit_after_wait(lambda: box[0], chunk, wait,
lambda: 30 if kind in ('age', 'new-generation') else 2,
submissions.append)
expected = {'new-generation':'stale-generation', 'close':'closed',
'cancel':'canceled', 'hold':'send-blocked', 'receive-only':'send-blocked',
'pending':'clear-pending', 'age':'expired-chunk',
'ok':'locally-submitted'}[kind]
assert outcome == expected
assert bool(submissions) == (kind == 'ok')
if kind == 'new-generation':
assert box[0].status == 'active' # Old work cannot expire new owner.
for kind in ('new-generation','close','cancel','hold','receive-only','pending','age','ok'):
asyncio.run(race(kind))
assert clear_done(held, current.generation) == held
assert record_exchange(current, Exchange('bad', 1, 'sendonly', 'sendonly'))[1] == 'invalid'
assert record_exchange(current, Exchange('bad', 1, [], 'inactive'))[1] == 'invalid'
full = replace(current, history=tuple((str(i), (i, 'inactive', 'inactive', 'offerer', False)) for i in range(64)))
assert record_exchange(full, Exchange('fresh', 100, 'inactive', 'inactive'))[1] == 'overloaded'
for payload in (b'', [1, 2]):
try:
own_chunk(1, 1, payload)
except ValueError:
pass
else:
raise AssertionError('invalid chunk accepted')
print('synthetic hold/resume ownership fixtures passed')
if __name__ == '__main__':
fixtures()
The fixture uses synthetic integer ticks and a small history bound. Those numbers are local test parameters, not recommended conversational deadlines or production capacity. A deployment must choose its real clock, message bounds, and retention policy under the actual integration contract.
The chunk takes an owned byte copy, so mutation of the source bytearray cannot change held audio after inspection. Its generation and creation time are immutable. A real adapter must additionally validate the audio format and size before constructing the chunk; these two bytes are a synthetic ownership payload, not playable speech.
The history retains transaction signatures across local hold changes and clear completion. An exact current retry is a duplicate, changed content under the same current identity is a conflict, and strictly older revision delivery is stale first. Terminal closure prevents all later exchange observations from reopening active playback.
Recheck held audio after waiting
A chunk can become obsolete while its controller waits for a clear operation or writer readiness. Capture its owner when it is created, then retrieve current state after the wait. Checking generation only before waiting leaves a gap in which a new caller fact or session change can invalidate the response.
The reference checks terminal disposition, generation, age, clear barrier, and current send eligibility immediately before a synchronous local submit. There is no await between those final checks and the local admission call. If the actual writer adds another asynchronous wait, repeat the current-state checks at that next admission boundary.
Generation is checked before chunk expiry. That order matters when expiry handling has side effects in a larger controller. Old work must not expire or cancel a newer response owner. The fixture advances generation during the wait and verifies that the active new owner remains active while the old chunk receives stale-generation.

A clear acknowledgment must be scoped to the generation it cleared. The reference ignores completion for an older generation. Otherwise a delayed clear callback could accidentally open the current barrier even though the newer output path still contains material that should be removed.
The audio backpressure guide explains why queue length is insufficient for freshness. A single held chunk can expire while the visible queue is empty. This example does not implement a queue; the held-chunk age check remains necessary regardless of queue occupancy.
If you add a queue, retain generation-wide creation-order history after draining it. Removing the last queued item does not erase the fact that a later-created chunk was already admitted or observed. An older same-generation arrival must still be classified under the declared ordering policy instead of becoming acceptable because the queue happens to be empty.
The chunk-size guide supplies the separate frame-accounting boundary. Fresh ownership does not prove correct sample format or duration. A real writer should require both the current ownership contract and a supported audio representation.
Local submission is the final claim made here. It does not establish provider acknowledgment, remote packet receipt, endpoint playback, acoustic audibility, or comprehension. The playback acknowledgment guide explains how to name those stronger evidence boundaries without borrowing them from a local enqueue.
Diagnose resume without borrowing old evidence
Inspect effective direction, codec, destination, and playback owner independently. A resumed send permission can coexist with the wrong destination or an unsupported decoder on the other side. A valid destination can coexist with stale application audio. A single resumed flag cannot locate those failures.
The PCMU versus PCMA fixture guide helps diagnose a codec mapping change. A public Asterisk issue involving hold, a codec change, and overlapping old and new RTP supplies a concrete diagnostic question; its published addresses and logs are not fixture data or a universal result. If negotiation changes the law, do not decode buffered old payloads under the latest mapping. Preserve the old media context long enough to classify delayed data or reject it under the transition policy.
Keep sample rate and RTP clock separate when reading codec metadata. The 8 kHz and wideband ASR guide explains the G.722 clock exception. A generic rate field can misclassify a supported codec and make a correct session appear to have an unexplained timing defect.

For receiver timing and loss, preserve original cumulative counters and receiver identity alongside derived deltas. RFC 3550 defines RTP and its reporting context. A receiver replacement or counter reset can invalidate a simple subtraction even when the application still calls the stream by the same friendly name.
Zero is different from unavailable. A new receiver with no observed packets, a missing statistics report, and a previous receiver's final counter should remain distinguishable. Do not substitute zero for a missing value and then claim that resume produced no traffic under an observation boundary you did not have.
The observability guide helps connect safe transaction and generation identities to these observations. Retain enough original evidence to recompute a diagnostic. Avoid publishing real network destinations, raw customer media, or credential-bearing traces in a shareable incident report.
If the path stalls after resume, inspect transport and buffering separately from negotiation. The WebSocket head-of-line guide describes a case where valid old audio can arrive in a burst after a delay. The generation and age guard should still reject material that is no longer appropriate.
Evaluate the supported integration path
Start with the complete local fence. Check every allowed and invalid direction pair, both local roles, rejected streams, stale revisions, changed identities, hold intent overlapping a remote exchange, old clear completion, and termination before a late ACK. Keep the exact published code as the test input.
Then test the stack's supported exchange patterns. Include the messages that carry the offer and answer, the stack's validated effective-state event, and unsuccessful or pending transaction outcomes. The local reference cannot establish those protocol facts from a synthetic tuple, so integration evidence must remain a separate report.
Use consented bounded media trials to test the path beyond local admission. Define whether the expected result is packet receipt, successful decode, device playback, or a completed interaction. Keep missing evidence explicit rather than treating successful signaling as a replacement for a device observation.
The voice regression guide provides stable scenario identities. Reproduce hold while audio is buffered, resume after a caller correction, codec change, destination change, and call closure during awaited clearing. A passing happy-path resume is insufficient when the defect occurs only during an overlap.
Measure the pause and resume experience at named endpoints. The latency benchmark guide explains why a local barrier duration and user-perceived silence are different intervals. Keep clock domains and unavailable observations visible. This article does not supply a measured carrier or device latency result.
For an application using 60db Agent, place the ownership gate at the documented local integration boundary. The reference does not imply a private queue, codec stack, or response architecture. Its value is a public contract you can test around the interfaces your application owns.
Write the release criterion as a set of observed behaviors. Old speech stays rejected after hold, current negotiation remains truthful, late callbacks cannot reopen terminal state, and fresh audio is admitted only under the supported writer contract. Preserve a rollback path for an adapter or configuration change that violates those criteria.
Keep protocol revisions and response generations distinct
A protocol revision and a response generation answer different ordering questions. The media revision identifies a validated effective exchange under the adapter's supported ordering rule. The response generation identifies application work that is still eligible to speak. Neither should be inferred from the other's numeric value.
A caller correction can invalidate a response without any SDP change. Conversely, a validated codec or destination change can require a new media context even when the text the application intends to say is unchanged. Keeping separate identities lets the controller explain which boundary changed and why old work became ineligible.
Do not use a transaction identity as a permanent conversation identity. A SIP dialog can contain multiple transactions and media revisions, while the application can produce many response generations inside one stable media configuration. Scope deduplication to the identity's actual domain so an exact retry does not become a new owner by accident.
The reference's local revision order is supplied by a trusted normalized adapter. It is not a claim that remote callbacks arrive in causal order or that a raw CSeq number can be compared without dialog and method context. A real adapter must preserve the protocol identity needed by its stack and explain how it presents effective updates to the single local owner.
Retain enough history to classify supported retries. Draining an audio queue or clearing a writer should not delete the negotiation history that distinguishes a duplicate from changed content under the same current transaction identity. Operational cleanup and evidence retention need separate lifetimes when they serve different contracts.
Bound that history deliberately. The synthetic reference returns overload after a small number of unique exchanges rather than silently forgetting arbitrary identities. A production implementation may rotate a session-scoped history through its own supported lifecycle, but that policy needs a clear boundary. Forgetting an identity while allowing its delayed retry can reintroduce ambiguity.
Treat current state and historical evidence differently in reports. A snapshot can retain the last validated negotiation while playback is canceled or closed. That historical result should remain visible for diagnosis, but the terminal disposition must remain authoritative for new local submission. An older successful negotiation does not override a later application close.
When integrating several asynchronous components, designate one local authority for response ownership. A decoder, model callback, and writer can each finish successfully while disagreeing about which response is current. They should carry the expected generation to the final owner rather than independently deciding that successful completion grants permission to speak.
Keep clear completion and writer readiness distinct if the integration exposes both. A cleared output buffer can still have a writer that is unavailable, and an available writer can still contain output from an older response. The example combines its awaited boundary for a small demonstration; a real adapter should preserve the actual evidence each completion supplies.
Do not add another await inside the synchronous submit callback without changing the contract. The final checks protect the interval immediately before local admission under one owner. If submission can suspend, capture the expected owner again and revalidate when that wait ends. Otherwise the callback recreates the race the helper was designed to expose.
Validate the local clock contract too. Chunk creation and admission age must use compatible monotonic units within the same supported domain. Wall-clock adjustments and unrelated remote timestamps should not enter that subtraction. The synthetic fixture uses integer ticks only to test ordering and boundary behavior; it does not recommend a specific physical duration for a conversation.
Reproduce a hold and resume incident
Begin with a synthetic active stream that permits sending and receiving. Create a chunk under the current generation, but do not submit it yet. Start the awaited clear or writer-readiness operation, then apply local hold intent while that operation is suspended. The chunk should become stale before the wait completes.
Complete the old clear operation afterward. Its generation no longer matches the current owner, so it must not open the new barrier. Verify both facts: the old chunk is rejected, and the new active owner remains active. A defect can hide if a test checks only that no bytes were submitted while missing an accidental cancellation of the new state.
Next provide a validated remote reoffer that permits sending. Local hold intent remains true in this reference policy, so negotiated send permission and application send eligibility still differ. Clear the current barrier and attempt admission again. The result should be send-blocked rather than a claim that the negotiation failed.
Then remove local hold intent and establish whatever validated media state the actual stack requires for the intended resume. That transition creates a fresh application generation. Prepare fresh content from current conversation facts, complete its scoped clear condition, and admit only a chunk whose owner and age remain valid after the final wait.
Repeat the sequence with a caller correction during hold. Preserve the corrected value in the conversation state and invalidate any dependent old response. The spoken self-correction guide explains why a correction changes the intended action even when earlier words were individually recognized correctly. Resume should reflect that current intent.
Repeat it with a closed session during the wait. A completed clear or validated historical exchange must not reopen playback. The shutdown semantics guide develops terminal ownership during teardown. Hold/resume and shutdown should agree about which component can authorize another submission.
For the ACK case, begin with a stack-validated answer carried in a successful response. Keep playback gated independently. Delay the ACK observation, then terminate the session before delivering it. The reference must remain closed. This fixture establishes the separation of local state; it does not simulate retransmission timers or prove how a particular SIP stack terminates an unacknowledged exchange.
Finally, repeat the incident with a fresh generation and an already old chunk at the same time. The obsolete owner should receive stale-generation before age handling. That ordering prevents old work from triggering expiry side effects on a newer response in an integrated controller that attaches cancellation to expiry.
Keep a minimal incident packet for review: normalized exchange identities, effective directions, local hold changes, response generations, clear completions, chunk creation times, terminal transitions, and submission dispositions. Synthetic content is sufficient to reproduce the ownership race. Raw caller recordings are unnecessary when the failure is local admission logic.
Frequently asked questions
Is local hold intent the negotiated media state?
No. Intent describes what the application wants, while the negotiated state comes from a validated offer-answer exchange. They can differ while a transaction is pending or a remote reoffer arrives. Keep both fields truthful and apply the local speech policy separately.
What does sendonly mean?
It describes the author of that SDP proposing to send under the relevant offer-answer rules. A matching recvonly answer describes the other endpoint receiving. Identify which endpoint authored each description before translating the pair into local send and receive permissions.
Can a sendonly offer receive a sendonly answer?
Not under the bounded RFC 3264 unicast direction matrix used here. Its allowed answers are recvonly or inactive. The complete fixture checks invalid pairs explicitly. A broader SDP validator still needs codec, media-section, transport, and other negotiation checks.
What if the direction attribute is absent?
The SDP parser must resolve the effective direction under session-level and media-level rules before calling the reference. The pure function accepts normalized directions only. An empty string should not become an application guess that conceals the actual parser boundary.
Does inactive mean the call ended?
No. It restricts the media direction for the relevant stream under the negotiated contract. Session termination is a separate protocol and application state. The controller must preserve that distinction so a later valid exchange does not accidentally reopen a terminal call.
Is port zero another form of ordinary hold?
No. It rejects or removes the corresponding stream under the offer-answer rules. The reference disables both local permissions for a rejected stream. A later supported negotiation must establish the effective stream state; the application should not simply toggle a hold flag to resume it.
Can a remote reoffer arrive during local hold?
Yes, and the application needs an explicit policy for that overlap. This reference keeps negotiated permissions truthful while local hold intent continues blocking application speech. The two facts can coexist without pretending the remote exchange changed the application's desired interaction state.
Is ACK always the offer-answer commit point?
No. Offer and answer can appear in different supported SIP messages, and the stack owns their effective timing. The reference consumes a validated exchange independently from ACK observation. ACK does not grant playback permission or prove remote audibility.
What happens if ACK arrives after termination?
The reference returns the terminal disposition and leaves state unchanged. Historical negotiation evidence may remain in the report, but a late acknowledgment cannot reopen the call or restore an old response generation. Playback authority remains a separate current-state decision.
Does every failed re-INVITE leave media unchanged?
Do not assume that universal rule. RFC 6141 refines re-INVITE handling and addresses relevant error cases. Let the stack interpret actual transaction and offer-answer history. This small reference excludes unresolved and unsuccessful exchanges instead of implementing a misleading rollback shortcut.
Does glare create a second audio owner?
No. It is a transaction concurrency outcome handled by the SIP stack's supported rules. Application response ownership remains under its own controller. Keep audio gated until the actual validated media state and current local policy permit the intended submission.
Why invalidate old speech when hold changes?
The caller or session can change while playback is paused. A fresh send direction does not make old content current. This reference takes a conservative new-generation policy, so resumed interaction prepares eligible fresh audio rather than silently replaying a stale response.
Why check generation after an await?
Another event can invalidate the response while the controller waits for clearing or writer readiness. A pre-wait check cannot cover that interval. Fetch current state afterward and repeat the ownership, age, barrier, and permission checks immediately before local admission.
Why check generation before expiry?
In a larger controller, expiry handling may have cancellation side effects. Obsolete work must not expire or terminate the newer owner. The fixture advances generation during the wait and verifies that the stale chunk is rejected while the current active state remains intact.
Can one held chunk expire with an empty queue?
Yes. Queue occupancy does not describe the age of material already held by a consumer. Check the chunk's creation time at final admission. Draining a queue must also preserve generation-wide ordering history if the broader controller relies on creation order.
What does a clear barrier establish?
It establishes the local output-clear condition defined by that adapter for a particular generation. An older completion must not open a newer barrier. The reference scopes clear completion to the current generation and does not claim that it proves remote acoustic silence.
Does local submission prove the caller heard audio?
No. It proves only admission at the named local writer boundary. Provider acknowledgment, endpoint receipt, playback, acoustic audibility, and comprehension require their own observations. Keep those claims separate when reporting resume success or diagnosing a missing response.
What should happen after a codec change?
Use the stack's new validated media contract and an explicit transition policy for old buffered data. Do not decode old payloads under the latest law by accident. Validate format mapping and current response ownership independently before allowing fresh application audio.
Why retain original receiver counters?
Derived deltas can become misleading after a receiver identity change or counter reset. Original counters, units, and source identity let a reviewer recompute the result. Keep zero distinct from unavailable so a missing report does not become a false no-traffic measurement.
Which tests require a real integration environment?
Actual SIP exchange behavior, packet routing, codec interoperability, provider acknowledgments, and device playback require their own boundaries. The synthetic fence tests local normalized logic only. Use consented bounded trials and preserve missing evidence rather than claiming a carrier benchmark from a local replay.
Resume from current facts
Let the stack establish effective media permissions, keep local hold intent explicit, and give each eligible response a current owner. A resumed direction or late ACK must not restore obsolete speech or terminal state.
Run the complete local fixture, then validate the stack and writer adapters. Recheck current generation, age, clear barrier, and send policy after waiting. Record the strongest observation available, so local submission remains distinct from remote playback and a successful conversation.
