پرش به مطلب اصلی

راهنمای توسعه‌ی WaterWall

این راهنما برای توسعه‌دهنده‌ها و coding agent‌هایی است که روی تونل‌های موجود در tunnels/ کار می‌کنند؛ از پیاده‌سازی و رفع باگ گرفته تا review کد. راهنما هفت بخش دربارهٔ runtime و یک بخش ویژهٔ API دارد و توضیحاتش بر اساس کد پروژه است. در بخش ۱ با ساختار کلی آشنا می‌شوید و در بخش‌های بعد قراردادهایی را می‌خوانید که هنگام تغییر کد باید رعایت کنید.

WaterWall یک runtime مبتنی بر chain برای اجرای تونل‌هاست؛ بنابراین پیاده‌سازی درست یک تونل به parser یا encoder آن محدود نمی‌شود. باید جهت callback‌ها، طول عمر line، state هر line، padding buffer و رفتار packet line را رعایت کنید. تونل همچنین باید با هر node قبل و بعد از خود در config کاربر درست کار کند.

مهم‌ترین قاعده

هنگام اصلاح یک تونل، بدون درخواست صریح مدل چرخه‌ی عمر را تغییر ندهید. از قراردادهای این راهنما و پیاده‌سازی فعلی ww/net/ آغاز کنید؛ سپس با تونل‌های جاافتاده‌ای مقایسه کنید که مسئله‌ای مشابه را حل کرده‌اند. پیاده‌سازی مرجع به‌تنهایی درستی یک الگو را ثابت نمی‌کند: آن را با قرارداد، caller‌ها و همسایه‌هایش تطبیق دهید، زیرا ممکن است یک الگوی تکرارشده، خطایی تکرارشده هم داشته باشد. بازطراحی صریحاً درخواست‌شده‌ی چرخه‌ی عمر ممنوع نیست، اما کاری فراگیر است و باید همه‌ی پیاده‌سازی‌ها، تست‌ها و اسناد قراردادی متأثر را پوشش دهد.

ساختار این راهنما​

بخشموضوعچه زمانی به آن نیاز دارید؟
بخش ۱ (همین صفحه)نمای کلی، اشیای اصلی، جهت‌ها و چرخه‌ی عمرهمیشه ابتدا این بخش را بخوانید.
بخش ۲: line‌ها، callback‌ها و ایمنی طول عمرInit/Est/Payload/Pause/Resume/Finish، re-entrancy، reference‌های line و بستن صحیحهر تغییری که جریان اتصال را دست‌کاری می‌کند.
بخش ۳: buffer‌ها، padding و shift buffer‌هاsbuf_t، buffer pool‌ها، required_padding_left و prependکردن headerهر تغییری که payload را frame، prepend یا بازنویسی می‌کند.
بخش ۴: packet line‌ها و تونل‌های packetمعنای packet line، تونل‌های packet خالص و پل‌های packet/streamهر کار مربوط به layer 3 یا packet.
بخش ۵: ساختار یک تونل و روند کارچیدمان دایرکتوری، metadata در node.c، فایل create.c، line state و قواعد HTTPهنگام افزودن یا بازسازی ساختار یک تونل.
بخش ۶: build، تست و reviewpreset‌های CMake، ‏ctest، اعتبارسنجی، چک‌لیست review و خروجی عاملپیش از آنکه تغییری را «تمام‌شده» بدانید.
بخش ۷: shutdown برنامهshutdown controller، quiescence در worker‌ها، مرحله‌های چرخه‌ی عمر، سیگنال‌ها و ترتیب نابودیهر تغییر در راه‌اندازی، shutdown، کنترل worker یا مالکیت async.

راهنمای API و مثال‌های اجرایی​

برای اتصال برنامهٔ بیرونی، پروتکل API را بخوانید؛ برای افزودن operation عمومی به یک node، از پیاده‌سازی operationهای node شروع کنید. تنظیمات و مجوزهای gateway در مرجع APIServer هستند. راه‌اندازی کلاینت نمونه، مدیریت حساب‌ها و ترکیب transportها در مثال‌های API آمده‌اند.

ابتدا این فایل‌ها را بخوانید​

پیش از تغییر رفتار تونل، کد منبعی را بخوانید که قرارداد را پیاده‌سازی می‌کند. این فایل‌ها کوتاه و فشرده‌اند و سازوکار فعلی را تعریف می‌کنند، اما بی‌نقص‌بودن پیاده‌سازی را ثابت نمی‌کنند. اگر کد منبع، تست‌ها و این راهنما با هم ناسازگارند، به‌جای آنکه بی‌سروصدا متن را با کد موجود یکسان کنید، اختلاف را بررسی و گزارش کنید.

حوزهفایل‌ها
callback‌های تونل و ساخت chainww/net/tunnel.h، ww/net/tunnel.c
طول عمر line و state مختص هر lineww/net/line.h، ww/net/line.c
نهایی‌سازی chain و packet line‌هاww/net/chain.h، ww/net/chain.c
رفتار پیش‌فرض تونل packetww/net/packet_tunnel.h، ww/net/packet_tunnel.c
shift buffer‌ها و paddingww/bufio/shiftbuffer.h، ww/bufio/shiftbuffer.c
buffer pool‌هاww/bufio/buffer_pool.h، ww/bufio/buffer_pool.c
metadata مربوط به nodeww/objects/node.h

تونل‌های مرجع مناسب​

نزدیک‌ترین الگوهای موجود را برای مقایسه به کار ببرید، نه به‌عنوان قالبی که چشم‌بسته کپی شود. تونل‌های زیر شکل‌های مفیدی از قرارداد را نشان می‌دهند، اما رفتار آن‌ها نیز باید با نقش دقیق مورد تغییر سنجیده شود:

مرجعبرای چه چیزی بخوانید؟
TcpListener، UdpListeneradapter‌های ابتدای chain که مالک یک منبع سیستم‌عامل‌اند و line‌های عادی خود را می‌سازند و نابود می‌کنند.
TcpConnector، UdpConnectoradapter‌های انتهای chain که مالک یک منبع سیستم‌عامل‌اند، اما line ساخته‌شده در upstream را قرض می‌گیرند.
TlsClient، EncryptionClientبسته‌بندی stateful پروتکل، state مختص line و Finish صحیح همراه با byte‌های پایانی.
MuxClientمالکیت داخلی line، line‌های parent/child و ایمنی در برابر re-entrancy.
PacketsToStream، StreamToPackets، PacketsToConnectionمرزهای packet-to-stream و پل‌هایی که به packet line متکی‌اند.
PingClient، PingServertransform‌های جفت و مستقیم ICMP Echo، داده‌ی decode‌شده در همان جهت، و پاسخ دقیق در جهت مخالف.
templateاسکلت حداقلی که نقطه‌ی شروع همه‌ی تونل‌هاست.

مدل runtime​

WaterWall instance‌های تونل را در یک chain مرتب کنار هم قرار می‌دهد. یک chain معمول stream چنین است:

TcpListener -> ObfuscatorClient -> TlsClient -> TcpConnector

node اول و آخر معمولاً adapter هستند. آن‌ها مالک یک منبع سیستم‌عامل، مانند socket نوع TCP یا UDP، دستگاه TUN، ‏raw socket و موارد مشابه‌اند. فقط همین node‌ها از دنیای بیرون داده می‌خوانند یا در آن می‌نویسند.

هر چیزی میان adapter‌ها یک تونل میانی است. تونل‌های میانی باید قابل ترکیب بمانند: callback‌ها و payload‌ها را بدون هیچ فرضی درباره‌ی adapter دو طرف تغییر دهند. تونل میانی‌ای که فقط در کنار یک adapter خاص کار می‌کند معیوب است، حتی اگر تست‌های خودش موفق باشند.

 -------------- chain --------------------------------------------------

------------ ------------ ------------
| | -- up -> | | -- up -> | |
| Tunnel 1 | | Tunnel 2 | | Tunnel 3 |
| | <- down- | | <- down- | |
------------ ------------ ------------
(adapter) (middle) (adapter)

-----------------------------------------------------------------------

در شکل، chain مجموعه‌ی تونل‌های به‌هم‌متصل را نشان می‌دهد. up مسیر upstream و down مسیر downstream است؛ adapter مرز ارتباط با بیرون و middle تونل میانی است.

هر اتصال یک line_t است. line دو انتها دارد که در ww/net/line.h با Down-end <----> Up-end توصیف شده‌اند. chain head، یعنی نخستین adapter، رو به down-end قرار می‌گیرد و chain tail، یعنی آخرین adapter، رو به Up-end. سازوکار backpressure متقارن است: اگر نوشتن در down-end متوقف شود، Up-end در حالت Pause قرار می‌گیرد و برعکس.

چهار شیء اصلی​

شیءتعریف
node_tconfig parse‌شده به‌همراه metadata شامل type، next، flags، layer_group، required_padding_left و createHandle. برای هر node در config JSON یک نمونه وجود دارد. تعریف در ww/objects/node.h.
tunnel_tinstance اجرایی یک node. دوازده function pointer مربوط به callback‌ها، لینک‌های next/prev، ‏tunnel state، اندازه‌ی line state و offset‌های chain را نگه می‌دارد. تعریف در ww/net/tunnel.h.
line_tیک اتصال: line عادی اتصال، line منطقی یا packet line مربوط به worker. شامل routing context، نشانه‌های user/auth، شناسه‌ی worker مالک، reference count، ‏flag مربوط به alive و state مختص line برای همه‌ی تونل‌ها است. تعریف در ww/net/line.h.
tunnel_chain_tمجموعه‌ی مرتب تونل‌ها. هنگام نهایی‌سازی، اندازه‌ی کل line state، مجموع padding سمت چپ و packet line‌های هر worker را محاسبه می‌کند. تعریف در ww/net/chain.h.

تفاوت tunnel state و line state​

هر تونل می‌تواند مالک دو نوع state باشد:

  • tunnel state یا tstate — یک block به‌ازای هر instance تونل که میان همه‌ی اتصال‌های عبوری مشترک است. اندازه‌ی آن هنگام tunnelCreate() ثابت می‌شود.
  • line state یا lstate — یک slot به‌ازای هر line_t که برای همان اتصال و همین تونل خصوصی است.

برای دسترسی به آن‌ها از helper‌های موجود استفاده کنید، نه دسترسی مستقیم به struct‌ها:

my_tstate_t *ts = tunnelGetState(t);        // State سراسری این Tunnel
my_lstate_t *ls = lineGetState(line, t); // State همین Tunnel برای این Line

lineGetState() چگونه slot درست را پیدا می‌کند؟ هنگام index‌شدن chain، runtime به هر تونل یک lstate_offset می‌دهد و به‌اندازه‌ی lstate_size در هر line فضا رزرو می‌کند؛ به tunnelDefaultOnIndex در ww/net/tunnel.c نگاه کنید. lineGetState() پس از آن فقط برابر است با line->tunnels_line_state + t->lstate_offset. به همین دلیل line state باید در Init مقداردهی اولیه شود و پس از نابودشدن دیگر معتبر دانسته نشود: این حافظه بخشی مشترک از فضای داخلی line است و اندازه‌ی آن فقط یک بار برای کل chain تعیین می‌شود.

اندازه‌ی state‌ها با cache line هم‌تراز است. هنگام zeroکردن line state، دقیقاً مانند تونل‌های موجود، تمام ناحیه‌ی هم‌تراز‌شده را پاک کنید:

memoryZeroAligned32(ls, tunnelGetCorrectAlignedLineStateSize(sizeof(my_lstate_t)));

جهت callback‌ها​

مالکیت جهت اولین نکته‌ای است که باید درست فهمیده شود و رایج‌ترین منشأ خطاست.

جریانمفهومhelper مربوط به forward
upstreamمسیر request، خروجی یا رو به جلو؛ به‌سمت Up-end و nexttunnelNextUpStream*
downstreamمسیر response، ورودی یا رو به عقب؛ به‌سمت down-end و prevtunnelPrevDownStream*

هر تونل دوازده callback مربوط به جریان دارد؛ برای هر ترکیب {event} x {direction} یک callback:

fnInitU  fnEstU  fnPayloadU  fnPauseU  fnResumeU  fnFinU    // Handlerهای upstream
fnInitD fnEstD fnPayloadD fnPauseD fnResumeD fnFinD // Handlerهای downstream

تقریباً هیچ‌گاه این pointer‌ها را مستقیماً صدا نمی‌زنید. از helper‌های forwarding استفاده می‌کنید که handler متناظر را روی همسایه فراخوانی می‌کنند:

// «Next در upstream»: تابع self->next->fnPayloadU(self->next, line, buf) را صدا می‌زند
tunnelNextUpStreamPayload(t, line, buf);

// «Prev در downstream»: تابع self->prev->fnPayloadD(self->prev, line, buf) را صدا می‌زند
tunnelPrevDownStreamPayload(t, line, buf);

forwardکردن رویدادهای چرخه‌ی عمر نیز از همین نام‌گذاری پیروی می‌کند:

tunnelNextUpStreamInit(t, line);      // ادامهٔ زنجیره را در جهت جلو آغاز می‌کند
tunnelNextUpStreamFinish(t, line); // جهت رو‌به‌جلو را می‌بندد

tunnelPrevDownStreamEst(t, line); // برقراری اتصال را به سمت عقب اعلام می‌کند
tunnelPrevDownStreamFinish(t, line); // جهت رو‌به‌عقب را می‌بندد

اگر تونل callback مشخصی را override نکند، رفتار پیش‌فرض framework را می‌گیرد که صرفاً رویداد را در همان جهت به همسایه pass-through می‌کند؛ به tunnelDefaultUpStreamPayload و tunnelDefaultDownStreamPayload در ww/net/tunnel.c نگاه کنید. بنابراین تونلی که فقط در upstream عمل obfuscation را انجام می‌دهد، می‌تواند handler مربوط به payload در downstream را روی مقدار پیش‌فرض بگذارد تا byte‌ها بدون تغییر عبور کنند.

جهت‌ها را برعکس نکنید

صرفاً چون تونل سمت server «برعکس به نظر می‌رسد»، helper مربوط به forwardکردن buffer دریافتی را عوض نکنید. chain واقعی را رسم کنید، Up-end و down-end را علامت بزنید و هر buffer خروجی را جداگانه دسته‌بندی کنید. در Ping v2، داده‌ی اصلی پس از decode در همان جهت callback درخواست دریافتی ادامه می‌یابد، اما یک Echo Reply دقیق که جداگانه clone شده، به‌عنوان تأیید در جهت مخالف حرکت می‌کند. بنابراین PingServer در handler درخواست upstream خود به downstream پاسخ می‌دهد و PingClient در handler درخواست downstream خود به upstream پاسخ می‌دهد. داده‌ی برنامه در جهت معکوس، یک Echo request جداگانه را آغاز می‌کند. برای جزئیات بخش ۴ را ببینید.

کدام تابع forwarding را صدا بزنم؟​

در حال handleکردن...و قصد فرستادن رویداد به...تابع مناسب
هر چیزیبخش بالاتر chain، به‌سمت nexttunnelNextUpStream{Init,Payload,Est,Pause,Resume,Finish}
هر چیزیبخش پایین‌تر chain، به‌سمت prevtunnelPrevDownStream{Init,Payload,Est,Pause,Resume,Finish}

helper‌های قرینه‌ی tunnelUpStream* و tunnelDownStream* که Next/Prev ندارند، handler را روی تونلی فراخوانی می‌کنند که pointer مستقیم آن را در اختیار دارید. این حالت زمانی به کار می‌رود که تونل شاخه‌ای را هدایت می‌کند که در پایین خود bind کرده است؛ مانند route target، ‏fallback یا شاخه‌ی کمکی. ورودی واقعی چنین شاخه‌ای را با tunnelGetBranchEntry() پیدا کنید و فرض نکنید node خامی که دریافت کرده‌اید همان head قابل‌فراخوانی است.

چرخه‌ی عمر تونل​

instance تونل از مجموعه‌ای ثابت از lifecycle hook‌ها عبور می‌کند. این hook‌ها با callback‌های جریانِ مختص اتصال که در بالا دیدیم متفاوت‌اند: در زمان config، راه‌اندازی و خاموش‌شدن و به‌ازای هر تونل یا worker یک بار اجرا می‌شوند، نه برای هر packet.

runtime تقریباً با ترتیب زیر آن‌ها را اجرا می‌کند؛ توضیح ابتدای ww/net/tunnel.h و پیاده‌سازی‌های پیش‌فرض در ww/net/tunnel.c را ببینید:

createHandle(node)         // node.c -> create.c: تخصیص، تعیین Callbackها و Parse تنظیمات
|
onChain // اتصال به next/prev و درج در زنجیره (پیش‌فرض node->next را می‌پیماید)
|
solve + onSolvedTopology // بررسی Snapshot حل‌شده؛ پس از تغییر توپولوژی تکرار می‌شود
|
onIndex // تعیین chain_index و lstate_offset و رزرو بایت‌های Line State
|
onPrepare // آماده‌سازی پیش از Start (نام پیشین: onChainingComplete)
|
onStart // زنجیره کامل است؛ آغاز کار واقعی / راه‌اندازی Packet Lineها
|
... // زمان اجرا: Callbackهای جریان اینجا اجرا می‌شوند
|
onQuiesceRequest // Thread اصلی: بستن پذیرش؛ درخواست توقف Producer؛ بدون انتظار
|
onWorkerQuiesce // هر Worker مالک: جداسازی Timer/Watcher/Handleهای وابسته به Worker
|
onQuiesceWait // Thread اصلی: Join/Detach ریشه‌های Callback خارجیِ از قبل پذیرفته‌شده
|
onWorkerStop // هر Worker مالک: Drainکردن Lineهای عادی و منابع تحت مالکیت
|
onStop // Thread اصلی: آزادسازی منابع مشترک باقی‌ماندهٔ مؤلفه
|
onDestroy // Thread اصلی: بازپس‌گیری Instance پس از پایان همهٔ Referenceها

کاربرد هر hook:

hookرفتار پیش‌فرضمورد معمول override
createHandleالزامی استساخت تونل با tunnelCreate(node, sizeof(tstate), sizeof(lstate))، تنظیم دوازده callback، ‏parse و اعتبارسنجی تنظیمات JSON و تخصیص منابع هر worker.
onChaintunnelDefaultOnChain با دنبال‌کردن node->next chain را می‌سازدبه‌ندرت override می‌شود. adapter‌ها نیز مقدار پیش‌فرض را حفظ می‌کنند.
onSolvedTopologyNULL (اختیاری)snapshot فعلی توپولوژی حل‌شده را مصرف می‌کند و در صورت نیاز، پیش از indexing توپولوژی خصوصی وابسته به layer را می‌سازد. snapshot لزوماً نهایی نیست و hook ممکن است چند بار اجرا شود، پس باید تکرار را تحمل کند. فقط و فقط اگر توپولوژی تغییر کرد true برگردانید؛ در این حالت snapshot فوراً نامعتبر می‌شود و NodeManager پیش از اجرای hook بعدی دوباره مسئله را حل می‌کند.
onIndextunnelDefaultOnIndex، ‏offset مربوط به line state را تعیین می‌کندپس از آنکه گسترش توپولوژی و حل layer‌ها به نقطه‌ی ثابت رسید، یک بار اجرا می‌شود. می‌توان domain‌های نهایی edge‌های حل‌شده را اینجا مصرف کرد، اما توپولوژی chain نباید تغییر کند. به‌ندرت override می‌شود.
onPrepareبدون عملیاتآماده‌سازی پیش از start که به وجود chain نیاز دارد.
onStartبدون عملیاتآغاز listen/connect و Bootstrapکردن packet line‌ها؛ بخش ۴ را ببینید.
onQuiesceRequestبدون عملیاتروی thread اصلی، در زمانی محدود و بدون مسدودکردن، پذیرش را می‌بندد و توقف producer را درخواست می‌کند. اینجا join نکنید و منابع لازم برای drainکردن line‌ها را آزاد نکنید.
onWorkerQuiesceبدون عملیاتروی هر worker مالک، پیش از تأیید quiescence، ‏timer‌ها، ‏watcher‌ها و handle‌های وابسته به همان WID را جدا می‌کند.
onQuiesceWaitبدون عملیاتپس از quiesceشدن worker‌ها، روی thread اصلی ریشه‌های callback خارجیِ از قبل پذیرفته‌شده را join یا detach می‌کند؛ منابع لازم برای drain line‌ها باید همچنان زنده بمانند.
onWorkerStopبدون عملیاتهنگام drain روی هر worker مالک، فقط inventory‌های مرجع source و line‌های عادیِ تحت مالکیت داخلی را می‌بندد. ترتیب ذخیره‌سازی chain تضمینی برای ترتیب همسایه‌ها نیست؛ registry‌های line قرضی را فقط در onDestroy نهایی و پس از drain همه‌ی مالکان اعتبارسنجی کنید.
onStopبدون عملیاتپس از drainشدن همه‌ی worker‌ها، منابع مشترک باقی‌مانده را روی thread اصلی آزاد می‌کند؛ نباید به timer یا watcher وابسته به worker دست بزند.
onDestroytunnelDestroy، ‏instance را آزاد می‌کندآزادکردن تنظیمات، SSL context‌ها و pool‌ها و سپس فراخوانی tunnelDestroy.

بیشتر تونل‌ها فقط مرحله‌های چرخه‌ی عمر مربوط به منابعی را override می‌کنند که واقعاً مالکشان هستند؛ به‌علاوه‌ی callback‌های جریان موردنیاز. رفتار پیش‌فرض chaining و indexing را نگه دارید، مگر آنکه قرارداد توپولوژی تونل چیز دیگری بخواهد. هر انحراف را با قواعد توپولوژی حل‌شده بسنجید، نه با کپی مکانیکی یک مرجع.

نتایج layer در دو نقطه‌ی جداگانه مصرف می‌شوند. فقط هنگامی از onSolvedTopology استفاده کنید که تونل باید پیش از indexing عملی انجام دهد؛ برای نمونه، توپولوژی خصوصی‌ای درج کند که محل آن به domain‌های حل‌شده‌ی مجاور وابسته است. callback‌ای که توپولوژی را تغییر می‌دهد باید استفاده از snapshot را متوقف کند و true برگرداند. توپولوژی تنها وقتی نهایی است که یک دور کامل اجرای callback‌ها هیچ تغییری گزارش نکند. اگر تونل فقط باید راه‌حل نهایی را بخواند، از onIndex استفاده کنید؛ این راه‌حل برای onIndex و همه‌ی callback‌های بعدی چرخه‌ی عمر ثابت می‌ماند. توپولوژی داخلی ثابتی که وجود و محل آن پیش از حل معلوم است، همچنان باید در onChain ساخته شود.

اسکلت حداقلی تونل​

همه‌ی تونل‌ها از یک ساختار یکسان آغاز می‌شوند. نمونه‌ی زیر نسخه‌ی کمی خلاصه‌شده‌ی create.c در تونل template است:

tunnel_t *templateTunnelCreate(node_t *node)
{
tunnel_t *t = tunnelCreate(node, sizeof(template_tstate_t), sizeof(template_lstate_t));
if (! t)
{
return NULL;
}

t->fnInitU = &templateTunnelUpStreamInit;
t->fnEstU = &templateTunnelUpStreamEst;
t->fnFinU = &templateTunnelUpStreamFinish;
t->fnPayloadU = &templateTunnelUpStreamPayload;
t->fnPauseU = &templateTunnelUpStreamPause;
t->fnResumeU = &templateTunnelUpStreamResume;

t->fnInitD = &templateTunnelDownStreamInit;
t->fnEstD = &templateTunnelDownStreamEst;
t->fnFinD = &templateTunnelDownStreamFinish;
t->fnPayloadD = &templateTunnelDownStreamPayload;
t->fnPauseD = &templateTunnelDownStreamPause;
t->fnResumeD = &templateTunnelDownStreamResume;

t->onPrepare = &templateTunnelOnPrepair;
t->onStart = &templateTunnelOnStart;
t->onStop = &templateTunnelOnStop;
t->onDestroy = &templateTunnelDestroy;

return t;
}

بخش ۵ ساختار کامل دایرکتوری پشت این اسکلت، از جمله metadata در node.c، فایل line_state.c و محل هر callback را بررسی می‌کند.

«درست‌بودن» در اینجا یعنی چه؟​

یک تغییر درست در تونل باید هم‌زمان همه‌ی موارد قابل‌اعمال زیر را حفظ کند:

  • قابلیت ترکیب تونل، مستقل از همسایه‌های آن
  • قواعد چرخه‌ی عمر line: مقداردهی در Init، یک بار destroy‌شدن و فقط به‌دست مالک
  • مالکیت جهت: هرگز Next* و Prev* را وارونه نکنید
  • فرض‌های مربوط به padding buffer: رعایت required_padding_left
  • استفاده‌ی صحیح از shift buffer: فراخوانی sbufShiftLeft فقط با ظرفیت کافی در چپ
  • ایمنی reference: جلوگیری از use-after-free برای line_t و line state
  • معنای packet line: محلی برای worker و بدون destroy در زمان اجرا
  • رفتار موجود chain: نبود regression در چیدمان‌های دیگر

اگر یک تغییر پیشنهادی نتواند توضیح دهد که تک‌تک این موارد را چگونه حفظ می‌کند، هنوز آماده نیست. ادامه‌ی این راهنما در واقع شرح مفصل همین فهرست است.

هویت thread مربوط به worker و WID​

هویت thread مربوط به worker در WaterWall صریح است و به‌طور پیش‌فرض نامعتبر در نظر گرفته می‌شود:

  • WID یعنی index یک slot ثبت‌شده‌ی worker، نه شناسه‌ی thread در سیستم‌عامل یا برنامه مانند getTID().
  • همه‌ی worker‌های ثبت‌شده هنگام اجرای عادی event loop دارند و نقش ثبت‌شده‌ی جداگانه‌ای بدون event loop وجود ندارد. getWorkersCount() تعداد همه‌ی slot‌های ثبت‌شده را برمی‌گرداند.
  • thread‌های ثبت‌نشده، از جمله reader‌های TUN در Darwin/BSD، ‏reader‌های capture، callback‌های completion بومی و thread‌های کمکی و تست، مقدار getWID() == kInvalidWID یعنی UINT8_MAX را می‌بینند.
  • workerWIDIsRegistered(wid) معتبر بودن slot را بررسی می‌کند و currentThreadIsWorker() هویت ثبت‌شده‌ی thread فراخواننده را. هیچ‌کدام مالکیت state یک worker دیگر را ثابت نمی‌کند؛ برای بررسی مالک دقیق از currentThreadOwnsWorker(wid) استفاده کنید.
  • thread‌های کمکی باید کارِ دارای مالکیت را از طریق صف پیام worker ارسال کنند و هرگز نباید مستقیماً یک pool محلی worker را قرض بگیرند. custom event خام loop فقط برای عملیات بدون مالکیت و متعلق به خود loop رزرو شده است.
  • ‏lwIP روی worker‌های عادی اجرا می‌شود و thread یا slot جداگانه‌ای ندارد؛ سقف تعداد worker‌ها همچنان ۲۵۴ است.
  • thread‌های native/library برای log و تشخیص خطا از getTID() استفاده می‌کنند و تا زمانی که صریحاً با workerBindCurrentThread() متصل نشوند WID نامعتبر دارند.

هویت worker پس از detachشدن loop و صف پیام هنگام teardown تا زمان unbindشدن thread معتبر می‌ماند. این هویت وجود loop یا pool، بازبودن admission یا فعال‌بودن callback عادی را تضمین نمی‌کند؛ مرز resource و lifecycle لازم را جداگانه بررسی کنید. nullبودن loop هرگز نشان‌دهنده‌ی نقش دیگری برای worker نیست.

snapshot‌های منابع پردازه نیز از همین قاعده‌ی مالکیت پیروی می‌کنند. provider‌های host و cgroup فقط هنگام مقداردهی همگام global state و در sampler پس‌زمینه‌ی worker 0 اجرا می‌شوند. در Linux، ‏sampler بیشترین درصد مصرف و کمترین ظرفیت باقی‌مانده را در سلسله‌مراتب محدود و قابل‌مشاهده‌ی cgroup منتشر می‌کند؛ محدودیت‌های مبهم یا پنهان برای fallback محافظه‌کارانه‌ی مصرف‌کننده همچنان ناموجود محسوب می‌شوند. مسیرهای داغ worker، ‏tuple اتمیک محدود و یکپارچه‌ی منتشرشده را مصرف می‌کنند و هرگز فایل سیستمی باز نمی‌کنند، state قدیمی host را تازه نمی‌کنند، تخصیص یا انتظار ندارند و mutex مربوط به sampler را نمی‌گیرند.

انتخاب helper مناسب برای context مربوط به worker​

هرگز getWID() را مستقیماً در بررسی affinity، ‏index یک array یا lookup منبع به‌کار نبرید. محدودترین helper سازگار با context موجود را انتخاب کنید. helper دامنه‌ی معتبر را در قرارداد خود کد می‌کند و اجازه نمی‌دهد یک thread ثبت‌نشده صرفاً به‌دلیل برابری اتفاقی از بررسی عبور کند.

contextاستفاده
callback یک line_t * داردlineIsOnCurrentWorker(line)، ‏lineGetWID(line)، ‏lineGetBufferPool(line)، ‏lineReuseBuffer(line, buf)
callback پیام worker یک worker_t *worker داردworker->wid، ‏worker->buffer_pool، ‏worker->loop؛ TLS را دوباره نخوانید.
callback مربوط به event/timer/I/O یک loop یا event داردیک بار getLoopWorkerWID(weventGetLoop(ev)) را بگیرید و مقدار محلی را دوباره استفاده کنید.
callback واقعی event loop بدون line یا workergetCurrentWorker()، ‏getCurrentWorkerWID()، ‏getCurrentWorkerBufferPool()، ‏getCurrentWorkerLoop()
callback چرخه‌ی عمر که wid صریح دریافت کرده استcurrentThreadOwnsWorker(wid)
کدی که بنا بر طراحی به worker 0 نیاز داردWID صریح 0 همراه currentThreadOwnsWorker(0)؛ هرگز آن را از TLS پیش‌فرض استنباط نکنید.
API قابل‌دسترسی از بیرون یا در غیر این صورت fallibletryGetCurrentWorker() یا currentThreadIsWorker()؛ سپس error یا NULL برگردانید یا کار را queue کنید.
callback خام lwIPمالک engine انتخاب‌شده؛ پیش از ورود به stack، مالکیت engine و WID صریح را بررسی کنید.
thread کمکی deviceفقط pool/channel متعلق به device؛ تحویل به یک event worker صریح.
فقط loggingاستفاده‌ی خام از getWID() مجاز است، به شرط عبور از workerWIDForLog() و چاپ با %d در کنار getTID()؛ مقدار -1 یعنی ثبت‌نشده.

accessor‌های بررسی‌شده‌ی getCurrentWorker*() فقط برای callback‌های داخلی‌اند که قراردادشان از قبل حضور روی worker ثبت‌شده را تضمین می‌کند. این توابع به‌جای fallback به worker 0، ‏abort می‌کنند. بنابراین هر چیزی که از thread دستگاه، callback یک library یا API عمومی قابل‌دسترسی است باید از شکل predicate استفاده کند و در صورت نبود context معتبر به‌طور تمیز fail شود.

کار وابسته به line یک مرز cross-thread اختصاصی دارد. چهار تابع lineSchedule*Task*() از هر thread قابل فراخوانی‌اند، اما فقط وقتی فراخواننده یک live handle همگام‌شده دارد: reference همراه gate یا عملیات registry که تا گرفتن reference داخلی scheduler مانع lineDestroy() شود. pointer خام یا reference‌ای که فقط allocation را نگه می‌دارد برای race با مرگ منطقی کافی نیست و submission نیز دسترسی به state یا pool ‏worker مقصد نمی‌دهد.

callback همیشه روی event worker مالک line اجرا می‌شود. کار فوری و delay صفر queue می‌شوند؛ delay مثبت از thread دیگر ابتدا setup timer را روی owner queue می‌کند. پس AcceptedAsync فقط انتقال مالکیت به مسیر async را ثابت می‌کند، نه armشدن timer یا اجرای task. قرارداد کامل نتیجه و لغو در بخش ۲ آمده است.

// Callback مربوط به Line: هم Affinity و هم مالکیت Pool از خود Line می‌آیند.
if (! lineIsOnCurrentWorker(l))
{
sendWorkerMessageForceQueueWithCleanup(lineGetWID(l), doWork, cleanup, t, l, buf);
return;
}
lineReuseBuffer(l, buf);

// Callback پیام Worker: پیام دقیقاً به یک Worker تحویل شده است.
static void onWorkerMessage(worker_t *worker, void *arg1, void *arg2, void *arg3)
{
line_t *packet_line = tunnelchainGetWorkerPacketLine(tunnelGetChain(t), worker->wid);
...
}

// Callback مربوط به Timer: مالک را از Loop بگیرید، یک بار بررسی و دوباره استفاده کنید.
const wid_t wid = getLoopWorkerWID(weventGetLoop(timer));
ts->worker_timers[wid] = NULL;

// خروجی lwIP: همسایه‌های دلخواه را حتی روی همان worker در صف بگذارید.
// PTC فقط می‌تواند sink مستقیم و تأییدشده‌ای را فراخوانی کند که صرفاً enqueue می‌کند.
queueToPacketWorker();

// Thread دستگاه: فقط منابع متعلق به دستگاه؛ تحویل با WID صریح.
sbuf_t *buf = bufferpoolGetLargeBuffer(dev->reader_buffer_pool);
sendWorkerMessageForceQueueWithCleanup(target_wid, deliver, cleanup, dev, buf, NULL);

پاک‌سازی buffer میان thread‌ها با recycleکردن یکسان نیست. reuseBuffer(buf) یعنی «روی event worker فعلی دوباره استفاده کن» و call site‌ای را که pool محلی worker ندارد رد می‌کند. وقتی روی worker مالک buffer نیستید، آزادسازی درست sbufDestroy(buf) است؛ این مسیرها را به reuseBuffer() تبدیل نکنید.

API‌های معمول تونل، یعنی <node>TunnelApi()، روی event worker اجرا می‌شوند و مالکیت پیام خود را می‌گیرند. پیام را با tunnelapiRecycleMessage() یا tunnelapiUnsupportedMessage() در ww/objects/node.h مصرف کنید. هر دو تابع وقتی caller روی event worker نیست buffer را نابود می‌کنند و kApiResultError برمی‌گردانند، نه اینکه pool مربوط به worker 0 را قرض بگیرند.

tests/worker_identity_source_policy_test.py این قواعد را اعمال می‌کند: تو‌در‌توکردن تازه‌ی getWorkerXxx(getWID())، تصمیم مالکیت با getWID() == 0 خام، مقایسه‌ی خام affinity با WID فعلی و کد تکراری مربوط به recycle پیام tunnel API رد می‌شوند.

انتخاب idle table مناسب​

وقتی ساخت، جست‌وجو، refresh، حذف، callback انقضا، drain و نابودی item همگی متعلق به یک event worker هستند، local_idle_table_t را انتخاب کنید. این table هیچ همگام‌سازی یا تحویل میان worker‌ها ندارد؛ هرگز خود table یا یکی از handle‌های قرضی item‌های آن را به worker دیگری ندهید.

فقط وقتی idle_table_t را انتخاب کنید که یک table باید item‌های متعلق به چند event worker را index کند و انقضا باید به مالک ثبت‌شده‌ی هر item ارسال شود. index‌های آن همگام‌سازی شده‌اند، اما handle بازگشتی همچنان reference‌ای قرضی و متعلق به worker است؛ table مشترک یک container عمومی و thread-safe برای handle‌ها نیست. صرفاً به‌دلیل نام یا mutex آن تصور نکنید امن‌تر است. refresh مشترک عمداً بدون lock انجام می‌شود و مرتب‌سازی دوباره‌ی heap را به پیمایش timer می‌سپارد.

هر دو table یک قاعده‌ی طول عمر منطقی دارند: حذف موفق، قابلیت lookup را پاک و همه‌ی handle‌های خام را پیش از بازگشت نامعتبر می‌کند. مصرف‌کننده‌ای که handle را ذخیره می‌کند باید آن slot مرجع را پیش از حذف پاک کند و پس از آن هرگز item را نخواند. بازپس‌گیری فیزیکی متفاوت است: حذف محلی معمولاً فوراً حافظه را آزاد می‌کند، اما یک item مشترک ممکن است تا تسویه‌ی ایمن در مالکیت heap یا message باقی بماند.

واژه‌نامه​

اصطلاحمفهوم
adapterتونلی در ابتدا یا انتهای chain که مالک یک منبع سیستم‌عامل، مانند socket، ‏TUN یا raw socket است. مالکیت منبع به‌معنای مالکیت line_t نیست: source‌ای مانند TcpListener، ‏line عادی را می‌سازد، اما tail‌ای مانند TcpConnector آن را قرض می‌گیرد.
تونل میانیتونلی غیر از adapter که callback‌ها یا payload‌ها را تغییر می‌دهد و باید قابل ترکیب بماند.
lineیک line_t؛ یک اتصال یا یک packet line مربوط به worker که state مختص line همه‌ی تونل‌ها را نگه می‌دارد.
upstreamجهت رو به جلو یا خروجی، به‌سمت next و Up-end.
downstreamجهت رو به عقب یا ورودی، به‌سمت prev و down-end.
tstatetunnel state؛ مختص instance و مشترک میان همه‌ی line‌ها.
lstateline state؛ مختص line و خصوصی برای یک تونل.
packet lineیک line_t پایدار به‌ازای هر worker در chain‌های layer 3؛ line اتصال نیست.
WIDindex یک slot ثبت‌شده‌ی worker در بازه‌ی 0 .. WORKERS_COUNT - 1؛ روی thread ثبت‌نشده kInvalidWID است.
kInvalidWIDمقدار sentinel برابر UINT8_MAX برای thread ثبت‌نشده یا کمکی.
re-entrancyحالتی که callback میان‌تونلی پیش از بازگشت کنترل، به‌صورت synchronous دوباره وارد تونل شما می‌شود و ممکن است line را ببندد.
reflectionforwardکردن اشتباه callback به‌سمتی که قبلاً Finish شده است؛ یکی از علت‌های رایج crash.

ادامه: بخش ۲: line‌ها، callback‌ها و ایمنی طول عمر.