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

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

این بخش درباره‌ی قراردادهایی است که نقض آن‌ها از رایج‌ترین علت‌های crash است: چرخه‌ی عمر callback‌های هر اتصال، مالکیت line و محافظت در برابر callback‌های re-entrant که ممکن است قبل از تمام‌شدن کار شما line را نابود کنند.

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

شش callback جریان​

هر اتصال با شش رویداد در دو جهت هدایت می‌شود: upstream به‌سمت next و downstream به‌سمت prev.

رویدادمفهومنکته‌ی جهت
Initیک line در حال شروع است؛ state مختص line این تونل را مقداردهی کنید.از adapter سازنده در جهت upstream حرکت می‌کند.
Estسمت دور واقعاً برقرار شده است؛ برای مثال socket راه دور متصل شده.معمولاً در جهت downstream و به‌سمت آغازکننده بازمی‌گردد.
Payloadبخشی از داده در قالب sbuf_t که باید transform و forward شود.هر دو جهت.
Pausebackpressure؛ فعلاً فرستادن در این جهت را متوقف کنید.هر دو جهت.
Resumebackpressure رفع شده است و ارسال می‌تواند ادامه یابد.هر دو جهت.
Finishاین جهت در حال بسته‌شدن است.جهت‌دار و مخرب؛ توضیح آن در ادامه آمده است.

تونل فقط handler‌های موردنیازش را پیاده‌سازی می‌کند. بقیه با رفتار پیش‌فرض framework، رویداد را بدون تغییر به همسایه می‌فرستند. مثلاً یک encryptor می‌تواند Payload را در هر دو جهت override کند و Pause/Resume را به رفتار پیش‌فرض بسپارد.

یک line چگونه آغاز می‌شود؟​

یک اتصال معمولی با Init آغاز می‌شود و adapter آن را ایجاد می‌کند:

  1. یک adapter، مانند TcpListener، ‏socket را accept و یک line_t ایجاد می‌کند.
  2. تابع tunnelNextUpStreamInit() را فراخوانی می‌کند.
  3. این فراخوانی fnInitU تونل بعدی را اجرا می‌کند؛ تونل state مختص line خود را مقداردهی و Init را در upstream به جلو forward می‌کند.
  4. هر تونل میانی به‌ترتیب state مختص line خود را مقداردهی می‌کند.
  5. adapter انتهایی، مانند TcpConnector، عملیات واقعی شبکه را آغاز می‌کند.
Init ایزوله نیست

Init یک تونل می‌تواند پیش از بازگشت، callback‌های دیگری مانند Payload، Est، ‏Pause، ‏Resume یا حتی Finish را به‌صورت synchronous فعال کند؛ زیرا تونل پایین‌دستی ممکن است بلافاصله داده را برگرداند یا اتصال را ببندد. این همان خطر re-entrancy است که در ادامه توضیح داده می‌شود، با این تفاوت که از لحظه‌ی تولد line آغاز می‌شود.

قواعد Init:

  • state مختص line همین تونل را در Init مقداردهی کنید. callback‌های بعدی حق دارند فرض کنند این state از قبل وجود دارد.
  • برای محافظت از callback‌های بعدی یک boolean به نام initialized اضافه نکنید. در چرخه‌ی عمر معمول WaterWall، ‏Init همیشه برای تونل زودتر اجرا می‌شود و flag‌ای به نام initialized تقریباً همیشه وصله‌ای روی control flow ناامن است. فقط زمانی آن را اضافه کنید که یک قرارداد مستند و صریح برای نقش line یا callback واقعاً به state پیش از Init معمول نیاز داشته باشد؛ خود آن استثنا را نیز مستقیماً تست کنید.
  • اگر Init یک helper مربوط به forwarding و re-entrant را صدا می‌زند و پس از آن هنوز باید به line دسترسی داشته باشد، از آن محافظت کنید؛ بخش نگه‌داشتن reference برای line را ببینید. اگر Init فقط forward می‌کند و برمی‌گردد، نیازی به نگه‌داشتن reference نیست.

طول عمر line: ‏refcount و alive​

هر line_t یک refc اتمیک و یک boolean به نام alive دارد؛ به ww/net/line.h نگاه کنید. این دو مفهوم جدا هستند و یکی‌گرفتن آن‌ها باعث bug می‌شود:

  • lineRef(line) مقدار refc را افزایش می‌دهد و تضمین می‌کند تا زمانی که این reference را دارید، تخصیص حافظه آزاد نشود. reference مربوط به line یک mutex یا lock نیست و mutual exclusion یا انتقال worker affinity فراهم نمی‌کند.
  • lineUnref(line) یک reference را آزاد می‌کند. آزادسازی reference نهایی فقط تخصیص line‌ای را بازپس می‌گیرد که از نظر منطقی مرده است؛ رساندن reference یک line زنده به صفر، نقض fatal قرارداد است.
  • lineDestroy(line) مقدار alive = false را تنظیم و reference سازنده را آزاد می‌کند.
  • lineIsAlive(line) می‌گوید آیا line از نظر منطقی هنوز باز است یا نه.

تفاوت مهم:

داشتن reference مربوط به line فقط اعتبار حافظه‌ی تخصیص‌یافته را حفظ می‌کند؛ به این معنا نیست که محتوای line قفل شده است یا line از نظر منطقی هنوز زنده است. پس از یک callback از نوع re-entrant، ممکن است line ارجاع‌شده alive == false باشد. پیش از ادامه همیشه دوباره lineIsAlive() را بررسی کنید.

مرگ منطقی فوری است، اما بازپس‌گیری فیزیکی حافظه نه. تابع lineDestroy() مقدار alive را پاک می‌کند و یک reference را کنار می‌گذارد. اگر شخص دیگری هنوز با lineRef() یک reference در اختیار داشته باشد، تخصیص حافظه تا زمان unrefشدن آن همچنان موجود می‌ماند. دقیقاً به همین دلیل، postcondition مالک که در ادامه می‌آید هم ارزان است و هم می‌توان آن را بررسی کرد:

lineRef(l);                  /* تخصیص را تا اجرای Assert زنده نگه می‌دارد */
owner->fnFinD(owner, l);
assert(! lineIsAlive(l)); /* مرگ منطقی فوری است */
lineUnref(l); /* آزادسازی فیزیکی ممکن است همین‌جا رخ دهد */

line‌ای که از نظر منطقی مرده است هرگز نباید callback جریان دیگری دریافت کند و تا زمان بازپس‌گیری حافظه، line state همه‌ی تونل‌ها باید از قبل zero شده باشد.

thread‌ای که آخرین reference را آزاد می‌کند الزاماً event worker همان line نیست. ممکن است یک device thread یا scheduler ثبت‌نشده، در حالی reference معتبر نگه داشته باشد که owner همان line را نابود و admission پیام‌ها را بسته است. بنابراین آخرین lineUnref() وقتی روی event worker ثبت‌شده نیست از مسیر thread-safe در shared pool استفاده می‌کند؛ فقط event worker ثبت‌شده مجاز است fast path مربوط به local pool را به کار ببرد. هنگام teardown، باقی‌ماندن item‌های checked-out در line pool یک خطای سخت در ترتیب lifecycle است، پس master pool مشترک تا بازگشت همهٔ reference‌ها زنده می‌ماند.

reference count مرز publication برای reclamation نهایی نیز هست. هر decrement از نوع acquire-release، write‌های teardown پیش از خود را منتشر می‌کند و RMW‌ای که گذار 1 -> 0 را می‌بیند پیش از خواندن alive، state‌های zeroشده، routing/domain یا credentials آن release sequence را acquire می‌کند. هر کدی که مستقیماً reference count را کم می‌کند باید همین قرارداد را حفظ کند؛ lock صف پیام یا readiness flag نامرتبط جای این publication را نمی‌گیرد.

انتشار هویت احراز‌شده​

پذیرش از cache محلی​

پس از نخستین همگام‌سازی معتبر جدول و مبنای ساعت، authenticationclientCanAuthenticate() در قطع شبکه و احراز هویت دوباره از آخرین cache حافظه استفاده می‌کند؛ cache مهلت کهنگی ندارد. enabled، انقضا، سهمیه ترافیک و reservation محلی اتصال/IP همچنان اعمال می‌شوند. نبود cache، توقف، خطای نهایی continuity یا رد صریح اعتبارنامه خود کلاینت، پذیرش جدید را می‌بندد. فقط پاسخ موفق و معتبر Session رد صریح را پاک می‌کند؛ timeout، قطع اتصال، پاسخ خراب و authentication-required چنین اثری ندارند. اتصال‌های قبلاً مجاز به ثبت مصرف و اجرای محدودیت‌های محلی ادامه می‌دهند. هر درخواست HTTP روی اتصال reused پذیرش تازه است؛ exchange جاری و CONNECT ادامه می‌یابند. ویرایش مدیر پس از refresh معتبر می‌رسد. دو کلاینت قطع‌شده با مصرف شناخته‌شده ۸۰ از سهمیه ۱۰۰ گیگابایت می‌توانند هر کدام ۲۰ مصرف کنند و مجموع نهایی ۱۲۰ شود؛ سهمیه و reservation محلی، تضمین سراسری نیستند.

ساعت و اولین استفاده محلی​

پاسخ GetAllUsers باید هر دو فیلد canonical و دقیق server-time-ms و accounting-time-ms مثبت را داشته باشد؛ metadata نامعتبر refresh را بدون جایگزینی cache معتبر رد می‌کند و fallback به ساعت دیواری کلاینت وجود ندارد. سرور یک بار C0 = max(wall-ms, 1) و monotonic M0 را ثبت می‌کند و با جمع saturating زمان سپری‌شده monotonic ساعت حسابداری را پیش می‌برد. اصلاح بعدی ساعت دیواری آن را reset نمی‌کند و anchor در فایل ذخیره نمی‌شود.

اولین upload یا download واقعی غیرصفر، حتی با هزینهٔ گرد‌شدهٔ صفر، درون قفل write کاربر، نمونه تازه monotonic، origin محلی معتبر حتی در صفر و برآورد مثبت و تغییرناپذیر اولین استفاده برای گزارش را ثبت می‌کند. login، reservation، فراخوانی صفر بایت و rebase شروع مصرف نیستند. ثبت مشاهده، شمارنده و تصمیم انقضا در transaction با ترتیب user سپس stats انجام می‌شود؛ نبود خروجی notification مانع مشاهده نیست. سن سپری‌شده حتی با مدت صفر حفظ می‌شود؛ ACK، reconnect، pull تکراری یا نبود فیلد canonical آن را پاک یا جوان نمی‌کنند. مشاهده زودتر کلاینت دیگر می‌تواند سن را بیشتر کند. origin واردشده در آینده تا رسیدن زمانش آینده می‌ماند؛ مصرف محلی واقعی می‌تواند origin زودتری بسازد.

انقضای تاریخ مطلق با ساعت دیواری سرور و مدت پس از اولین استفاده با سن monotonic جدا محاسبه می‌شوند. تاریخ بدون تغییر deadline زودتر را حفظ می‌کند؛ ویرایش/پاک‌کردن هر سیاست فقط جزء خودش را دوباره محاسبه می‌کند. deadline زودتر حاکم است؛ انقضای گذشته در monotonic صفر هرگز به unlimited تبدیل نمی‌شود. state زمان/مشاهده/ACK و reservation با ID پایدار، حتی هنگام تغییر credential همان ID، منتقل می‌شوند؛ policy و timestamp مرجع جدید حفظ می‌شوند. کپی عمومی و JSON این state محلی را ندارند. تبدیل بین ماشین‌ها برآوردی با خطای تأخیر شبکه/serialization و drift است. رفتار suspend ساعت runtime عوض نمی‌شود؛ CLOCK_MONOTONIC لینوکس زمان تعلیق میزبان را حساب نمی‌کند.

ترتیب قفل lookup/admission برابر control_mutex -> users_lock -> collection/user است؛ precheck عمومی فقط snapshot است و تصمیم واقعی باید زیر قفل دوباره availability را بسنجد. lookupهای password، SHA224، SHA256، UUID و WireGuard-key همین قرارداد را دارند. مسیر accounting/release اتصال موجود این gate پذیرش تازه را ندارد. انتقال state زمان و reservation دو collection lock، سپس هر دو user lock و سپس هر دو stats lock را با ترتیب قطعی می‌گیرد. snapshot گزارش، مشاهده و شمارنده را با هم و به‌صورت valueهای owned ثبت می‌کند. flag زمان‌بندی مدرک مصرف یا marker ACK نیست و reset آن نباید مشاهده را پاک کند.

lookup مجموعه، pointer قرضی user/string برمی‌گرداند؛ اگر حذف یا جایگزینی همزمان ممکن است، طول عمر آن به مالکیت بیرونی collection نیاز دارد. usersCopy(dest, src) موفق و غیر self، کل مقصد را جایگزین و تمام pointerهای قدیمی user/string آن را نامعتبر می‌کند؛ شکست، مقصد را حفظ می‌کند و self-copy هیچ کاری ندارد. setter مستقل فیلد ایندکس‌شده مانند userSetId و userSetWireGuardAllowedIps نباید حساب تحت مالکیت collection را تغییر دهد؛ برای تغییر همزمان تمام ایندکس‌ها از transaction مجموعه استفاده کنید. runtime self-move نیز no-op است.

patch تنظیم آماده‌شده پس از انتقال موفق مقدارهای owned، alias و presence mask خود را پاک می‌کند و consumed است؛ بعد از آن فقط destruction مجاز است. Busy، timeout و دیگر refusalهای پیش از commit آن را قابل استفادهٔ دوباره نگه می‌دارند. preparation ساخت حساب قابل استفادهٔ دوباره می‌ماند، از جمله تلاش بعدی با ID تکراری پس از ساخت موفق.

import تازهٔ JSON هر credential را یک بار مشتق و ساختار/ایندکس را validate می‌کند. کپی baseline مورد اعتماد، همان مقدار مشتق‌شده را بدون محاسبهٔ دوباره حفظ می‌کند. usersValidate و userPasswordDataValid عمومی در همهٔ buildها diagnostic کامل سازگاری credential را انجام می‌دهند؛ checked-add شیء دلخواه caller نیز این بررسی را حفظ می‌کند. import افزایشی با ID اجباری، هنگام شکست فقط سطرهای افزودهٔ خودش را rollback می‌کند؛ صفحهٔ موفق کل جدول انباشته را کپی یا دوباره مشتق نمی‌کند. APIهای diagnostic، مستقل و update قدیمی مجموعه همچنان در دسترس‌اند.

مالکیت تنظیم حساب و حسابداری​

usersPatchConfigByIdentifier() از زمان lookup تا commit، کاربر را با write lock مجموعه زنده نگه می‌دارد. نوع user_config_patch_t برای هر leaf مجاز presence bit دارد و credential، شمارنده، runtime یا زمان ایجاد/اولین استفاده را حمل نمی‌کند. آماده‌سازی string/CIDR و transaction ایندکس همان مسیر موجود است؛ تمام خطاهای مورد انتظار نام تکراری، overlap و ظرفیت پیش از انتشار هر فیلد بررسی می‌شوند. فقط leafهای انتخاب‌شده زیر user->lock اعمال می‌شوند. snapshot کامل timeinfo یا limits را برای patch merge نکنید؛ ممکن است اولین استفاده همزمان یا سیاست حذف‌شده از درخواست را overwrite کند. ترتیب قفل collection سپس user حفظ می‌شود و first-use همین قفل user را می‌گیرد. reservation زنده حفظ می‌شود و refresh کلاینت آن را با ID پایدار منتقل و projection انقضا را دوباره محاسبه می‌کند.

UpdateUser فقط تنظیمات صریح حساب با ID پایدار دقیق و غیرصفر را می‌پذیرد. فیلد محافظت‌شده/ناشناخته/تکراری، container خالی، null، UTF-8/NUL نامعتبر، عمق زیاد و داده اضافی، کل درخواست را رد می‌کند. lexical parser کل payload را با عمق هشت بررسی می‌کند، نه محدودیت اندازه فایل host config. patch موفق و غیرخالی زیر write lock سرور config_revision را دقیقاً یک بار افزایش می‌دهد، حتی برای مقدار برابر؛ stats_revision و checkpoint ثابت‌اند. ویرایش‌ها serialize می‌شوند: فیلد حذف‌شده از درخواست حفظ و برای یک فیلد آخرین ویرایش پذیرفته‌شده برنده است؛ expected-revision یا reset remote وجود ندارد. تغییر صرف نام پس از رسیدن مصرف از ۱۰۰ به ۱۱۰ بایت، مجموع ۱۱۰ و زمان اولین استفاده را حفظ می‌کند.

تعویض رمز transaction آماده‌شدهٔ جداگانهٔ collection است که API دارای مجوز صریح change-user-password آن را عرضه می‌کند. edit token سراسری سرور بررسی می‌شود و رمز همراه چهار ایندکس credential یک‌جا تغییر می‌کند؛ patch تنظیمات و UpdateUser تولیدکننده همچنان credential نمی‌پذیرند. آدرس و ID پایدار کاربر، تنظیمات، ترافیک، مدرک اولین استفاده، زمان ایجاد/انقضا، stats_revision، checkpoint و reservation زنده حفظ می‌شوند و commit رمز stats lock نمی‌گیرد. بایت دقیق رمز اهمیت دارد: رمز UUID هویت UUID را اضافه/جایگزین و تبدیل به متن عادی آن را حذف می‌کند. حروف بزرگ/کوچک همان UUID برای همان حساب هویت را حفظ می‌کنند؛ کلید مشتق‌شده‌ای که عوض شده دیگر resolve نمی‌شود.

کلاینت‌ها تغییر را در refresh عادی دریافت می‌کنند؛ تا آن زمان cache متصل یا آفلاین ممکن است credential قبلی را بپذیرد. refresh با ID پایدار reservation، ساعت حسابداری، مدرک اولین استفاده/ACK و projection انقضا را همراه replay دقیق batch معلق و ترافیک هنگام pull منتقل می‌کند. اتصال موجود حسابداری و آزادسازی reservation را ادامه می‌دهد و تعویض رمز آن را به‌اجبار قطع نمی‌کند. read عمومی credential را ندارد؛ پاسخ نامعلوم تعویض رمز نیازمند read تازه و تصمیم صریح برای ارسال مجدد رمز مطلوب با token فعلی است، بدون retry خودکار.

گزینهٔ traffic-multiplier در AuthenticationClient بایت اندازه‌گیری‌شدهٔ upload/download را در هر دو API ترافیک، پیش از شمارنده و اعمال سهمیه، با محاسبهٔ صحیح fixed-point و مقیاس 1000 تبدیل می‌کند. عدد JSON هنگام راه‌اندازی ثابت است؛ پیش‌فرض 1، بازهٔ 0.001 تا 1000 و حداکثر سه رقم اعشار مؤثر دارد. caller باید بایت واقعی بدون ضرب اولیه بدهد. باقی‌ماندهٔ کسری هر کاربر و جهت را جدا نگه دارید؛ گردکردن مستقل هر payload با خردشدن ترافیک هزینه را تغییر می‌دهد. باقی‌مانده، شمارندهٔ قابل‌کسر و مدرک اولین استفاده در همان transaction با قفل user/stats به‌روز می‌شوند. refresh باقی‌مانده را با ID پایدار حتی با تغییر credential منتقل می‌کند؛ ACK و reconnect آن را پاک نمی‌کنند. کپی عمومی، JSON دیتابیس و batch این state محلی را ندارند؛ حذف/نابودی آن را دور می‌ریزد و بازیابی crash وجود ندارد. شمارندهٔ قابل‌کسر با اشباع کنترل می‌شود و هرگز unsigned wrap نمی‌کند.

شمارندهٔ موجود/دریافتی، delta batch، baseline، retry تغییرناپذیر و بدهی catch-up قبلاً واحد قابل‌کسر دارند؛ دوباره ضرب نکنید. سرور، producerهای دیگر و مسیر import مصرف قابل‌کسر را همان‌گونه که داده شده می‌پذیرند. محدودیت ترافیک و مجموع usage/accounting در API همین واحد را دارند. bandwidth، سرعت، reservation و timestamp معنای قبلی را حفظ می‌کنند. اندازه‌گیری واقعی مثبت باید انقضای اولین استفاده را شروع کند حتی اگر افزایش قابل‌کسر صفر باشد.

گزارش مصرف remote فقط PushUserStats deduplicated با ریشه epoch، sequence، users و ورودی id، up، down، first-usage-at-ms است؛ همه اجباری‌اند و فیلد دیگری پذیرفته نمی‌شود. همین قواعد strict lexical برقرارند. replay دقیق با بایت‌های اصلی پیش از بررسی وجود فعلی حساب ACK می‌شود، حتی پس از ویرایش/حذف داخلی حساب. batch جدید پیش از هر mutation همه ورودی‌ها را اعتبارسنجی می‌کند. ترافیک تجمعی و اولین استفاده وضعیت حسابداری سرور، زمان ایجاد metadata ایجاد/import و limit/enabled/expiry تنظیمات‌اند. reservation IP/connection محلی است؛ آمار ذخیره/import شده IP، device، connection و speed اشغال زنده global یا منبع admission نیستند. جدول مالکیت آمار و نیازهای telemetry آینده در مرجع AuthenticationServer آمده است. گزارش gauge زنده، reset و بازیابی مصرف پس از crash از این قرارداد نتیجه نمی‌شوند. هر ورودی حتی پس از ACK همان timestamp تولیدکننده را دارد. برای مشاهده ACKنشده، deltaهای صفر مجازند؛ ACK فقط markerهای included را تسویه می‌کند و origin/سن باقی می‌ماند. سرور همه ورودی‌های batch جدید را پیش از mutation بررسی می‌کند، ساعت حسابداری را یک بار می‌خواند، آینده را clamp و کمترین مقدار غیرصفر را merge می‌کند. یک افزایش stats_revision همراه checkpoint منتشر می‌شود و config_revision ثابت است. APIهای missing-only قبلی تغییر نمی‌کنند.

زمان‌بندی task وابسته به line​

lineScheduleTask()، ‏lineScheduleTaskWithBuf() و دو گونهٔ delayed، کار را به event worker تغییرناپذیر line منتقل می‌کنند. کار فوری و delay_ms == 0 همیشه برای iteration بعد queue می‌شود و inline اجرا نمی‌شود؛ ساخت timer و اجرای task حتی برای submitter خارجی روی owner worker باقی می‌ماند.

فراخواننده باید تا گرفتن reference داخلی scheduler یک live handle همگام‌شده داشته باشد: reference برای ماندن allocation و gate/lock رجیستری برای جلوگیری از race با lineDestroy(). reference فیزیکی به‌تنهایی alive را حفظ نمی‌کند. scheduler خود line را نگه می‌دارد اما tunnel_t را جداگانه retain نمی‌کند؛ lifecycle مؤلفه باید t را تا پایان task یا cancellation زنده نگه دارد.

نتیجهٔ line_task_submit_result_e باید صریح بررسی شود:

نتیجهچیزی که هنگام return ثابت شده است
kLineTaskSubmitRejectedSettledtask اجرا نمی‌شود؛ notification اختیاری و cleanup رکورد، reference و buffer تمام شده است.
kLineTaskSubmitAcceptedAsyncمالکیت وارد queue/setup async شده؛ armشدن timer، pendingبودن فعلی یا اجرای task را تضمین نمی‌کند.
kLineTaskSubmitTimerArmedفراخوانی delay مثبت روی owner worker، timer را پیش از return نصب کرده است؛ مرگ line یا quiescence هنوز می‌تواند آن را لغو کند.

API فوری هرگز TimerArmed نمی‌دهد؛ delay صفر AcceptedAsync است. delay مثبت روی owner پس از نصب موفق TimerArmed و از thread دیگر پس از queueشدن setup مقدار AcceptedAsync می‌دهد.

گرفتن رکورد scheduling و نصب منبع زمان‌بندی عمداً policy تخصیص متفاوتی دارند. رکورد pooled به‌صورت fail-fast گرفته می‌شود، زیرا ساخت item در master pool نمی‌تواند OOM را به API زمان‌بندی گزارش کند. در مقابل، worker message با delay مثبت timer خود را از مرز داخلی wtimerTryAdd() نصب می‌کند. فقط timer‌های این مسیر با eventloopTryZalloc() واقعاً fallible (یعنی calloc مربوط به CRT) ساخته می‌شوند و provenance آن‌ها ثبت می‌شود تا expiry، حذف صریح، حذف quiesced/deferred، نابودی loop و reclamation رویداد pending همگی از eventloopTryFree() متناظر (یعنی free مربوط به CRT) استفاده کنند. wtimerAdd() عمومی و allocation‌های نامرتبط event loop همان رفتار fail-fast قبلی با event-loop allocator/mimalloc را حفظ می‌کنند.

wtimerTryAdd() نتیجه‌های installed، admission-closed و resource-failure را جدا می‌کند. فقط شکست واقعی allocation به ResourceFailure تبدیل می‌شود؛ بسته‌شدن پذیرش در مرز نصب، AdmissionClosed است. روی owner worker، شکست منبع برای delay مثبت همگام cancellation را اجرا می‌کند و RejectedSettled می‌دهد. پس از پذیرش پیام setup خارجی، فراخوانی متعهد است AcceptedAsync برگرداند؛ اما ResourceFailure سمت owner نسبت به submitter async است و ممکن است پیش از مشاهدهٔ return، هم‌زمان با آن یا پس از آن تحویل شود. task هرگز inline اجرا نمی‌شود، چون این کار minimum delay را نقض می‌کند و می‌تواند task خودزمان‌بند را به recursion نامحدود ببرد. task‌های delayed مستقل حتی با delay برابر ترتیب FIFO تضمین‌شده ندارند؛ کار مرتب باید در یک queue صریح با یک drain task باشد.

با LineTaskCancelFn غیرNULL دقیقاً یکی از task یا cancellation اجرا می‌شود. cancellation ممکن است همگام و re-entrant پیش از return، بعداً روی owner/submitting thread یا در teardown اجرا شود. scheduler هنگام notification reference فیزیکی را دارد، اما line ممکن است logically dead و lstate آن نابود شده باشد. callback فقط notification است؛ مالک buffer یا cleanup داخلی نیست و بدون اثبات مستقل نباید state مختص owner را لمس کند. NULL فقط notification را حذف می‌کند، نه settlement را.

دلیل‌ها دقیق‌اند: TargetUnavailable، ‏AdmissionClosed، ‏EnqueueFailure، ResourceFailure، ‏Quiesced، ‏Teardown و LineDead. latch را پیش از submission تنظیم کنید تا cancellation همگام بتواند آن را settle کند. callback لغو هنگام quiescence/teardown نباید کار عادی را restart کند و branch مربوط به RejectedSettled نباید fallback‌ای را دوباره اجرا کند که cancellation انجام داده است.

پس از admission خارجی، مسیر submitter و owner مستقل از هم پیش می‌روند:

submitter lane -> setup admission commits result -> return/observe AcceptedAsync
owner lane -> accepted setup may run -> allocation fails -> cancellation(ResourceFailure)
cancellation may precede, race with, or follow submitter observation

ایمنی حافظه و منابع همیشگی است، اما پیشرفت task تضمین نیست. کار الزامی به fallback همگام برای RejectedSettled و inventory/stop مالک برای cancellation پس از پذیرش نیاز دارد. payload عمداً lossy یا statistics مشاهده‌ای می‌تواند NULL بدهد و نتیجهٔ typed را صریح discard کند.

نمونهٔ source-oriented برای کار آماری عمداً lossy و بدون notification لغو:

const line_task_submit_result_e result =
lineScheduleDelayedTask(parent, muxclientLogStatsTask, 5000, t, NULL);
discard result; /* statistics are intentionally best effort */

برای کار الزامی، latch مستقل را پیش از submission منتشر کنید و همان latch را در هر دو outcome پایانی reconcile کنید؛ cancellation به lstate احتمالاً نابودشده دسترسی ندارد:

static void retryCancelled(tunnel_t *t, line_t *line, line_task_cancel_reason_e reason)
{
retry_tstate_t *ts = tunnelGetState(t);
atomicStoreExplicit(&ts->pending[lineGetWID(line)], false, memory_order_release);
discard reason; /* owner drain is authoritative after cancellation */
}

atomicStoreExplicit(&ts->pending[wid], true, memory_order_release);
const line_task_submit_result_e result =
lineScheduleDelayedTask(line, retryTask, delay_ms, t, retryCancelled);
if (result == kLineTaskSubmitRejectedSettled)
{
/* retryCancelled already cleared pending; run only the safe inline policy. */
}

pipe_tunnel مشترک نیز همین قاعده را برای stream‌های مرتب cross-worker رعایت می‌کند. یک pair owner صریح نقش‌های borrowed/owned و reference‌ها را نگه می‌دارد؛ از دست‌رفتن هر Init، data، control یا Finish کل pair را به حالت terminal می‌برد. هر سمت روی worker مالک خود detach می‌شود، فقط companion line ساخته‌شده نابود می‌شود و drain روی worker مالک fallback بدون پیام است. pair تا detach شدن هر دو سمت و همهٔ پیام‌های صف‌شده، reference فیزیکی هر دو line را نگه می‌دارد؛ آزادکردن reference مقصد با detach سمت اول می‌تواند sender هم‌زمان را با استفادهٔ دوباره از حافظه وارد race کند.

چه کسی اجازه دارد line را نابود کند؟​

فقط تونلی که line را ساخته است اجازه دارد lineDestroy() را روی آن فراخوانی کند؛ یعنی همان تونلی که برای دقیقاً همان نمونه‌ی line_t تابع lineCreate() یا lineCreateForWorker() را صدا زده است.

مالکیت به خود line تعلق دارد، نه به نوع node. آن را از نام‌هایی مانند «client»، «server»، «listener» یا «connector» استنباط نکنید:

  • TcpListener برای هر اتصال پذیرفته‌شده lineCreate() را فراخوانی می‌کند و مالک همان line است.
  • TcpConnector مالک یک socket است، نه line. نه lineCreate() را فراخوانی می‌کند و نه lineDestroy() را؛ بلکه line‌ای را مصرف می‌کند که در بخش بالاتری از chain ساخته شده است. مالکیت یک منبع سیستم‌عامل به‌معنای مالکیت line_t نیست.
  • یک تونل ممکن است به‌طور معمول چند نقش را هم‌زمان داشته باشد: MuxClient مالک line والد/carrier خود است، اما تمام line‌های فرزند را قرض می‌گیرد؛ HttpClient مالک line‌های transport مربوط به upload و download است که از یک line اصلیِ قرضی جدا می‌کند؛ و Socks5Server، ‏TrojanServer و VlessServer مالک line‌های remote مربوط به UDP هستند، ولی line سمت client را قرض می‌گیرند. پیش از هر پاک‌سازی، حتی درون یک handler واحد، نقش هر line را جداگانه تعیین کنید.

تست tests/line_ownership_policy_test.py دسته‌بندی همه‌ی محل‌های ساخت در کد اصلی را ثبت می‌کند و اگر محل ساخت تازه‌ای دسته‌بندی نشده باشد شکست می‌خورد.

owner‌ها و adapter‌های کامل در shutdown​

مالکیت فوری Finish تنها نیمی از قرارداد line عادی است. هر سازنده‌ی خودگردان باید بتواند هنگام shutdown تمام line‌های زنده‌ی خود را فهرست کند. هر line تازه را پیش از آنکه Init یا callback دیگری به‌صورت re-entrant آن را در دسترس قرار دهد، دقیقاً در یک inventory متعلق به worker مالک ثبت کنید و در تمام مسیرهای بستن، دقیقاً یک بار از آن بردارید. slot یا ورودی idle table را پیش از فراخوانی Finish پاک کنید؛ callback ممکن است دوباره وارد کد شود و اجرای تکراری stop باید slot را خالی ببیند و کاری انجام ندهد.

مالک source که اتصال می‌پذیرد، پذیرش را متوقف می‌کند، line‌های باقی‌مانده را فهرست می‌کند، state خود را نابود می‌کند، در صورت لزوم Finish را در جهتی دور از فرستنده می‌فرستد و در حالی برمی‌گردد که تمام line‌های تحت مالکیتش از نظر منطقی مرده‌اند. adapter انتهایی که اتصال‌ها را نگه می‌دارد تعهد دیگری دارد: همه‌ی line‌هایی را که Init آن‌ها به او رسیده فهرست کند، منبع سیستم‌عامل را جدا و بسته کند، state مربوط به adapter را نابود کند و دقیقاً یک Finish در downstream بفرستد تا سازنده‌ی واقعی line آن را ببندد. adapter انتهایی پیش از رسیدن Init مسئولیتی ندارد؛ سازنده‌ی خودگردان یا داخلی باید آن فاصله‌ی زمانی را پوشش دهد.

drainکردن مالک source روی event worker مالک انجام می‌شود؛ زمانی که event loop، chain، ‏tunnel state‌ها، ‏adapter‌های انتهایی و line pool‌ها هنوز زنده‌اند. NodeManager تونل‌ها را به ترتیب ذخیره‌سازی در chain می‌بیند و این ترتیب تضمین نمی‌شود source-to-tail باشد. بنابراین hook مربوط به worker در یک تونل میانی یا انتهایی نمی‌تواند با تکیه بر ترتیب همسایه‌ها خالی‌بودن مجموعه‌ی line‌های قرضی را الزام کند و نباید صرفاً برای جبران این موضوع inventory تکراری بسازد. تونل‌های میانی فقط line‌های عادی‌ای را که خودشان در داخل می‌سازند ثبت و drain می‌کنند. onDestroy نهایی، پس از drain همه‌ی worker‌ها، می‌تواند باقی‌ماندن یک registry قرضی را تشخیص دهد. packet line‌های پایدار و terminal absorber‌های مستندی که منبع مختص line ندارند، استثناهای صریح‌اند؛ نه inventory‌های فراموش‌شده.

هر تونل دیگری باید Finish را منتقل کند و نابودی line را به مالک بسپارد. فراخوانی lineDestroy() روی line‌ای که خودتان نساخته‌اید یک خطای جدی است.

یک invariant مهم در حالت Debug نیز وجود دارد: وقتی line سرانجام آزاد می‌شود، lineUnRefInternal() با debugAssertZeroBuf بررسی می‌کند که line state همه‌ی تونل‌ها از قبل zero شده باشد. به همین دلیل، هر تونل باید state مختص line خود را پیش از انتقال Finish واقعیِ بستن line نابود کند؛ مگر آنکه عمداً در سراسر close یک 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 — ‏lineCallWithRef()؛ روش پیشنهادی​

lineCallWithRef() و lineCallWithRefWithBuf() در طول callback یک reference موقت نگه می‌دارند و به شما می‌گویند line زنده مانده است یا نه. پیاده‌سازی واقعی آن‌ها در ww/net/line.h چنین است:

static inline bool lineCallWithRef(line_t *const line, LineTaskFnNoBuf task, tunnel_t *t)
{
lineRef(line);
task(t, line);
if (! lineIsAlive(line))
{
lineUnref(line);
return false; // Line هنگام Callback مرده است
}
lineUnref(line);
return true; // Line هنوز زنده است؛ ادامه‌دادن امن است
}

هر زمان پس از یک فراخوانی re-entrant هنوز باید با line کار کنید، از این helper استفاده کنید:

if (! lineCallWithRef(line, tunnelNextUpStreamInit, t))
{
return; // Line مرده است؛ به line یا ls دست نزنید
}

// اینجا Line هنوز زنده است. اگر به State نیاز دارید، آن را دوباره بخوانید.
my_lstate_t *ls = lineGetState(line, t);

برای forwardکردن payload، نسخه‌ی دارای buffer را به کار ببرید:

if (! lineCallWithRefWithBuf(line, tunnelNextUpStreamPayload, t, buf))
{
return;
}

وقتی helper مقدار false برمی‌گرداند:

  • به line دست نزنید؛
  • به line state این تونل دست نزنید؛
  • برای «تمیزکاری» تابع LinestateDestroy() خود را صدا نزنید؛ مسیر close که line را کشته، این کار را قبلاً انجام داده است؛
  • اگر هنوز مالک buffer‌ای هستید که تحویل نداده‌اید، بدون دسترسی به state مرده‌ی line آن را recycle کنید.

re-entrancy می‌تواند از مرز یک line دقیق نیز عبور کند. callback روی line ‏A ممکن است وارد یک تونل همسایه‌ی مشترک شود و line ‏B دیگری را که ثبت شده ببندد. اگر فراخواننده B را در array‌ای از pointer‌ها یا snapshot یک registry نگه می‌دارد و پس از callback روی A آن را بررسی خواهد کرد، باید پیش از نخستین callback با lineRef() یک reference از B نگه دارد و آن reference را در همه‌ی مسیرها آزاد کند. نگه‌داشتن reference فقط برای A، یا نگه‌داشتن mutex مربوط به registry هنگام کپی pointer‌های خام و سپس آزادکردن آن، حافظه‌ی B را زنده نگه نمی‌دارد.

برای نمونه، client مربوط به SOCKS UDP می‌تواند یک remote backend بسازد که Init آن control association و line متعلق به provider را می‌بندد. caller باید پیش از فراخوانی callback remote، ‏buffer pool مربوط به client را نگه دارد و خود client را retain کند؛ retainکردن فقط remote نه lstate نابودشدهٔ client را امن می‌کند و نه lineReuseBuffer(client, ...) را. اگر client مرده باشد، byte‌های محلی باقی‌مانده را با pool ذخیره‌شده recycle کنید، reference مربوط به client را unref کنید و بدون خواندن state نابودشدهٔ هیچ‌کدام از line‌ها بازگردید.

چه زمانی به wrapper نیازی نیست؟

اگر helper مربوط به forwarding را فراخوانی و بلافاصله return می‌کنید، بدون آنکه کار دیگری با line داشته باشید، قرار دادن آن در lineCallWithRef() سودی ندارد؛ نتیجه‌ی boolean رفتار شما را عوض نمی‌کند. این حالت برای pass-through ساده‌ی Init و Finish رایج است.

روش B — ‏lineRef() / lineUnref() دستی​

وقتی روی چند فراخوانی به کنترل صریح نیاز دارید، به‌صورت دستی reference نگه دارید:

lineRef(line);

// یک یا چند Callback که ممکن است Re-entrant باشند ...

if (! lineIsAlive(line))
{
lineUnref(line);
return;
}

// هنوز زنده است؛ ادامه‌دادن امن است.
lineUnref(line);

قواعد: هر چیزی را که ref می‌کنید حتماً unref کنید؛ پس از destroyکردن tunnel state آن را نخوانید؛ و اگر helper هم line state شما را نابود و هم Finish را propagate می‌کند، caller باید بلافاصله برگردد.

معنای Finish​

Finish جهت‌دار و مخرب است و یک ویژگی آن را از همه‌ی callback‌های دیگر متمایز می‌کند:

تضمین می‌شود که Finish از نوع re-entrant نباشد

همه‌ی 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); // ابتدا State محلی را نابود کنید
tunnelNextUpStreamFinish(t, l); // سپس رویداد را منتقل کنید
}

ترتیب اهمیت دارد: state محلی را پیش از propagateکردن close واقعی نابود کنید. پس از LinestateDestroy(ls) هیچ فیلدی از ls را نخوانید؛ فرض کنید تمام آن zero شده است.

بستن هر دو جهت از میانه‌ی chain​

وقتی خود یک تونل میانی تصمیم می‌گیرد اتصال را به‌علت خطای پروتکل یا رسیدن به یک محدودیت tear down کند، مسئول بستن هر دو سمت است. برای normal line قرضی با مالک در سمت prev، ترتیب امن زیر است؛ اگر مالک سمت next باشد جهت‌ها برعکس می‌شوند تا همیشه سمت غیرسازنده پیش از سازنده Finish شود:

  1. line state خود تونل را نابود کنید.
  2. ابتدا Finish را در upstream بفرستید. سمت next این line را قرض گرفته و نباید آن را نابود کند یا callback به سمت فرستنده بازتاب دهد؛ در این چیدمان line پس از آن زنده می‌ماند.
  3. سپس Finish را در downstream بفرستید. این فراخوانی ممکن است line را از بین ببرد.
  4. بلافاصله return کنید.
my_lstate_t *ls = lineGetState(l, t);
myLinestateDestroy(ls); // ۱. State محلی نابود شد

tunnelNextUpStreamFinish(t, l); // ۲. بستن جهت جلو (Line هنوز زنده است)
tunnelPrevDownStreamFinish(t, l); // ۳. بستن جهت عقب (ممکن است Line اکنون بمیرد)
return; // ۴. دیگر به l یا ls دست نزنید

کار adapter‌ها ساده‌تر است: چون در یک انتهای chain قرار دارند، Finish را فقط یک بار و به‌سمت جهت مخالف می‌فرستند.

line‌هایی که مالکشان هستید باز هم متفاوت‌اند و تعهدی قوی‌تر و قابل‌بررسی دارند؛ موضوع بخش بعد.

postcondition مربوط به Finish مالک​

وقتی تونلی برای یک line عادی که خودش ساخته است، ‏Finish دریافت می‌کند، handler نباید در حالی برگردد که line هنوز از نظر منطقی زنده است:

/* هنگام بازگشت از handler مربوط به Finish مالک یک line عادی */
assert(! lineIsAlive(line));

این postcondition فقط درباره‌ی مرگ منطقی است. ممکن است reference‌های بازِ lineRef() تخصیص حافظه را تا خارج‌شدن frame‌های معلق یا re-entrant زنده نگه دارند؛ لازم نیست پیش از بازگشت handler، حافظه واقعاً آزاد شده باشد.

مالک باید پیش از بازگشت این کارها را انجام دهد:

  1. تمام producer‌هایی را جدا کند که هنوز می‌توانند line را هدف بگیرند؛ از جمله callback‌های I/O، ‏timer‌ها، آیتم‌های idle table، ‏lookup map‌ها، ‏queue‌ها و task‌های زمان‌بندی‌شده.
  2. هر پاک‌سازی مجاز را که با جهت فرستنده‌ی Finish سازگار است، کامل کند.
  3. state مختص line خود را دقیقاً یک بار نابود کند.
  4. Finish را فقط در صورتی که توپولوژی لازم می‌داند و فقط در جهتی دور از فرستنده منتقل کند؛ مالکیت، قاعده‌ی no-reflection را تغییر نمی‌دهد.
  5. مگر آنکه مسیری تو‌در‌تو قبلاً این کار را کرده باشد، lineDestroy(line) را فراخوانی کند.
  6. بلافاصله برگردد و به هیچ state نابودشده‌ای دست نزند.

در مالکیت مبتنی بر key یا زمان، ورودی map/timer را جدا و handle مصرف‌کننده را پیش از فراخوانی re-entrant مربوط به Finish یا نابودکردن line پاک کنید. یک ورودی index‌شده‌ی idle باید هنگام close صریح فوراً حذف شود؛ نگه‌داشتن tombstone با deadline دور باعث می‌شود حافظه با churn تاریخی رشد کند، نه با مالکیت زنده.

سازنده‌ای مانند MuxClient دقیقاً همین کار را می‌کند. قطعه‌کد زیر از muxclientTunnelDownStreamFinish() است؛ یعنی slot مربوط به fnFinD، پس next فرستنده است. فرزندهای parent در سمت prev آویزان‌اند و فقط در همین جهت می‌توان چیزی فرستاد:

/* ‏fnFinD روی Line والدی که MuxClient ساخته است؛ next ما را Finish کرد. */
while (child_ls)
{
/* ... */
muxclientLinestateDestroy(child_ls);
tunnelPrevDownStreamFinish(t, child_l); // در جهتی دور از فرستنده
/* ... */
}

muxclientLinestateDestroy(parent_ls);
lineDestroy(parent_l); // فقط مالک اجازهٔ این کار را دارد

شکل عمومی کد در ادامه آمده است. کپی‌کردن مقدار لازم روی stack باعث می‌شود کد پس از LinestateDestroy() ایمن بماند. جهت را از slot مربوط به callback‌ای بخوانید که handler در آن نصب شده است: این نمونه fnFinU است، پس prev آن را فرستاده و فقط سمت next را می‌توان باخبر کرد:

void ownerUpStreamFinish(tunnel_t *t, line_t *l)
{
owner_lstate_t *ls = lineGetState(l, t);

ownerDetachResources(t, l, ls); /* 1 */

bool propagate_away = ls->next_init_sent; /* پیش از نابودی کپی کنید */
ownerLinestateDestroy(ls); /* 3 */

if (propagate_away)
{
tunnelNextUpStreamFinish(t, l); /* 4 */
}
if (lineIsAlive(l))
{
lineDestroy(l); /* 5 */
}
return; /* 6 */
}

نسخه‌ی قرینه‌ی fnFinD همین تابع است، با این تفاوت که در مرحله‌ی ۴ از tunnelPrevDownStreamFinish() استفاده می‌کند. یک مالک endpoint، مانند TcpListener یا UdpStatelessSocket که در ابتدای chain قرار دارد، اصلاً prev ندارد؛ بنابراین fnFinD آن چیزی منتقل نمی‌کند و مرحله‌ی ۴ حذف می‌شود. در هر دو حالت، postcondition همان است.

چرا این قاعده مهم است؟ بدون آن، توالی زیر حافظه را خراب می‌کند: تونل میانی یک callback را به‌شکل re-entrant منتقل می‌کند؛ Finish تو‌در‌تو state مختص line آن تونل میانی را نابود می‌کند؛ مالک line، ‏Finish منتقل‌شده را دریافت می‌کند اما line را زنده می‌گذارد؛ سپس callback بیرونی lineIsAlive(line) == true می‌بیند و کار را روی state‌ای ادامه می‌دهد که دیگر وجود ندارد. این postcondition باعث می‌شود lineIsAlive() پس از callback، سیگنالی صادقانه باشد.

line‌های قرضی درست نقطه‌ی مقابل‌اند. فقط state مختص line خود را نابود کنید، اگر توپولوژی لازم می‌داند همان Finish جهت‌دار را دور از فرستنده منتقل کنید و هرگز lineDestroy() را فراخوانی نکنید؛ نابودی به‌دست مالک واقعی باید معتبر بماند. هیچ‌گاه Finish، ‏payload، ‏Est، ‏Pause یا Resume را به‌سمت کسی که شما را Finish کرده بازتاب ندهید.

تونل میانی‌ای که مالک line نیز هست برای close محلی خود همچنان ترتیب بستن هر دو جهت را که بالاتر گفته شد رعایت می‌کند؛ سپس postcondition مالک نیز افزوده می‌شود: پیش از بازگشت frame مربوط به close، ‏line باید مرده باشد.

تابع requestProgramShutdown() این postcondition را برآورده نمی‌کند. این تابع teardown سراسری را روی worker 0 زمان‌بندی می‌کند؛ callback فعلی worker پیش از آن برمی‌گردد و از تمام frame‌های معلق خارج می‌شود. اگر Finish مالک به یک شکست terminal برسد، باز هم باید منابع را جدا کند، state خود را نابود کند و line را مرده علامت بزند؛ سپس shutdown را درخواست کند.

پاک‌سازی deferred منبع سیستم‌عامل بهانه‌ای برای زنده نگه‌داشتن line نیست. اگر یک منبع به teardown async نیاز دارد، ابتدا آن را از line جدا کنید و کپی مستقل داده‌های موردنیاز را به شیء پاک‌سازی deferred بدهید. line زنده token پاک‌سازی نیست.

packet line‌ها مستثنا هستند. postcondition مربوط به line عادیِ تحت مالکیت هیچ‌گاه برای آن‌ها اعمال نمی‌شود و نباید در زمان اجرا نابود شوند. دریافت Finish روی packet line نیز خودبه‌خود نقض قرارداد نیست: anchor چرخه‌ی عمر packet می‌تواند آن را رد کند؛ یک transform میانی شفاف آن را در همان جهت forward می‌کند؛ و یک terminal absorber عمدی، وقتی چیزی برای اطلاع‌دادن یا آزادکردن در ادامه نیست، می‌تواند آن را جذب کند. نقش و جهت دقیق handler تعیین می‌کند کدام حالت درست است؛ بخش ۴ را ببینید.

بیشتر تونل‌ها به 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‌های پایانی بفرستد؛ از جمله یک batch محدود و با‌تأخیر مربوط به fallback که در بخش‌های بعد توضیح داده می‌شود. این ارسال دوباره وارد adapter یا branch می‌شود و می‌تواند Pause/Resume تولید کند. flag به handler‌ها اجازه می‌دهد هر رویدادی را که به‌سمت Finish‌شده reflection می‌کند کنار بگذارند. وظیفه‌ی این flag فقط ثبت همین خطر است، نه چیز دیگر.

تونل‌های واقعی دقیقاً همین مقدار state و نه بیشتر را نگه می‌دارند. نام‌ها متفاوت، اما نقش یکسان است:

تونلstate مربوط به close جهت‌داردلیل نیاز
TlsServerupstream_finished، downstream_finishing، fallback_close_drainingپیش از close، ‏TLS alert/close را می‌فرستد و fallback با‌تأخیر را تسویه می‌کند.
TrojanServer، VlessServerfallback_close_drainingیک batch با‌تأخیر مربوط به fallback را بدون reflection به‌سمت قبلیِ Finish‌شده تسویه می‌کنند.
TcpOverUdpClientcan_downstreamباید پیش از close، ‏teardown مربوط به reliable stream را به پایان برساند.
TcpOverUdpServercan_upstreamهمان رفتار در جهت مخالف.
HttpClient، HttpServer، Socks5Serverprev_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 ایجاد می‌کند.

خطر به‌ترتیب رخداد:

۱. تونل از سمت A یک Finish دریافت می‌کند.
۲. Byteهای پایانی پروتکل را به‌سمت B می‌فرستد.
۳. Adapter سمت B هنگام نوشتن Block می‌شود و Pause (یا بعداً Resume) می‌فرستد.
۴. آن Pause/Resume در مسیر برگشت از تونل عبور می‌کند...
۵. ...و اگر تونل آن را به‌سمت A بازتاب دهد ــ سمتی که Finish اولیه State آن را
نابود کرده است ــ State آزاد یا Zero‌شده فراخوانی می‌شود و برنامه Crash می‌کند.

ساختار امن الزامی:

  1. lineRef(line) را فراخوانی کنید، چون قرار است byte‌ها را به‌شکل re-entrant بفرستید.
  2. ابتدا سمت فرستنده را Finish‌شده علامت بزنید. از state جهت‌دار موجود تونل، مانند prev_finished، ‏next_finished، ‏can_downstream = false یا can_upstream = false استفاده کنید. handler‌های Pause/Resume باید این state را بررسی کنند و اجازه ندهند چیزی به سمت Finish‌شده reflection شود.
  3. byte‌های پایانی پروتکل را به‌سمت دیگر بفرستید.
  4. line state محلی این تونل را نابود کنید.
  5. Finish واقعی WaterWall را propagate کنید.
  6. lineUnref(line) را فراخوانی کنید.
  7. بلافاصله برگردید و پس از آن به line state محلی دست نزنید.

نمونه‌های جهت:

  • هنگام handleکردن Finish در upstream و فرستادن byte‌های پایانی به next، هیچ Pause/Resume در downstream که بر اثر آن ارسال تولید می‌شود نباید به prev forward شود.
  • هنگام handleکردن Finish در downstream و فرستادن byte‌های پایانی به prev، هیچ Pause/Resume در upstream که بر اثر آن ارسال تولید می‌شود نباید به next forward شود.

برای این کار flag تازه‌ای به نام initialized نسازید. از state موجود تونل برای Finish/close جهت‌دار استفاده کنید؛ یا اگر واقعاً چنین state‌ای ندارد و این مورد همان سناریوی byte‌های پایانی است، flag صریحی برای close جهت‌دار بیفزایید. همان‌طور که تأکید شد، این تنها جای درست چنین flag‌ای است.

batch‌های با‌تأخیر fallback پیش از close​

یک task زمان‌بندی‌شده برای line فقط یک reference فیزیکی نگه می‌دارد؛ line را از نظر منطقی زنده نگه نمی‌دارد و runner آن پس از نابودکردن line به‌دست مالک، عمداً callback را اجرا نمی‌کند. بنابراین یک تونل میانیِ قرض‌گیرنده هرگز نباید فقط به‌دلیل delayداشتن queue مربوط به payload‌های fallback، ‏Finish در upstream را پشت timer همان line متوقف کند.

برای یک branch مقداردهی‌شده‌ی fallback، ترتیب close زیر را به کار ببرید:

  1. پیش از هر callback نهایی Payload روی branch، سمت قبلی را Finish‌شده علامت بزنید و gate مربوط به drainکردن fallback را منتشر کنید. اگر queue خالی است، می‌توان state محلی را نابود و branch مقداردهی‌شده را بلافاصله و همگام Finish کرد.
  2. برای FIFO محدود و غیرخالی، ‏branch و line pool را ثبت کنید، queue را از line state جدا کنید، مجموع اندازه را range-check کنید و آن را با حفظ ترتیب FIFO در یک buffer ادغام کنید. یک callback واحد مانع می‌شود پس از Pause همگام، همان batch یک payload تازه‌ی دیگر آغاز کند.
  3. اگر branch از قبل Pause شده است، حتی همان یک payload را هم نمی‌تواند دریافت کند. batch‌ای را که هنوز محلی است recycle و branch را Finish کنید، یا آن را به مالکی واقعاً جدا منتقل کنید؛ برای حفظ byte‌های هنگام close، ‏backpressure را دور نزنید.
  4. هنگام اجرای callback نهایی، ‏Init، ‏Est، ‏Pause و Resume را جذب و هر Payload بازگشتی را recycle کنید. یک Finish در downstream که به‌شکل re-entrant می‌رسد ثبت می‌کند که branch از قبل بسته شده و جذب می‌شود تا Finish بازتابی یا تکراری برای branch فرستاده نشود.
  5. پس از payload نهایی و در حالی که reference موقت را نگه داشته‌اید، دوباره lineIsAlive() را بررسی کنید. اگر line مرده است، unref کنید و بدون دست‌زدن به line state برگردید. در غیر این صورت، line state محلی را پیش از Finish نهایی branch نابود کنید؛ پس از آن Finish فقط unref کنید و برگردید.

timer قدیمی handle لغو ندارد. خالی یا جداکردن queue کار را از نظر منطقی لغو می‌کند؛ پس از آنکه مالک واقعی line را نابود کرد، runner زمان‌بندی‌شده به آزادسازی بی‌خطر یک reference تبدیل می‌شود. حفظ دقیق delay باقی‌مانده پس از Finish نیازمند session object جداگانه‌ای است که مالک buffer‌های کپی‌شده، state مربوط به timer و منابع branch باشد و هیچ reference‌ای به line_t یا line state نداشته باشد. این یک طراحی متفاوت است، نه دلیلی برای زنده نگه‌داشتن line قرضی.

وقتی delay صفر است، مسیر inline ایمن با reference معتبر فقط زمانی در دسترس است که fallback Pause نباشد و هیچ batch قدیمی‌تر FIFO یا drain زمان‌بندی‌شده‌ای وجود نداشته باشد. byte‌های نگه‌داشته‌شده از مسیر drain زمان‌بندی‌شده Resume می‌شوند تا payload بعدی از آن‌ها سبقت نگیرد.

adapter‌ها خودشان flush می‌کنند​

adapter‌هایی مانند TcpConnector و TcpListener ممکن است پس از دریافت Finish، پیش از بستن واقعی socket، ‏byte‌های موجود در queue را flush کنند. بنابراین تونل میانی باید byte‌های پایانی پروتکل را بفرستد، state محلی را نابود کند، Finish را forward کند و برای flush به adapter اعتماد کند. state پروتکل را صرفاً برای «منتظر ماندن تا socket تخلیه شود» زنده نگه ندارید.

‏Est، ‏Pause و Resume​

  • Est نشان می‌دهد سمت downstream واقعاً برقرار شده است؛ آن را زودتر از موعد نفرستید. تونلی که باید line state را بداند می‌تواند lineIsEstablished() و lineMarkEstablished() را نیز بررسی کند؛ به ww/net/line.h نگاه کنید.
  • Pause / Resume سیگنال‌های backpressure هستند. فقط زمانی آن‌ها را تولید کنید که از نظر معنایی و جهت درست باشد و هرگز به‌سمت جهت Finish‌شده reflection نکنید. این سیگنال‌ها را بی‌دلیل نفرستید؛ Pause/Resume اضافی ممکن است stream را در deadlock قرار دهد.

backpressure دو مرز متفاوت دارد​

Pause مجوز ارسال payload‌های آینده را به‌صورت قطعی تغییر می‌دهد، اما ثابت نمی‌کند که همه queue‌های مسیر خالی هستند. این دو لحظه را از هم جدا نگه دارید:

  • پس از دریافت Pause: تولیدکننده تا زمان دریافت Resume نباید Payload دیگری به‌سمت بخش Pause‌شده آغاز کند. Pause صرفاً یک hint نیست و اجازه دورزدن همسایه را نمی‌دهد. queue‌ای که باید از آن سمت تخلیه شود منتظر می‌ماند؛ مسیر پایانی نیز byte‌های از قبل پذیرفته‌شده را طبق سیاست close خود نگه می‌دارد یا مسیر مستند overflow/abort را اجرا می‌کند.
  • پیش از تولید Pause: مصرف‌کننده، به‌ویژه adapter‌ای که روی منبع OS می‌نویسد، می‌تواند عمداً مقدار محدودی داده بپذیرد و سپس backpressure را اعلام کند. TcpListener، ‏TcpConnector و UdpConnector با queue و threshold محلی، توقف‌های کوتاه و تأخیر callback/event loop را جذب می‌کنند. این byte‌ها پیش از دیده‌شدن Pause توسط تولیدکننده پذیرفته شده‌اند و قرارداد را نقض نمی‌کنند.

به همین ترتیب، Resume یعنی مصرف‌کننده طبق سیاست محلی خود دوباره توان پذیرش payload دارد؛ نه اینکه همه queue‌های adapter، ‏event loop، ‏OS یا تونل میانی حتماً خالی شده‌اند. payload‌ای که قبلاً به callback تحویل شده همچنان در مالکیت گیرنده است و می‌تواند طبق سیاست همان نود در queue قرار گیرد.

tolerance مربوط به adapter را به buffering دلخواه در تونل‌های میانی شفاف تعمیم ندهید. تونلی که queue صریح ندارد معمولاً Pause و Resume را بی‌درنگ forward می‌کند. نودی که عمداً فشار را جذب می‌کند باید ترتیب FIFO و مالکیت buffer را حفظ کند، سقف صریح byte/item و سیاست overflow یا close داشته باشد، فقط وقتی دوباره توان پذیرش دارد تولیدکننده را Resume کند، و در صورت re-entry و نابودی line هنگام تولید Pause نیز ایمن بماند.

threshold‌های عددی و hysteresis جزئیات پیاده‌سازی‌اند. فایل‌های payload.c، helpers.c و structure.h مربوط به adapter هدف را بخوانید؛ tolerance فعلی یک adapter را به نودی دیگر یا به‌عنوان عددی سراسری در این راهنما کپی نکنید.

مثال عملی: دنبال‌کردن یک payload​

chain زیر را در نظر بگیرید:

TcpListener -> ObfuscatorClient -> TlsClient -> TcpConnector
(ابتدا) (میانی) (میانی) (انتها)

upstream، از client به مقصد راه دور:

Byteهای Client به Socket مربوط به TcpListener می‌رسند
TcpListener: tunnelNextUpStreamPayload(t, line, buf)
-> ObfuscatorClient.fnPayloadU: درهم‌ریزی Byteها و Forward در upstream
-> TlsClient.fnPayloadU: رمزنگاری با BoringSSL و Forward در upstream
-> TcpConnector.fnPayloadU: نوشتن متن رمز‌شده روی Socket راه دور

downstream، از مقصد راه دور به client:

Byteهای راه دور به Socket مربوط به TcpConnector می‌رسند
TcpConnector: tunnelPrevDownStreamPayload(t, line, buf)
-> TlsClient.fnPayloadD: رمزگشایی و Forward در downstream
-> ObfuscatorClient.fnPayloadD: رفع درهم‌ریزی Byteها و Forward در downstream
-> TcpListener.fnPayloadD: نوشتن متن آشکار برای 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 می‌کند، یا با lineCallWithRef() / lineRef() دستی و lineIsAlive() از line محافظت می‌کند؟
  • اگر lineCallWithRef() مقدار false برگرداند، آیا از دست‌زدن به line، ‏ls و LinestateDestroy() خودداری می‌کنید؟
  • آیا Finish، ‏state محلی را پیش از propagateکردن close واقعی نابود می‌کند؟
  • هنگام close از میانه، آیا ابتدا سمت غیرسازنده و سپس سمت سازنده را Finish می‌کنید و بعد بلافاصله برمی‌گردید؟
  • اگر هنگام Finish ‏byte‌های پایانی پروتکل را می‌فرستید، آیا پیش از ارسال سمت فرستنده را Finish‌شده علامت می‌زنید و reflection مربوط به Pause/Resume را مسدود می‌کنید؟
  • آیا تولیدکننده پس از دریافت Pause، ارسال payload تازه را تا Resume متوقف می‌کند؟ آیا tolerance عمدی پیش از Pause محدود، FIFO، حساب‌شده و دارای مسیر روشن Resume یا overflow/close است؟
  • آیا فقط مالک line تابع lineDestroy() را فراخوانی می‌کند؟
  • آیا پیش از ویرایش مسیر Finish، هر line را در یکی از سه دسته‌ی line عادیِ تحت مالکیت، line عادیِ قرضی یا packet line قرار داده‌اید؟
  • آیا تمام مسیرهای Finish مالک برای یک line عادی، از جمله شاخه‌های خطا، timeout و نتیجه‌ی terminal، با ! lineIsAlive(line) برمی‌گردند؟
  • آیا callback‌های I/O، ‏timer‌ها، آیتم‌های idle و ورودی‌های map پیش از نابودی state مالک و خود line جدا می‌شوند؟
  • آیا مسیر مربوط به line قرضی به‌طور کامل از lineDestroy() پرهیز می‌کند؟
  • آیا requestProgramShutdown() فقط در کنار بستن line تحت مالکیت استفاده می‌شود و هرگز جای آن را نمی‌گیرد؟
  • آیا از افزودن flag‌ای به نام initialized که کد منبع نیازی به آن ندارد، خودداری کرده‌اید؟

ادامه: بخش ۳: buffer‌ها، padding و shift buffer‌ها.

کنترل جریان UserController​

UserController دارای bandwidth محدود، مانند SpeedLimit استثنای صریح producer-control روی normal line قرضی است. برای stream، Pause مصرف‌کننده و hold صف محلی مستقل‌اند؛ Resume هیچ‌کدام را به‌اشتباه آزاد نمی‌کند. backlog محلی datagram نه hold تولیدکننده ایجاد می‌کند و نه به‌تنهایی Pause/Resume می‌فرستد، زیرا تولیدکنندهٔ UDP ممکن است پیش از پر شدن صف محدود controller ورودی‌ها را حذف کند. overflow صف datagram فقط ورودی تازهٔ ردشده را حذف می‌کند؛ Pause/Resume صریح مصرف‌کننده همچنان اعمال و منتقل می‌شود. صف upstream تولیدکنندهٔ سمت prev و صف downstream تولیدکنندهٔ سمت next را کنترل می‌کند؛ upload/download جداگانه از مبدأ Init ذخیره‌شده تعیین می‌شوند. Pause آن جهت را از drain و نوبت عادلانه خارج می‌کند. Resume پیش از callback بیرونی پیشرفت را برقرار می‌کند، چون callback می‌تواند Payload یا Finish را بازورود کند. middle node عادی همچنان همهٔ اعلان‌ها را شفاف عبور می‌دهد و output آماده را منتظر Resume نمی‌گذارد.

bucket و FIFO نوبت‌های هر user و جهت بین همهٔ controllerها و workerهای همان AuthenticationClient مشترک‌اند. ticket به runtime یکتا تعلق دارد و دوباره استفاده نمی‌شود؛ هیچ قفل user، collection یا budget هنگام callback همسایه نگه داشته نمی‌شود. نوبت محدود موفق به انتها می‌رود و ticket خالی، Pause، لغوشده یا حذف‌شده مانع دیگران نیست. نرخ بر حسب بایت منطقی واقعی در ثانیه است، صفر نامحدود و کل unsigned 64-bit معتبر است. burst عادی تقریباً ۱۰۰ ms است؛ datagram بزرگ اعتبار کامل را پیش از ارسال جمع می‌کند و آزاد کردن نوبت اعتبار بزرگ را منتقل نمی‌کند.

preview و commit تحویل، نمونهٔ زمان pacing و انقضا را جدا می‌گیرند. bucket، grant و retry همیشه از میلی‌ثانیهٔ monotonic تازه استفاده می‌کنند. projection انقضای نصب‌شدهٔ کلاینت، حتی بدون client clock، تفسیر monotonic محلی خود را حفظ می‌کند. کاربر مستقل بدون آن projection، انقضای مطلق و مدت پس از اولین استفاده را زیر همان قفل‌ها با زمان دیواری تازه می‌سنجد؛ اصلاح ساعت دیواری اعتبار bucket نمی‌سازد. preview هزینه ندارد؛ commit شرط مشترک enabled، انقضا و سهمیهٔ ترافیک را دوباره می‌سنجد و فقط بایت تحویلی را با همان primitive باقی‌ماندهٔ کسری هر جهت در accounting ضرب‌شده ثبت می‌کند. gauge زندهٔ reservation از predicate مربوط به gauge ذخیره‌شده جدا می‌ماند.

ورود مثبت ابتدا first-use را حتی offline ثبت می‌کند. delivery ابتدا preview بدون مصرف، سپس آماده‌سازی دقیق و reservation منابع و در پایان commit اتمی bandwidth و quota ضرب‌شده است. پس از commit هیچ کار fallible تا handoff نیست. stream می‌تواند split شود؛ datagram کامل می‌رود. قطعهٔ نهایی تمام‌کنندهٔ سهمیه یک بار تحویل و سپس line بسته می‌شود. صف و حذف هزینه و باقی‌ماندهٔ multiplier ندارند؛ API عمومی accounting قرارداد خود را حفظ می‌کند.

سازنده پیش از prepare یک resolver خالص metadata روی worker مالک bind می‌کند؛ instance دستی payload-mode برابر stream، datagram یا dest_context->protocol می‌دهد. منبع متعارض یا غایب در startup رد می‌شود. گزینهٔ destination پروتکل را یک بار در Init می‌خواند: فقط TCP یعنی stream و فقط UDP یعنی datagram؛ پروتکل غایب، مبهم یا پشتیبانی‌نشده line را حتی در حالت unmanaged رد می‌کند. این گزینه صریح است و fallback خودکار نیست. تنها وقتی از آن استفاده کنید که metadata مقصد پیش از Init آماده باشد و framing در همین محل chain را توصیف کند؛ تبدیل transport می‌تواند metadata مسیریابی نامتناسب با payload فعلی باقی بگذارد. Init هر جهت mode را پیش از admission/forward ذخیره می‌کند؛ promotion دیرهنگام از همان مقدار استفاده می‌کند، مستقل از تغییر destination. SOCKS/Trojan/VLESS نقش آمادهٔ TCP/UDP remote، HTTP مقدار stream و transport عادی WireGuard در هر دو جهت datagram می‌دهند. این binding احراز هویت database برای WireGuard اضافه نمی‌کند.

در close داخلی UserController، مبدأ Init پیش از پاک‌کردن state ذخیره می‌شود و سمت غیرسازنده پیش از سازنده Finish می‌شود؛ برای line آغازشده از next، ابتدا prev و سپس next بسته می‌شوند. controller همچنان line را قرض گرفته و مالک آن نیست. Finish دریافتی فقط دور از فرستنده انتشار می‌یابد.

قراردادهای سرور احراز هویت​

هر envelope تولیدکننده فقط یک بار به حداکثر ۶۴ frame view اعتبارسنجی می‌شود؛ descriptor عملیات منبع واحد نام، مجوز، mutation و lock است. mutationها پیش از readها با session زمان ورود اجرا می‌شوند. page/point read از read lock مشترک استفاده می‌کنند. GetUsersPage با درخواست ۹ و پاسخ ۳، checkpoint ثابت producer و cursor شامل instance/config/position دارد؛ page معمولی ۱۲۸ user و هدف ۲۵۶ KiB است و user نخست استثنایی تا حد سخت با کسر metadata جا می‌شود. validation نهایی cursor پیش از نصب کلاینت لازم است؛ تغییر config traversal را باطل می‌کند ولی تغییر صرف stats نه. cursor مجوز نیست و snapshot کامل سرور نگه داشته نمی‌شود. base64 lookup canonical و طول decoded دقیق است.

انتشار اتمی جدول صفحه‌ای کلاینت​

درخواست مشترک ۹ GetUsersPage و پاسخ ۳ Users یک traversal با جدول خصوصی incremental و range ساعت هر صفحه دارند. batch حل‌نشده پیش از شروع replay می‌شود و pull_required در تمام صفحه‌ها، فاصله‌ها و validation نهایی باقی است؛ حذف correlation یک درخواست مانع traversal را باز نمی‌کند. first-use/push latch می‌مانند و مصرف محلی ادامه دارد. هر درخواست epoch و آخرین sequence/digest ACKشده را bind می‌کند؛ این مقادیر تنها در ACK مطابق یا enrollment عوض می‌شوند. metadata رشتهٔ ده‌دهی دقیق، instance/config، total، موقعیت پیوسته، count، cursor canonical پیش‌رونده، completion و checkpoint پیش از ورود رکورد بررسی می‌شوند. span دقیق object هر user پیش از ساخت charge می‌شود. sync-max-users و sync-max-bytes مثبت و قابل‌نمایش با پیش‌فرض 100000 و268435456 سقف یک traversal هستند و کل total از پیش reserve نمی‌شود. شکست فقط staging را آزاد و cache/debt/clock/generation فعال را حفظ می‌کند.

ویرایش config، حتی هم‌مقدار، cursor را باطل می‌کند؛ تغییر صرف آمار نمی‌کند. سرور برای هر صفحه state lock را نگه می‌دارد و پیش از callback آزاد می‌کند؛ snapshot کامل منجمد ندارد. validation جداگانهٔ cursor نهایی پیش از انتشار اجباری است. stats revision نصب‌شده نمونهٔ محافظه‌کارانهٔ صفحهٔ نخست است و آخرین remote revision جدا می‌ماند؛ تفاوت صرف آمار cadence عادی دارد. پیش از move runtime همهٔ اعتبارسنجی ساختاری/ID/index، baseline، پوشش ساعت‌ها و کامل‌بودن بدهی زنده انجام می‌شود. زیر table lock delta/observation فعلی merge، سپس reservation/queue/ticket/carry منتقل و active/baseline با یک افزایش generation منتشر می‌شوند. commit پس از شروع migration recoverable نیست. ساعت هر user پس از migration از wall/accounting/receipt همان صفحه دوباره نصب می‌شود؛ صفحهٔ نهایی جای همه نیست. بدهی نسبیِ پنهان‌شده در saturation یا مربوط به حساب غایب هرگز برای اتمام traversal حذف نمی‌شود.

تبدیل‌های خام QUIC​

QuicClient و QuicServer همان line عادی موجود را قرض می‌گیرند و socket یا line همراه نمی‌سازند. plaintext upstream کلاینت به حامل سمت next می‌رود؛ حامل upstream سرور به plaintext سمت next تبدیل می‌شود. نقش معکوس transform جهت callback را عوض نمی‌کند. هر line یک اتصال QUIC v1 و یک stream دوطرفه با آغازگر کلاینت دارد. کلاینت بایت رمز‌شدهٔ 0x00 را برای بازکردن stream می‌فرستد؛ سرور آن را پیش از دادهٔ برنامه، شامل خروجی server-first، اعتبارسنجی و مصرف می‌کند.

state در Init پیش از تنها next Init منتشر می‌شود. آغاز handshake کلاینت منتظر Est نمی‌ماند. هر downstream Est پشتیبانی‌شده، حتی تکراری، relay می‌شود؛ آمادگی handshake یا stream، Est اضافه نمی‌سازد. تا آمادگی، برنامه FIFO با سقف قطعی دارد. ورودی و خروجی library نسبت به reentry سری می‌شوند و callback همسایه فقط پس از بازگشت driver اجرا می‌شود. پس از callback هم عمر line و هم اتصال state محلی را بررسی کنید؛ Finish می‌تواند transform را روی line هنوز زنده بازنشسته کند.

Pause/Resumeهای تکراری relay می‌شوند؛ alarm بازیابی، ACK و خروجی مرتب آماده در Pause مشورتی ادامه دارند. latch مستقل فشار ارسال فقط از منبع برنامه Pause می‌خواهد وقتی هر یک از شمارنده‌های بایت، entry یا هزینهٔ allocation از سه‌چهارم سقف خود بیشتر شود. Resume تنها با کمترشدن هر سه از نصف سقف رخ می‌دهد؛ برابری حالت را حفظ می‌کند. سقف allocation دو برابر بودجهٔ بایت است. دادهٔ تأییدنشده charge دارد و ACK بدون Payload جدید نیز باید بتواند hold را آزاد کند. Pause خارجی adapter UDP ممکن است دریافت ACK را متوقف کند؛ Pause محدود در مهلت مجاز بازیابی می‌شود و Pause طولانی می‌تواند ارتباط را ببندد.

Finish ابتدا جهت فرستنده را می‌بندد و هیچ callbackی به همان سمت نمی‌فرستد. alarmها لغو و state پیش از relay دور از فرستنده جدا می‌شوند. خطای محلی با پاک‌سازی محلی و Finish به next سپس prev پایان می‌یابد. این نودها lineDestroy() نمی‌زنند و پس از Finish وعدهٔ drain قابل‌اعتماد ناهمگام ندارند؛ مالک می‌تواند بلافاصله line را نابود کند. دادهٔ نهایی تأییدنشده ممکن است از دست برود و timeout محدود همتا آن را حل کند. FIN/reset stream پس از بایت‌های decodeشدهٔ قبلی کل ارتباط را می‌بندد؛ half-close برنامه وجود ندارد.

Finish سمت برنامه می‌تواند فقط به حامل هنوز باز drain همگام محدود خروجی آماده انجام دهد؛ کنترل ازدحام و pacing حفظ می‌شود و هیچ signal بازگشتی به سمت Finishشده نمی‌رود. این کار تضمین drain ناهمگام پس از Finish نیست.

آغاز کار و مالکیت VmessClient​

VmessClient، lineهای عادی مستقیم TCP و برنامه UDP را قرض می‌گیرد. برای هر برنامه UDP یک carrier TCP وابسته روی همان worker می‌سازد و head و مالک همان carrier است. هر دو state و لینک association باید پیش از next Init منتشر شوند. مقصد نهایی را پیش از جایگزینی context توسط transport با endpoint سرور، snapshot کنید. DomainResolver اختیاری، helper متعلق به node و پیش از کلاینت است و مقصد نهایی را پیش از DNS اعمال می‌کند. UUID خروجی هویت احرازشده همتای محلی نیست و نباید روی carrier ثبت مصرف مدیریت‌شده مضاعف ایجاد کند.

Init درخواست نمی‌فرستد. اولین payload مجاز، حتی پیش از Est، در reentry آن یا پس از Pause، فوراً و بدون انتظار پاسخ ارسال می‌شود. مالکیت درخواست، نشانگر انتظاری پاسخ و هر دو codec را پیش از اولین Payload خروجی منتشر کنید. TCP هر Est را فوراً منتقل می‌کند؛ UDP اولین Est carrier را یک‌بار نگاشت می‌کند. اولین Est یک deadline بیکار ایجاد می‌کند و تکرار آن مهلت را تمدید نمی‌کند. خروجی سررسیدشده مستقیم TCP از Pause مشورتی عبور می‌کند؛ carrier UDP فقط header بیکار را هنگام Pause همان carrier عقب می‌اندازد. TCP خالی و UDP بومی خالی/بزرگ درخواست را شروع نمی‌کنند؛ packetaddr، datagram خالی برنامه را در پوشش آدرس غیرخالی حفظ می‌کند. backend ساکت مجاز است و timer جداگانه انتظار پاسخ وجود ندارد.

guardهای مستقل TX/RX، FIFO پذیرفته‌شده را در payload تودرتو و پاسخ هم‌زمان حفظ می‌کنند. رکورد کامل به‌تدریج، حتی هنگام Pause مشورتی، تحویل داده می‌شود؛ close تحویل را متوقف می‌کند. کلاینت در تمام اتصال، حتی NONE و zero، فعال است و bypass دائمی TCP موفق ندارد. UDP مرز datagram را حفظ می‌کند؛ رکورد خالی رمز‌شده فقط پس از تأیید tag پایان پروتکل است.

Finish پیش از callback بیرونی سمت فرستنده را بسته اعلام می‌کند، associationها را جدا، timer را لغو، bufferها را تسویه و کلیدها را پاک می‌کند. termination فقط پس از ارسال درخواست و به سمت باز wire با send-termination=true (پیش‌فرض) مجاز است. false همان full-close را بدون رکورد پایانی انجام می‌دهد، حتی در strict exhaustion. EOF ورودی معتبر مستقل از این گزینه پذیرفته می‌شود؛ Finish درخواست تازه ایجاد نمی‌کند. Finish یا فشار را به فرستنده بسته بازتاب ندهید. carrier متعلق به node باید پیش از بازگشت Finish از نظر منطقی مرده باشد، حتی اگر Finish هنگام تحویل رکورد پایانی reenter شود. reference موقت فقط تخصیص را حفظ می‌کند؛ پس از callback، زندگی منطقی و عضویت association را بررسی کنید. line قرضی برنامه در close و shutdown متعلق به source owner می‌ماند.

احراز هویت و مالکیت packetaddr در VmessServer​

سرور carrier را قرض می‌گیرد و مالک backendهای عادی وابستهٔ UDP است. UDP بومی یک backend ثابت دارد. packetaddr احرازشده جدول associative محدود از IP/family/portهای تغییرناپذیر دارد؛ packetaddr-max-destinations پیش‌فرض ۶۴ و بازهٔ 1..1024 است. هر entry زنده یک backend روی همان worker دارد؛ source/listener context و هویت احرازشده پیش از protected Init کپی می‌شوند. در managed mode، UserController هر payload برنامه را یک‌بار می‌بیند و پوشش/carrier مصرف جداگانهٔ برنامه نیست. در ظرفیت کامل فقط datagram نیازمند مقصد جدید حذف می‌شود. Finish هر backend همان entry را حذف می‌کند و در صورت باز بودن admission، ورودی بعدی می‌تواند آن را بازسازد. پاسخ از مقصد ثابت همان backend استفاده می‌کند و بایت‌های صف مستقل از عمر آن هستند.

حذف entry و inventory و جداسازی association باید پیش از callback منتشر شود. پس از reentry، زندگی منطقی و عضویت دقیق دوباره بررسی شود؛ reference فقط تخصیص را حفظ می‌کند. بستن carrier پذیرش backend را می‌بندد و همهٔ entryهای متعلق به آن را تخلیه می‌کند؛ worker stop نیز inventory مالکیت جداگانه را پیش از pool/chain تخلیه می‌کند. relay فشار نقش head/tail همان line و FIFO محدود پاسخ را حفظ می‌کند؛ packetaddr به transform معمولی producer gate تازه نمی‌دهد. این lineها normal هستند و فقط سازندهٔ هر line آن را نابود می‌کند.

سرور پس از احراز/parse درخواست و پیش از protected Init یا backend، سیاست allowed-security، UDP و packetaddr را اعمال می‌کند. درخواست احرازشدهٔ ردشده fallback ندارد. NONE با chunk برابر none و NONE خام برابر zero است؛ wire mode یا احراز header تازه‌ای اضافه نمی‌شود. Mux.Cool/XUDP پشتیبانی نمی‌شوند و Mux اختصاصی WaterWall جایگزین سازگار خارجی نیست.