فهرست مقاله
در آزمایش سیگنالینگ تماس، «چند بار درخواست فرستاده شد؟» بدون دانستن زمان و حالت تراکنش پرسش کاملی نیست. این درس زمانبندیِ زیرمجموعهای از INVITE را با دادهٔ ساختگی محاسبه میکند تا ارسال مجدد، پاسخ موقت و مرز مشاهده جدا باشند. هیچ بسته، شمارهٔ تلفن یا تماس واقعی وجود ندارد؛ فهرست زمانهای ارسال فقط خروجی یک مدل آفلاین است.
۱. دامنهٔ تراکنش را از صوت و تماس واقعی جدا کنیم

فرض میکنیم رویدادهای پاسخ از قبل به یک درخواست مشخص مربوطاند؛ کد نه پیام SIP را میخواند و نه branch، CSeq یا هویت پاسخ را اعتبارسنجی میکند. در آغاز، حالت calling و زمان ارسال اولیه صفر است. ساعت، عدد صحیح با واحد میلیثانیه است؛ زمان سیستم یا تأخیر شبکه خوانده نمیشود. ورودی را عمداً کوچک نگه میداریم تا هر انتقال قابلآزمون باشد.
RFC 3261، بخش ۱۷٫۱٫۱٫۲ زمانسنج A را برای ارسال مجدد INVITE روی انتقال نامطمئن و B را با مقدار ۶۴ برابر T1 توضیح میدهد. فاصلهٔ A در Calling دوبرابر میشود؛ مقدار پیشفرض T1، پانصد میلیثانیه است. ما همین مقدار را برای ساخت نمونه میگیریم، بدون ادعای اندازهگیری RTT یا توصیهٔ تنظیم شبکه.
در معماری تلفن هوشمند سازمانی مسیر صوت و کنترل مکالمه کنار هم مطرح میشوند، اما سنجههایشان یکی نیست. آزمون زمانبندی درخواست، کیفیت صوت، موفقیت اتصال، احراز هویت یا اجرای اقدام سازمانی را ثابت نمیکند. همچنین یک پاسخ نهاییِ ثبتشده در دادهٔ ساختگی، شاهد پاسخدادن انسان یا برقراری رسانه نیست.
۲. فاصلهٔ ارسال مجدد را با زمان مطلق اشتباه نگیریم

اگر هیچ پاسخی نرسد، فاصلهها پانصد، هزار، دوهزار، چهارهزار، هشتهزار و شانزدههزار میلیثانیهاند. زمان مطلق هر ارسال، مجموع فاصلههای قبلی است. بنابراین فهرست ارسالها، با ارسال اولیه، صفر، پانصد، هزاروپانصد، سههزاروپانصد، هفتهزاروپانصد، پانزدههزاروپانصد و سیویکهزاروپانصد میشود. فهرست فاصلهها را نباید مستقیماً بهعنوان زمان ورود رویدادها استفاده کرد.
مرجع دستیِ زمان تلاش j، پس از ارسال اولیه، برابر T1 ضربدر «دو به توان j منهای یک» است. برای j برابر سه، هفت برابر پانصد، یعنی ۳۵۰۰ میلیثانیه میشود. این رابطه امکان مقایسهٔ یک پیادهسازی رویدادمحور با محاسبهٔ مستقل را میدهد؛ هر دو مسیر لازم نیست همان حلقه و همان متغیر فاصله را داشته باشند.
در نمونهٔ بیپاسخ، موعد B برابر ۳۲۰۰۰ میلیثانیه است؛ مدل در همان زمان از Calling خارج و calling_timeout ثبت میکند. هفت ارسال پیش از آن داریم، نه هفت ارسال مجدد علاوه بر ارسال اولیه. تعداد ارسال، شمارش درخواست فرضی است و نرخ اتلاف نیست. نام این حالت، برچسب خروجیِ محدود ماست؛ همهٔ رفتارهای پایان تراکنش، اعلان واقعی به نرمافزار تماس یا مدیریت ارتباط پیاده نشدهاند.
۳. پاسخ موقت، ارسال مجدد را متوقف میکند

نمونهٔ اصلی یک پاسخ ۱۸۰ در زمان ۲۵۰۰ و پاسخ نهایی ۲۰۰ در زمان ۳۵۰۰۰ دارد؛ افق مشاهده ۴۰۰۰۰ میلیثانیه است. تا پاسخ اول فقط زمانهای صفر، پانصد و هزاروپانصد در فهرست ارسالاند. پاسخ موقت، مدل را به proceeding میبرد؛ رویداد ازقبلصفشدهٔ A در ۳۵۰۰ دیگر ارسال ایجاد نمیکند. این یک بررسی حالت است، نه لغو زمانسنج سیستمعامل.
توقف A به معنای timeout خودکارِ Proceeding در موعد B نیست. در زیرمجموعهٔ تعریفشده، B فقط وقتی هنوز calling باشیم timeout میسازد؛ در ردِ مثال، رویداد ۳۲۰۰۰ با برچسب timer_b_not_calling ثبت میشود. اگر پاسخ نهایی را حذف کنیم، حالت در افق ۴۰۰۰۰ همچنان proceeding است. پایان بازهٔ مشاهده، دلیل اختراع یک پاسخ یا خطای پروتکلی نیست.
با رسیدن پاسخ نهایی، خروجی outside_model میگیرد: از دامنهٔ این شبیهسازی خارج شدهایم. این نام، حالت استاندارد SIP نیست. اصلاح RFC 6026 رفتار پاسخهای 2xx را با Accepted و زمانسنج M تغییر داده است؛ بنابراین از توصیف قدیمی، پایان فوریِ کامل نتیجه نمیگیریم. Accepted، Completed، ACK و زمانسنجهای بعدی در کد حاضر وجود ندارند.
۴. کد کامل و مرجع مستقلِ رویدادها را اجرا کنیم

فایل sip_invite_timers.py با Python 3.14.5 و کتابخانهٔ استاندارد اجرا شده است. صف heap، زمان، اولویت و شمارهٔ ورود را نگه میدارد. سیاست قراردادی ما برای تساوی زمان، رسیدگی به پاسخ پیش از زمانسنج است؛ میان پاسخهای همزمان، ترتیب ورودی حفظ میشود. این قاعده برای تکرارپذیری آزمایش انتخاب شده و دستور عمومی RFC دربارهٔ مسابقهٔ شبکه معرفی نمیشود.
"""Offline INVITE initial retransmission timing subset, no SIP/network stack.
Calling/Proceeding only; first final response is an explicit model boundary.
No Accepted/Completed/ACK/D/M/dialog/CANCEL/response matching implementation.
"""
import heapq
import json
import platform
def validate(t1_ms, transport, responses, horizon_ms):
if type(t1_ms) is not int or not 1 <= t1_ms <= 10000:
raise ValueError('t1_ms')
if transport not in ('udp', 'reliable') or type(transport) is not str:
raise ValueError('transport')
if type(horizon_ms) is not int or not 0 <= horizon_ms <= 1000000:
raise ValueError('horizon_ms')
if type(responses) is not list or len(responses) > 32:
raise ValueError('responses')
for item in responses:
if type(item) is not tuple or len(item) != 2:
raise ValueError('response_shape')
time_ms, status = item
if type(time_ms) is not int or not 0 <= time_ms <= 1000000:
raise ValueError('response_time')
if type(status) is not int or not 100 <= status <= 699:
raise ValueError('response_status')
def simulate(t1_ms=500, transport='udp', responses=None, horizon_ms=40000):
responses = [] if responses is None else responses
validate(t1_ms, transport, responses, horizon_ms)
events = []
serial = 0
def schedule(time_ms, priority, kind, status=None):
nonlocal serial
heapq.heappush(events, (time_ms, priority, serial, kind, status))
serial += 1
# Own simulation policy: responses before timers at equal integer time;
# equal-time responses retain input order. B precedes A for completeness.
for time_ms, status in responses:
schedule(time_ms, 0, 'response', status)
schedule(64 * t1_ms, 1, 'timer_b')
if transport == 'udp':
schedule(t1_ms, 2, 'timer_a')
state = 'calling'
sends = [0]
provisional = []
first_final = None
interval_ms = t1_ms
trace = [{'at_ms': 0, 'event': 'initial_send', 'state': state}]
for _ in range(64): # <=32responses + <=20timeritems for bounded inputs.
if not events or events[0][0] > horizon_ms:
break
time_ms, priority, order, kind, status = heapq.heappop(events)
if kind == 'response':
if state not in ('calling', 'proceeding'):
action = 'response_outside_active_subset'
elif status < 200:
state = 'proceeding'
provisional.append({'at_ms': time_ms, 'status': status})
action = 'provisional_observed'
else:
first_final = {'at_ms': time_ms, 'status': status}
state = 'outside_model'
action = 'final_model_boundary'
elif kind == 'timer_b':
if state == 'calling':
state, action = 'calling_timeout', 'calling_timeout'
else:
action = 'timer_b_not_calling'
elif state == 'calling':
sends.append(time_ms)
interval_ms *= 2
schedule(time_ms + interval_ms, 2, 'timer_a')
action = 'retransmit'
else:
action = 'timer_a_inactive'
record = {'at_ms': time_ms, 'event': action, 'state': state}
if kind == 'response':
record['status'] = status
trace.append(record)
else:
raise RuntimeError('internal_event_bound')
return {'t1_ms': t1_ms, 'transport': transport, 'horizon_ms': horizon_ms,
'timer_b_ms': 64 * t1_ms, 'send_times_ms': sends, 'state_at_horizon': state,
'provisional': provisional, 'first_final_boundary': first_final,
'trace': trace}
def closed_form_oracle(t1_ms, transport, responses, horizon_ms):
"""Independent geometric send times + ordered response scan, no heap/timer loop."""
deadline = 64 * t1_ms
state, first_response_ms = 'calling', None
provisional, final = [], None
for index, item in sorted(enumerate(responses), key=lambda row: (row[1][0], row[0])):
at_ms, status = item
if at_ms > horizon_ms:
continue
if state == 'calling' and at_ms > deadline:
state = 'calling_timeout'
if state not in ('calling', 'proceeding'):
continue
if first_response_ms is None:
first_response_ms = at_ms
if status < 200:
provisional.append({'at_ms': at_ms, 'status': status})
state = 'proceeding'
else:
final = {'at_ms': at_ms, 'status': status}
state = 'outside_model'
if state == 'calling' and horizon_ms >= deadline:
state = 'calling_timeout'
sends = [0]
if transport == 'udp':
for power in range(1, 7):
candidate = t1_ms * (2 ** power - 1)
if candidate <= horizon_ms and (first_response_ms is None or candidate < first_response_ms):
sends.append(candidate)
return {'send_times_ms': sends, 'state_at_horizon': state,
'provisional': provisional, 'first_final_boundary': final}
def rejects(call, expected):
try:
call()
except ValueError as error:
return str(error) == expected
return False
def run_example():
fixture = [(2500, 180), (35000, 200)]
untouched = list(fixture)
main = simulate(responses=fixture)
no_response = simulate()
provisional_only = simulate(responses=[(2500, 180)])
exact_a = simulate(responses=[(500, 180)])
exact_b = simulate(responses=[(32000, 180)])
after_b = simulate(responses=[(32001, 180)])
order_one = simulate(responses=[(500, 180), (500, 486)])
order_two = simulate(responses=[(500, 486), (500, 180)])
doubled = simulate(t1_ms=1000, horizon_ms=80000)
boundary_statuses = {status: simulate(responses=[(0, status)]) for status in [100, 199, 200, 299, 300, 699]}
invalid_rows = [([[]], 'response_shape'), ([(0,)], 'response_shape'),
([(True, 180)], 'response_time'), ([(-1, 180)], 'response_time'),
([(0, True)], 'response_status'), ([(0, 99)], 'response_status'),
([(0, 700)], 'response_status')]
cases, oracle_ok = 0, True
for t1 in [1, 2, 500]:
fixtures = [[], [(0, 100)], [(t1, 180)], [(3 * t1, 486)],
[(64 * t1, 180)], [(64 * t1 + 1, 200)],
[(2 * t1, 180), (70 * t1, 200)],
[(2 * t1, 180), (2 * t1, 200)]]
for transport in ['udp', 'reliable']:
for horizon in [0, 64 * t1, 80 * t1]:
for responses in fixtures:
observed = simulate(t1, transport, responses, horizon)
reference = closed_form_oracle(t1, transport, responses, horizon)
oracle_ok &= all(observed[key] == value for key, value in reference.items())
cases += 1
max_inputs = simulate(responses=[(1000000, 180)] * 32, horizon_ms=1000000)
checks = {
'hand_no_response_geometric_send_times': no_response['send_times_ms'] == [0, 500, 1500, 3500, 7500, 15500, 31500],
'hand_calling_deadline_timeout': no_response['state_at_horizon'] == 'calling_timeout' and no_response['timer_b_ms'] == 32000 and any(row['event'] == 'calling_timeout' and row['at_ms'] == 32000 for row in no_response['trace']),
'hand_provisional_stops_retransmission': main['send_times_ms'] == [0, 500, 1500] and main['provisional'] == [{'at_ms': 2500, 'status': 180}],
'proceeding_does_not_timeout_on_timer_b': provisional_only['state_at_horizon'] == 'proceeding' and any(row['event'] == 'timer_b_not_calling' for row in provisional_only['trace']) and not any(row['event'] == 'calling_timeout' for row in provisional_only['trace']),
'final_after_b_is_explicit_model_boundary': main['state_at_horizon'] == 'outside_model' and main['first_final_boundary'] == {'at_ms': 35000, 'status': 200},
'final_before_first_retry_prevents_extra_send': simulate(responses=[(499, 486)])['send_times_ms'] == [0] and simulate(responses=[(499, 486)])['state_at_horizon'] == 'outside_model',
'equal_time_response_precedes_timer_a_by_own_policy': exact_a['send_times_ms'] == [0] and exact_a['state_at_horizon'] == 'proceeding',
'equal_time_provisional_precedes_timer_b_by_own_policy': exact_b['state_at_horizon'] == 'proceeding' and exact_b['send_times_ms'] == no_response['send_times_ms'],
'response_one_ms_after_b_cannot_undo_timeout': after_b['state_at_horizon'] == 'calling_timeout' and not after_b['provisional'] and after_b['trace'][-1]['event'] == 'response_outside_active_subset',
'same_time_responses_preserve_input_sequence': order_one['provisional'] == [{'at_ms': 500, 'status': 180}] and order_one['first_final_boundary']['status'] == 486 and order_two['provisional'] == [] and order_two['first_final_boundary']['status'] == 486,
'reliable_transport_has_no_timer_a_retransmissions': simulate(transport='reliable')['send_times_ms'] == [0] and simulate(transport='reliable')['state_at_horizon'] == 'calling_timeout',
'observation_horizon_is_not_a_timeout_event': simulate(horizon_ms=499)['state_at_horizon'] == 'calling' and simulate(horizon_ms=499)['send_times_ms'] == [0] and simulate(horizon_ms=31999)['state_at_horizon'] == 'calling' and simulate(horizon_ms=32000)['state_at_horizon'] == 'calling_timeout',
't1_scales_sends_and_deadline': doubled['send_times_ms'] == [2 * value for value in no_response['send_times_ms']] and doubled['timer_b_ms'] == 64000,
't1_and_transport_contract_reject': all(rejects(lambda value=value: simulate(t1_ms=value), 't1_ms') for value in [True, 0, -1, 1.0, 10001]) and rejects(lambda: simulate(transport='unknown'), 'transport'),
'response_validation_preserves_caller_inputs': all(rejects(lambda rows=rows: simulate(responses=rows), message) for rows, message in invalid_rows) and fixture == untouched and simulate(responses=list(reversed(fixture)))['send_times_ms'] == main['send_times_ms'],
'status_class_boundaries_are_explicit': all(boundary_statuses[status]['state_at_horizon'] == 'proceeding' for status in [100, 199]) and all(boundary_statuses[status]['state_at_horizon'] == 'outside_model' for status in [200, 299, 300, 699]),
'bounded_event_count_and_integer_horizon': max_inputs['horizon_ms'] == 1000000 and rejects(lambda: simulate(responses=[(0, 180)] * 33), 'responses') and all(rejects(lambda value=value: simulate(horizon_ms=value), 'horizon_ms') for value in [True, -1, 1.0, 1000001]),
'independent_closed_form_and_response_oracle': cases == 144 and bool(oracle_ok),
}
assert len(checks) == 18 and all(type(value) is bool and value for value in checks.values()), checks
return {'python': platform.python_version(), 'dependencies': 'Python standard library only',
'scope': 'Bounded offline INVITE timing subset, no packets/call/ACK/fulltransaction or realtime guarantee; final response is model boundary',
'results': {'synthetic_responses': [{'at_ms': t, 'status': s} for t, s in fixture],
'main': main, 'no_response': no_response,
'provisional_only_state_at_40000_ms': provisional_only['state_at_horizon'],
'own_equal_time_policy': 'responses before timers, input order between responses; B before A',
'oracle_scenarios_inside_one_group': cases}, 'checks': checks}
if __name__ == '__main__':
print(json.dumps(run_example(), ensure_ascii=False, indent=2))
برای مثال، پاسخ موقت دقیقاً در پانصد میلیثانیه، پیش از A پردازش میشود و ارسال همان لحظه حذف میشود. پاسخ موقت دقیقاً در ۳۲۰۰۰، پیش از B حالت را عوض میکند؛ اما در ۳۲۰۰۱، timeout قبلاً رخ داده و پاسخ آن را برنمیگرداند. اگر دو پاسخ در یک زمان باشند، جابهجایی ترتیب ورودی میتواند ردِ میانی را عوض کند؛ این تصمیم را پنهان نکردهایم.
هجده گروه اجرایی، پاسخ دستی، مرزهای زمان و کد وضعیت، انتقال قابلاعتماد بدون A، افق مشاهده و ورودی نامعتبر را بررسی میکنند. مرجع مستقل از رابطهٔ بستهٔ زمان ارسال و پیمایش جداگانهٔ پاسخهای مرتبشده استفاده میکند؛ صف heap یا حلقهٔ زمانسنج مسیر اصلی را ندارد. صدوچهلوچهار سناریوی کوچک در یک گروه مقایسه میشوند و تعداد گروهها با اجرای دوباره افزایش نمییابد.
۵. مرزها و چهار پرسش پاسخدار را صریح نگه داریم

قرارداد ورودی، T1 صحیح از یک تا دههزار میلیثانیه، حداکثر سیودو پاسخ و افق صحیح تا یکمیلیون میلیثانیه را میپذیرد. کدهای ۱۰۰ تا ۶۹۹ و زمانهای نامنفی مجازند؛ bool، زمان اعشاری و وضعیت خارج از دامنه رد میشوند. همهٔ ورودیها پیش از ساخت رویداد بررسی میشوند. این کرانها توصیهٔ بهرهبرداری تماس نیستند و نبود خطای ورودی، اعتبار پیام شبکه را ثابت نمیکند.
{
"python": "3.14.5",
"dependencies": "Python standard library only",
"scope": "Bounded offline INVITE timing subset, no packets/call/ACK/fulltransaction or realtime guarantee; final response is model boundary",
"results": {
"synthetic_responses": [
{
"at_ms": 2500,
"status": 180
},
{
"at_ms": 35000,
"status": 200
}
],
"main": {
"t1_ms": 500,
"transport": "udp",
"horizon_ms": 40000,
"timer_b_ms": 32000,
"send_times_ms": [
0,
500,
1500
],
"state_at_horizon": "outside_model",
"provisional": [
{
"at_ms": 2500,
"status": 180
}
],
"first_final_boundary": {
"at_ms": 35000,
"status": 200
},
"trace": [
{
"at_ms": 0,
"event": "initial_send",
"state": "calling"
},
{
"at_ms": 500,
"event": "retransmit",
"state": "calling"
},
{
"at_ms": 1500,
"event": "retransmit",
"state": "calling"
},
{
"at_ms": 2500,
"event": "provisional_observed",
"state": "proceeding",
"status": 180
},
{
"at_ms": 3500,
"event": "timer_a_inactive",
"state": "proceeding"
},
{
"at_ms": 32000,
"event": "timer_b_not_calling",
"state": "proceeding"
},
{
"at_ms": 35000,
"event": "final_model_boundary",
"state": "outside_model",
"status": 200
}
]
},
"no_response": {
"t1_ms": 500,
"transport": "udp",
"horizon_ms": 40000,
"timer_b_ms": 32000,
"send_times_ms": [
0,
500,
1500,
3500,
7500,
15500,
31500
],
"state_at_horizon": "calling_timeout",
"provisional": [],
"first_final_boundary": null,
"trace": [
{
"at_ms": 0,
"event": "initial_send",
"state": "calling"
},
{
"at_ms": 500,
"event": "retransmit",
"state": "calling"
},
{
"at_ms": 1500,
"event": "retransmit",
"state": "calling"
},
{
"at_ms": 3500,
"event": "retransmit",
"state": "calling"
},
{
"at_ms": 7500,
"event": "retransmit",
"state": "calling"
},
{
"at_ms": 15500,
"event": "retransmit",
"state": "calling"
},
{
"at_ms": 31500,
"event": "retransmit",
"state": "calling"
},
{
"at_ms": 32000,
"event": "calling_timeout",
"state": "calling_timeout"
}
]
},
"provisional_only_state_at_40000_ms": "proceeding",
"own_equal_time_policy": "responses before timers, input order between responses; B before A",
"oracle_scenarios_inside_one_group": 144
},
"checks": {
"hand_no_response_geometric_send_times": true,
"hand_calling_deadline_timeout": true,
"hand_provisional_stops_retransmission": true,
"proceeding_does_not_timeout_on_timer_b": true,
"final_after_b_is_explicit_model_boundary": true,
"final_before_first_retry_prevents_extra_send": true,
"equal_time_response_precedes_timer_a_by_own_policy": true,
"equal_time_provisional_precedes_timer_b_by_own_policy": true,
"response_one_ms_after_b_cannot_undo_timeout": true,
"same_time_responses_preserve_input_sequence": true,
"reliable_transport_has_no_timer_a_retransmissions": true,
"observation_horizon_is_not_a_timeout_event": true,
"t1_scales_sends_and_deadline": true,
"t1_and_transport_contract_reject": true,
"response_validation_preserves_caller_inputs": true,
"status_class_boundaries_are_explicit": true,
"bounded_event_count_and_integer_horizon": true,
"independent_closed_form_and_response_oracle": true
}
}
پرسش اول: سومین ارسال مجدد در چه زمانی است؟ پاسخ: در نمونهٔ بیپاسخ، ۳۵۰۰ میلیثانیه؛ ارسال اولیه در زمان صفر است و جدا از سه تلاش مجدد شمرده میشود.
پرسش دوم: پاسخ ۱۸۰ در ۲۵۰۰ چه تغییری میدهد؟ پاسخ: فهرست ارسال در صفر، پانصد و هزاروپانصد میماند؛ رویداد A بعدی در ۳۵۰۰ ارسال نمیکند و مدل در proceeding منتظر میماند.
پرسش سوم: آیا رسیدن به افق ۳۱۹۹۹، timeout است؟ پاسخ: خیر؛ در حالت بیپاسخ هنوز calling است. با افق ۳۲۰۰۰، رویداد B هم داخل مشاهده قرار میگیرد و timeout ثبت میشود؛ دو خروجی به دو بازهٔ مشاهده تعلق دارند.
پرسش چهارم: آیا این آزمون ترتیب صوت RTP را هم بررسی میکند؟ پاسخ: خیر؛ برای آن، آزمون جداگانهٔ شمارهٔ توالی RTP ورودی و قرارداد متفاوت دارد. منابع در ۲۴ مهر ۱۴۰۵ خوانده شدند؛ RFC 3261 مربوط به ژوئن ۲۰۰۲ و اصلاح RFC 6026 مربوط به سپتامبر ۲۰۱۰ است. این آموزش پایه، خبر تازه یا گواه سازگاری کامل SIP نیست.
منابع و مطالعهٔ بیشتر
- RFC3261؛ زیرمجموعهٔ Calling و زمانسنجهای A و B در INVITE
- RFC6026؛ اصلاح رفتار تراکنش INVITE پس از پاسخ 2xx
بررسی منابع: ۲۴ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.

