بخش ۲: Lineها، Callbackها و ایمنی طول عمر
این بخش قراردادهایی را توضیح میدهد که نقض آنها بیش از هر چیز باعث Crash میشود: چرخهی عمر Callbackهای هر اتصال، مالکیت Line و راه محافظت در برابر Callbackهای Re-entrant که ممکن است Line را در میانهی کار شما نابود کنند.
اگر پیش از دستزدن به جریان اتصال فقط فرصت خواندن یک بخش را دارید، همین بخش را بخوانید. پیشنیاز: بخش ۱، شامل Objectها، جهتها و چرخهی عمر.
شش Callback جریان
هر اتصال با شش رویداد در دو جهت هدایت میشود: upstream بهسمت next و downstream
بهسمت prev.
| رویداد | مفهوم | نکتهی جهت |
|---|---|---|
Init | یک Line در حال شروع است؛ State مختص Line این تونل را مقداردهی کنید. | از Adapter سازنده در جهت upstream حرکت میکند. |
Est | سمت دور واقعاً برقرار شده است؛ برای مثال Socket راه دور متصل شده. | معمولاً در جهت downstream و بهسمت آغازکننده بازمیگردد. |
Payload | بخشی از داده در قالب sbuf_t که باید Transform و Forward شود. | هر دو جهت. |
Pause | Backpressure؛ فعلاً فرستادن در این جهت را متوقف کنید. | هر دو جهت. |
Resume | Backpressure رفع شده است و ارسال میتواند ادامه یابد. | هر دو جهت. |
Finish | این جهت در حال بستهشدن است. | جهتدار و مخرب؛ توضیح آن در ادامه آمده است. |
تونل فقط Handlerهای موردنیاز خود را پیادهسازی میکند و بقیه را روی رفتار پیشفرض
فریمورک، یعنی Pass-through به همسایه، میگذارد. برای مثال، یک Encryptor میتواند
Payload را در هر دو جهت Override کند و Pause/Resume را به رفتار پیشفرض
بسپارد.
یک Line چگونه آغاز میشود؟
یک اتصال معمولی با Init آغاز میشود و Adapter آن را ایجاد میکند:
- یک Adapter، مانند
TcpListener، Socket را Accept و یکline_tایجاد میکند. - تابع
tunnelNextUpStreamInit()را فراخوانی میکند. - این فراخوانی
fnInitUتونل بعدی را اجرا میکند؛ تونل State مختص Line خود را مقداردهی وInitرا در upstream به جلو Forward میکند. - هر تونل میانی بهترتیب State مختص Line خود را مقداردهی میکند.
- Adapter انتهایی، مانند
TcpConnector، عملیات واقعی شبکه را آغاز میکند.
Init یک تونل میتواند پیش از بازگشت، Callbackهای دیگری مانند Payload،
Est، Pause، Resume یا حتی Finish را بهصورت Synchronous فعال کند؛ زیرا
تونل پاییندستی ممکن است بلافاصله داده را برگرداند یا اتصال را ببندد. این همان
خطر Re-entrancy است که در ادامه توضیح داده میشود، با این تفاوت که از لحظهی تولد
Line آغاز میشود.
قواعد Init:
- State مختص Line همین تونل را در
Initمقداردهی کنید. Callbackهای بعدی حق دارند فرض کنند این State از قبل وجود دارد. - برای محافظت از Callbackهای بعدی یک Boolean به نام
initializedاضافه نکنید. در چرخهی عمر معمول WaterWall، Initهمیشه برای تونل زودتر اجرا میشود و چنین Flagای تقریباً همیشه راهحلی موقت برای Control Flow ناامن است. فقط زمانی آن را اضافه کنید که Source Code بهروشنی ضرورتش را ثابت کند. - اگر
Initیک Helper مربوط به Forwarding و Re-entrant را صدا میزند و پس از آن هنوز باید به Line دسترسی داشته باشد، از آن محافظت کنید؛ بخش قفلکردن Line را ببینید. اگرInitفقط Forward میکند و برمیگردد، نیازی به Lock نیست.
طول عمر Line: refcount و alive
هر line_t یک refc اتمیک و یک Boolean به نام alive دارد؛ به ww/net/line.h
نگاه کنید. این دو مفهوم جدا هستند و یکیگرفتن آنها باعث Bug میشود:
lineLock(line)مقدارrefcرا افزایش میدهد و تضمین میکند تا زمانی که این Reference را دارید، حافظه آزاد نشود.lineUnlock(line)مقدارrefcرا کم میکند و وقتی به صفر برسد Line را آزاد میکند.lineDestroy(line)مقدارalive = falseرا تنظیم و Reference سازنده را آزاد میکند.lineIsAlive(line)میگوید آیا Line از نظر منطقی هنوز باز است یا نه.
تفاوت مهم:
داشتن Lock فقط اعتبار حافظه را حفظ میکند؛ به این معنا نیست که Line از نظر منطقی هنوز زنده است. پس از یک Callback از نوع Re-entrant، ممکن است Line قفلشده
alive == falseباشد. پیش از ادامه همیشه دوبارهlineIsAlive()را بررسی کنید.
چه کسی اجازه دارد Line را نابود کند؟
فقط تونلی که Line را ساخته است اجازه دارد lineDestroy() را روی آن فراخوانی
کند. سازندههای رایج عبارتاند از:
- Adapterهایی مانند
TcpListener، UdpListenerوTcpConnector - چند تونل میانی که بهطور قانونی Lineهای خود را میسازند، مانند
MuxServer، ReverseClient، PacketsToStreamوPacketsToConnection
همهی تونلهای دیگر باید Finish را Propagate کنند و نابودکردن Line را به
مالک بسپارند. فراخوانی lineDestroy() روی Lineای که خودتان نساختهاید یک Bug
جدی است.
Invariant مهمی نیز در حالت Debug وجود دارد: وقتی Line سرانجام آزاد میشود،
lineUnRefInternal() با debugAssertZeroBuf بررسی میکند که Line State همهی
تونلها Zero شده باشد. از همین رو هر تونل باید پیش از رسیدن Finish واقعیِ
بستن Line، Line State خود را نابود کند؛ مگر آنکه عمداً در طول بستهشدن یک Reference
مثبت نگه داشته باشد.
محافظت از Line هنگام Callbackهای Re-entrant
فراخوانیهای میانتونلی زیر میتوانند پیش از بازگشت، غیرمستقیم Line را ببندند. تکتک آنها را خطرناک در نظر بگیرید:
tunnelNextUpStreamInit tunnelNextUpStreamPayload
tunnelPrevDownStreamInit tunnelPrevDownStreamPayload
tunnelNextUpStreamEst tunnelNextUpStreamPause tunnelNextUpStreamResume
tunnelPrevDownStreamEst tunnelPrevDownStreamPause tunnelPrevDownStreamResume
پس از بازگشت هرکدام:
- ممکن است Line از قبل مرده باشد؛
- ممکن است Line State این تونل از قبل Destroy و Zero شده باشد؛
- هر دسترسی به
lineیاlsناامن است، مگر آنکه از Line محافظت کرده باشید.
هرگز اینگونه استدلال نکنید: «فراخوانی برگشته است، پس Line State من هنوز معتبر است.»
روش A — withLineLocked()؛ روش پیشنهادی
withLineLocked() و withLineLockedWithBuf() در طول Callback یک Reference موقت
نگه میدارند و به شما میگویند Line زنده مانده است یا نه. پیادهسازی واقعی آنها
در ww/net/line.h چنین است:
static inline bool withLineLocked(line_t *const line, LineTaskFnNoBuf task, tunnel_t *t)
{
lineLock(line);
task(t, line);
if (! lineIsAlive(line))
{
lineUnlock(line);
return false; // line died during the callback
}
lineUnlock(line);
return true; // line still alive; safe to continue
}
هر زمان پس از یک فراخوانی Re-entrant هنوز باید با Line کار کنید، از این Helper استفاده کنید:
if (! withLineLocked(line, tunnelNextUpStreamInit, t))
{
return; // line died; do not touch line or ls
}
// Still alive here. Re-read state if you need it.
my_lstate_t *ls = lineGetState(line, t);
برای Forwardکردن Payload، نسخهی دارای Buffer را به کار ببرید:
if (! withLineLockedWithBuf(line, tunnelNextUpStreamPayload, t, buf))
{
return;
}
وقتی Helper مقدار false برمیگرداند:
- به
lineدست نزنید؛ - به Line State این تونل دست نزنید؛
- برای «تمیزکاری» تابع
LinestateDestroy()خود را صدا نزنید؛ مسیر Close که Line را کشته، این کار را قبلاً انجام داده است؛ - اگر هنوز مالک Bufferای هستید که تحویل ندادهاید، بدون دسترسی به State مردهی Line آن را Recycle کنید.
اگر Helper مربوط به Forwarding را فراخوانی و بلافاصله return میکنید، بدون آنکه
کار دیگری با Line داشته باشید، قرار دادن آن در withLineLocked() سودی ندارد؛
نتیجهی Boolean رفتار شما را عوض نمیکند. این حالت برای Pass-through سادهی
Init و Finish رایج است.
روش B — lineLock() / lineUnlock() دستی
وقتی روی چند فراخوانی به کنترل صریح نیاز دارید، بهصورت دستی Lock کنید:
lineLock(line);
// one or more potentially re-entrant callbacks ...
if (! lineIsAlive(line))
{
lineUnlock(line);
return;
}
// still alive: safe to continue
lineUnlock(line);
قواعد: هر چیزی را که Lock میکنید حتماً Unlock کنید؛ پس از Destroyکردن Tunnel
State آن را نخوانید؛ و اگر Helper هم Line State شما را نابود و هم Finish را
Propagate میکند، Caller باید بلافاصله برگردد.
معنای Finish
Finish جهتدار و مخرب است و یک ویژگی آن را از همهی Callbackهای دیگر
متمایز میکند:
همهی Callbackهای دیگر جریان ممکن است Re-entrant باشند؛ Finish تنها استثناست.
اگر Tunnel A برای یک Line، Finish را به Tunnel B بفرستد، Tunnel B نباید برای
همان line_t هیچ Callbackی، حتی Finish، در همان جهت به Tunnel A برگرداند.
بهطور مشخص:
- تونلی که
Finishدر upstream دریافت میکند نباید هیچیک ازtunnelPrevDownStream*ها، شامل Init/Payload/Est/Pause/Resume/Finish، را برای آن Line بهسمت فرستنده برگرداند. - تونلی که
Finishدر downstream دریافت میکند نباید هیچیک ازtunnelNextUpStream*ها را برای آن Line بهسمت فرستنده برگرداند.
این خطا Reflection نام دارد و یکی از علتهای رایج Crash است؛ زیرا تونلها پیش
از Propagateکردن Finish، Line State خود را نابود میکنند. بازتاب یک Callback،
تونلی را فراخوانی میکند که State آن برای این Line دیگر وجود ندارد.
اگر تضمین بالا را از زاویهی دیگر ببینید، به قاعدهای ساده و حیاتی میرسید:
چون Finish از نوع Re-entrant نیست، تضمین دارید از سمتی که Finish شده Callback
دریافت نکنید؛ شما نیز باید همین تضمین را ادامه دهید و هیچچیز به آن سمت
نفرستید. بهمحض دریافت Finish از یک جهت، آن جهت برای آن Line بسته است. در
هیچ رویداد بعدی برای آن line_t و تحت هیچ شرایطی، Payload، Est،
Pause/Resume یا Finish دوم را به آن سمت نفرستید.
جهت نابودی را نیز بهخاطر بسپارید: اگر Finish در downstream به Adapter سازندهی
Line برسد، در نهایت lineDestroy() اجرا میشود. بنابراین پس از Propagateکردن
Finish در downstream فرض کنید Line ممکن است از بین رفته باشد.
سادهترین Finish صحیح
تونل میانیای که چیزی برای Flushکردن ندارد، فقط Line State خود را نابود میکند و
رویداد را Forward میکند. کد زیر عین پیادهسازی Finish در upstream برای
EncryptionClient است:
void encryptionclientTunnelUpStreamFinish(tunnel_t *t, line_t *l)
{
encryptionclient_lstate_t *ls = lineGetState(l, t);
encryptionclientLinestateDestroy(ls); // destroy local state first
tunnelNextUpStreamFinish(t, l); // then propagate
}
ترتیب اهمیت دارد: State محلی را پیش از Propagateکردن Close واقعی نابود کنید.
پس از LinestateDestroy(ls) هیچ فیلدی از ls را نخوانید؛ فرض کنید تمام آن Zero
شده است.
بستن هر دو جهت از میانهی زنجیره
وقتی خود یک تونل میانی تصمیم میگیرد اتصال را بهعلت خطای پروتکل یا رسیدن به یک محدودیت Tear Down کند، مسئول بستن هر دو سمت است. ترتیب امن:
- Line State خود تونل را نابود کنید.
- ابتدا
Finishرا در upstream بفرستید. چونFinishRe-entrant نیست، Line پس از آن هنوز زنده است. - سپس
Finishرا در downstream بفرستید. این فراخوانی ممکن است Line را از بین ببرد. - بلافاصله
returnکنید.
my_lstate_t *ls = lineGetState(l, t);
myLinestateDestroy(ls); // 1. local state gone
tunnelNextUpStreamFinish(t, l); // 2. close forward (line still alive)
tunnelPrevDownStreamFinish(t, l); // 3. close backward (line may die now)
return; // 4. do not touch l or ls again
کار Adapterها سادهتر است: چون در یک انتهای زنجیره قرار دارند، Finish را فقط یک
بار و بهسمت جهت مخالف میفرستند.
Lineهایی که مالکشان هستید حالت دیگری دارند. سازندهای مانند MuxClient ابتدا
State خود را نابود و Finish را Forward میکند و سپس، چون مالک Line است، اگر Line
هنوز زنده باشد آن را نابود میکند:
muxclientLinestateDestroy(parent_ls);
tunnelNextUpStreamFinish(t, parent_l);
if (lineIsAlive(parent_l))
{
lineDestroy(parent_l); // only the owner may do this
}
بیشتر تونلها به State جهتدار Finish نیاز ندارند
نتیجهی مستقیم Non-re-entrancy این است: بهصورت پیشفرض Flagهایی مانند
prev_finished / next_finished یا can_upstream / can_downstream به تونل
اضافه نکنید. تونلی که فقط Finish را Forward میکند تضمین دارد از سمت
Finishشده دوباره فراخوانی نشود؛ پس چیزی برای بهخاطرسپردن ندارد. Boolean احتمالی
برای اینکه «آیا این سمت Finish شده؟» تقریباً همیشه پوششی روی مشکل Control Flow
است، نه راهحل آن؛ درست مانند Anti-pattern مربوط به initialized.
دقیقاً یک موقعیت وجود دارد که State مربوط به Close جهتدار را توجیه میکند:
تونل باید پیش از بستهشدن Byteهای پایانی بفرستد؛ موضوع بخش بعد. این ارسال دوباره
وارد Adapter میشود و Adapter میتواند Pause/Resume تولید کند. Flag به Handlerهای
Pause/Resume اجازه میدهد هر رویدادی را که بهسمت Finishشده Reflection میکند کنار
بگذارند. وظیفهی این Flag فقط ثبت همین خطر است، نه چیز دیگر.
تونلهای واقعی دقیقاً همین مقدار State و نه بیشتر را نگه میدارند. نامها متفاوت، اما نقش یکسان است:
| تونل | State مربوط به Close جهتدار | دلیل نیاز |
|---|---|---|
TlsServer | upstream_finished، downstream_finishing | پیش از Close، TLS Alert/Close را میفرستد و Fallback را Resolve میکند. |
TcpOverUdpClient | can_downstream | باید پیش از Close، Teardown مربوط به Reliable Stream را به پایان برساند. |
TcpOverUdpServer | can_upstream | همان رفتار در جهت مخالف. |
HttpClient، HttpServer، Socks5Server | prev_finished، next_finished | پیش از بستهشدن Transport، Byteهای پایانی پروتکل را میفرستند. |
اگر تونل شما هنگام Finish هیچ Byte پایانی نمیفرستد، نباید هیچیک از این Flagها
را داشته باشد. State محلی را نابود و رویداد را Forward کنید؛ تمام وظیفه همین است.
فرستادن Byteهای پایانی پروتکل پیش از Close
برخی پروتکلها باید پیش از بستهشدن Transport، Byteهای پایانی بفرستند: پیام
close_notify در TLS، Final Chunk در HTTP، END_STREAM در HTTP/2، Close Frame
در WebSocket یا KCP. فرستادن این Byteها هنگام Finish یک بازهی خطرناک
Re-entrant ایجاد میکند.
خطر بهترتیب رخداد:
1. Tunnel receives Finish from side A.
2. It sends final protocol bytes toward side B.
3. Side B's adapter blocks while writing and emits Pause (or later Resume).
4. That Pause/Resume travels back through the tunnel...
5. ...and if the tunnel reflects it toward side A — whose state was already
destroyed by the original Finish — it calls into freed/zeroed state. Crash.
ساختار امن الزامی:
lineLock(line)را فراخوانی کنید، چون قرار است Byteها را بهشکل Re-entrant بفرستید.- ابتدا سمت فرستنده را Finishشده علامت بزنید. از State جهتدار موجود تونل،
مانند
prev_finished، next_finished، can_downstream = falseیاcan_upstream = falseاستفاده کنید. HandlerهایPause/Resumeباید این State را بررسی کنند و اجازه ندهند چیزی به سمت Finishشده Reflection شود. - Byteهای پایانی پروتکل را بهسمت دیگر بفرستید.
- Line State محلی این تونل را نابود کنید.
Finishواقعی WaterWall را Propagate کنید.lineUnlock(line)را فراخوانی کنید.- بلافاصله برگردید و پس از آن به Line State محلی دست نزنید.
نمونههای جهت:
- هنگام Handleکردن
Finishدر upstream و فرستادن Byteهای پایانی بهnext، هیچPause/Resumeدر downstream که بر اثر آن ارسال تولید میشود نباید بهprevForward شود. - هنگام Handleکردن
Finishدر downstream و فرستادن Byteهای پایانی بهprev، هیچPause/Resumeدر upstream که بر اثر آن ارسال تولید میشود نباید بهnextForward شود.
برای این کار Flag تازهای به نام initialized نسازید. از State موجود تونل برای
Finish/Close جهتدار استفاده کنید؛ یا اگر واقعاً چنین Stateای ندارد و این مورد
همان سناریوی Byteهای پایانی است، Flag صریحی برای Close جهتدار بیفزایید. همانطور
که تأکید شد، این تنها جای درست چنین Flagی است.
Adapterها خودشان Flush میکنند
Adapterهایی مانند TcpConnector و TcpListener ممکن است پس از دریافت Finish،
پیش از بستن واقعی Socket، Byteهای موجود در Queue را Flush کنند. بنابراین تونل
میانی باید Byteهای پایانی پروتکل را بفرستد، State محلی را نابود کند، Finish را
Forward کند و برای Flush به Adapter اعتماد کند. State پروتکل را صرفاً برای «منتظر
ماندن تا Socket تخلیه شود» زنده نگه ندارید.
Est، Pause و Resume
Estنشان میدهد سمت downstream واقعاً برقرار شده است؛ آن را زودتر از موعد نفرستید. تونلی که باید وضعیت Line را بداند میتواندlineIsEstablished()وlineMarkEstablished()را نیز بررسی کند؛ بهww/net/line.hنگاه کنید.Pause/Resumeسیگنالهای Backpressure هستند. فقط زمانی آنها را تولید کنید که از نظر معنایی و جهت درست باشد و هرگز بهسمت جهت Finishشده Reflection نکنید. این سیگنالها را بیدلیل نفرستید؛ Pause/Resume اضافی ممکن است Stream را در Deadlock قرار دهد.
مثال عملی: دنبالکردن یک Payload
زنجیرهی زیر را در نظر بگیرید:
TcpListener -> ObfuscatorClient -> TlsClient -> TcpConnector
(head) (middle) (middle) (tail)
Upstream، از Client به مقصد راه دور:
client bytes arrive at TcpListener's socket
TcpListener: tunnelNextUpStreamPayload(t, line, buf)
-> ObfuscatorClient.fnPayloadU: scramble bytes, forward upstream
-> TlsClient.fnPayloadU: encrypt via BoringSSL, forward upstream
-> TcpConnector.fnPayloadU: write ciphertext to the remote socket
Downstream، از مقصد راه دور به Client:
remote bytes arrive at TcpConnector's socket
TcpConnector: tunnelPrevDownStreamPayload(t, line, buf)
-> TlsClient.fnPayloadD: decrypt, forward downstream
-> ObfuscatorClient.fnPayloadD: unscramble, forward downstream
-> TcpListener.fnPayloadD: write plaintext to the accepted client
توجه کنید که همان Instance از TlsClient، روی fnPayloadU رمزنگاری و روی
fnPayloadD رمزگشایی میکند. جهت، نه خود تونل، Transform را تعیین میکند. اگر
نوشتن Payload در upstreamِ TlsClient روی TcpConnector متوقف شود، Backpressure
بهشکل Pause در جهت downstream بازمیگردد و نباید از سمتی که Finish شده است
عبور داده شود.
Checklist این بخش
پیش از تمامشدهدانستن تغییر در جریان، موارد زیر را بررسی کنید:
- آیا
Initپیش از آنکه Callback دیگری State را به کار بگیرد، Line State همین تونل را مقداردهی میکند؟ - آیا Callbackهای upstream فقط با
tunnelNextUpStream*و Callbackهای downstream فقط باtunnelPrevDownStream*Forward میشوند؟ - آیا هر فراخوانی Re-entrant یا بلافاصله Return میکند، یا با
withLineLocked()/ Lock دستی وlineIsAlive()از Line محافظت میکند؟ - اگر
withLineLocked()مقدارfalseبرگرداند، آیا از دستزدن بهline، lsوLinestateDestroy()خودداری میکنید؟ - آیا
Finish، State محلی را پیش از Propagateکردن Close واقعی نابود میکند؟ - هنگام Close از میانه، آیا ابتدا upstream، سپس downstream را Finish میکنید و بعد بلافاصله برمیگردید؟
- اگر هنگام
FinishByteهای پایانی پروتکل را میفرستید، آیا پیش از ارسال سمت فرستنده را Finishشده علامت میزنید و Reflection مربوط بهPause/Resumeرا مسدود میکنید؟ - آیا فقط مالک Line تابع
lineDestroy()را فراخوانی میکند؟ - آیا از افزودن Flagی به نام
initializedکه Source Code نیازی به آن ندارد، خودداری کردهاید؟