- EdgeDB जब network I/O को Python से Rust में शिफ्ट कर रहा था, तब
reqwestपर आधारित नए HTTP fetch टेस्ट केवल ARM64 CI पर अटके हुए लग रहे थे, लेकिन असल में वे crash हो रहे थे - core dump analysis से पता चला कि समस्या नई HTTP code में नहीं, बल्कि
libcकेgetenv()के अंदर थी; वह environment variable array में चलते हुए गलत pointer0x220पढ़ने की कोशिश में fail हुआ - एक दूसरे thread ने
openssl-probepath मेंSSL_CERT_FILEऔरSSL_CERT_DIRset करते समयsetenv()के जरिएenvironको reallocate कर दिया, और उसी समय Python error-handling path नेgetenv()call किया, जिससे race condition बनी - Rust code में explicit
unsafeनहीं था, लेकिनstd::env::set_var()जब global environment बदल रहा था, तब दूसरे runtime या directlibccalls तक synchronization Rust के internal lock से नहीं हो रहा था - समाधान Linux पर
reqwestकेrust-native-tls/opensslbackend की जगहrustlsपर जाना था, और Rust 2024 edition तथा glibc भी इस तरह की समस्याओं को कम करने की दिशा में बदल रहे हैं
केवल ARM64 CI पर सामने आया crash
- EdgeDB network I/O code के बड़े हिस्से को Python से Rust में port करते हुए नया HTTP fetch feature बना रहा था
- HTTP client library के रूप में
reqwestइस्तेमाल किया गया, और feature local व x86_64 CI runners पर pass हुआ, लेकिन ARM64 CI runner पर कभी-कभी fail होने लगा - शुरुआत में लगा कि test runner अनिश्चित समय तक hang हो गया है, और CI logs में बिना error के एक test लगातार running state में रह गया और कुछ घंटों बाद timeout हुआ
- शुरुआती hypothesis Intel और ARM64 के memory model differences को लेकर था
- Intel का memory model अपेक्षाकृत strict होता है, और memory writes के लिए सभी processors के बीच एक total order पर सहमति होती है
- ARM का memory model weaker ordering वाला है, और writes अलग-अलग threads को अलग order में दिख सकती हैं
Docker CI environment में core dump track करना
- nightly CI machine Amazon AWS पर चलती थी, इसलिए container के बाहर असली root user के रूप में connect करके
dmesgऔर system logs देखे जा सकते थे - container के अंदर और बाहर उस PID को खोजा गया जो hang हुआ लगा था, लेकिन वह process मौजूद नहीं था; इससे साफ हुआ कि यह deadlock नहीं, बल्कि crash था
- Docker container process namespace होता है, इसलिए core dump Docker host को forward हुआ, और
journalctlमेंpython3process का core dump मिला - पहली बार
gdbसे core खोलने पर container के अंदर की.sofiles न होने के कारण backtrace बेकार था - container से
/lib,/usrआदि copy करके औरgdbकाsolib-absolute-prefixset करने के बाद पुष्टि हुई कि crash pointlibc.so.6काgetenv()था
getenv() द्वारा पढ़ा गया टूटा हुआ environment variable pointer
- पूरा backtrace
getenv()→__dcigettext()→strerror_r()→strerror()→PyErr_SetFromErrnoWithFilenameObjects()flow था - नया HTTP code सीधे crash नहीं हुआ था; Python errno-based exception बनाते समय gettext-related path से होकर
getenv()call कर रहा था - GLIBC 2.17 में
getenv()implementation POSIX केenvironकोchar **list के रूप में traverse करता है, और आखिरीNULLpointer तक environment variable string pointers check करता है - disassembly और register state के हिसाब से
getenv()नेx19 = 0x220address से byte पढ़ने की कोशिश की और crash हो गया0x220स्पष्ट रूप से valid memory address नहीं थाenvironitself को inspect करने पर current environment variable list consistent दिख रही थी
कारण: setenv() और getenv() की race condition
setenv()multi-threaded environment में safe तरीके से call की जा सकने वाली function नहीं है, औरlibcकेgetenv()में अजीब crashes पैदा करने वाली यह समस्या कई बार फिर से खोजी जा चुकी है- disassembly और C code की तुलना करने पर,
x20उस pointerepसे मेल खाता था जोenvironarray पर चलता है - crash के समय
x200x248b5000था, जबकि currentenviron0x28655750था, यानी करीब 60MB आगे - पुराने environment array के आसपास की memory और current
environकी तुलना करने पर आखिरी अंतरSSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crtऔरSSL_CERT_DIR=/etc/ssl/certsentries में दिखा - लगता था कि एक दूसरा thread
setenv()call के दौरानenvironको move कर चुका था, औरgetenv()पुराने environment array को पढ़ता रह गया, यानी use-after-free स्थिति बन गई
openssl-probe और TLS backend connection
rust-native-tlsसे जुड़े एक पुराने issue में clue मिला किopenssl-probesystem certificates खोजने के लिएSSL_CERT_FILEऔरSSL_CERT_DIRenvironment variables set करता है- Linux पर
rust-native-tlsकाopensslbackend इस्तेमाल करने पर वही path call होता है - problematic
openssl-probecode explicitunsafeके बिनाenv::set_var()से दो environment variables set करता हैSSL_CERT_FILESSL_CERT_DIR
- इस combination की वजह से बिना unsafe वाला Rust code उसी process के अंदर
libcusage के साथ खराब interaction करके crash पैदा कर बैठा
ARM64 Linux पर reproduce होने की वजह
- यह crash तभी होता है जब
setenv()द्वाराreallocसे environment array move किए जाने के उसी क्षण कोई दूसरा threadgetenv()call करे - reproduce करने के लिए कई conditions एक साथ match होनी थीं
- environment variables की संख्या इतनी होनी चाहिए कि
realloctrigger हो - unrelated I/O failure
asyncioमें पकड़ा जाना चाहिए - Python error-handling path को
LANGUAGEenvironment variable पाने के लिए ठीक उसी समयgetenv()call करना चाहिए
- environment variables की संख्या इतनी होनी चाहिए कि
- गलत value
0x22064-bit word के हिसाब से पुराने environment size के करीब थी0x220 / 8 = 68- पुराने environment block के terminating
NULLकी जगह यह value overwrite हुई थी, जो system malloc द्वारा free block size दिखाने के लिए इस्तेमाल की गई value लग रही थी
- इतनी सारी preconditions चाहिए थीं, इसलिए किसी एक platform पर इसका काफी reproducible होना अपने-आप में किस्मत की बात थी
ARM64 disassembly में मिले संकेत
getenv()disassembly मेंx20बदलने वाली जगह साफ नहीं दिख रही थी, जिससे confusion हुआ- key point AArch64 का pre-index addressing mode था
ldr x19, [x20, #8]!इस तरह काम करता हैx19 = *(x20 + 8)x20 = x20 + 8
- इस addressing mode की वजह से
x20explicit रूप से left side पर लिखे बिना भी environment variable pointer array traverse कर रहा था
लागू किया गया fix और related project changes
- अंत में Linux पर
reqwestकेrust-native-tls/opensslbackend की जगहrustlsपर migrate करने का फैसला किया गया - मूल रूप से native TLS backend चुनने की वजह यह थी कि Python code को Rust में ले जाते समय दो TLS engines साथ में load करने से बचा जाए
- इस समस्या के बाद short term में दो TLS engines load करना acceptable माना गया
- एक alternative यह भी था कि
try_init_ssl_cert_env_vars()का पहला call Python की GIL hold करते हुए किया जाए- Rust में Rust code के बीच environment पढ़ने-लिखने की race रोकने के लिए internal lock है
- लेकिन यह उन cases को नहीं रोकता जहां दूसरी language का code सीधे
libcइस्तेमाल करता है - GIL hold करने से कम से कम Python threads के साथ race रोकी जा सकती है
- Rust project इस समस्या को पहले ही पहचान चुका था, और 2024 edition में environment setter functions को
unsafeबनाने की योजना है - glibc project ने भी हाल में
reallocसे बचकर पुराने environment arrays को leak करने के तरीके सेgetenv()की thread safety बढ़ाने वाला change जोड़ा है
4 टिप्पणियां
Setenv thread-safe नहीं है, और C इसे ठीक नहीं करना चाहता
Setenv फ़ंक्शन फिर से परेशानी खड़ी कर रहा है।
मैं शीर्षक इस तरह लिखूँगा: 'C stdlib की thread-unsafety को वह Rust भी नहीं बचा सकती जिसे सुरक्षित कहा जाता है' :)
मैंने इसे निश्चित रूप से समझ लिया है।
Hacker News की रायें
यहाँ सबसे बड़ा पॉइंट यह है कि Rust के अगले edition में environment variable सेट करने वाले functions unsafe हो जाएंगे
अगर किस्मत अच्छी रही, तो इसका असर उन crates तक भी नीचे जाएगा जो ऐसे crashes पैदा करते हैं, और इस बीच upstream में https://github.com/alexcrichton/openssl-probe/issues/30 issue दर्ज किया गया है
getenvऔरsetenvयाunsetenvको अलग-अलग threads से सुरक्षित रूप से call नहीं किया जा सकता—वास्तव में ठीक नहीं होतीभरोसेमंद समाधान बस यही दिखता है कि इन functions को अनिवार्य रूप से mutex लेना पड़े, ऐसा बदला जाए
दुर्भाग्य से ecosystem ऐसा नहीं है: https://github.com/seanmonstar/reqwest/blob/master/Cargo.tom...
____UNSAFE_payattention__nevermindthatthisappears50timesinthisfile___जैसे blocks और prefixes को ignore करने के लिए trained हो जाते हैंweb frameworks में भी कुछ ऐसा ही है: Vue में
v-htmldirective है और React मेंdangerouslySetInnerHTML; इस मामले में मुझे लगता है कि Vue साफ़ तौर पर बेहतर हैRust standard library के
set_varऔरremove_varअगले edition, यानी 2024 edition, में ठीक सेunsafe {}block मांगेंगेdocs अब safety issue का ज़िक्र भी करते हैं, लेकिन शुरुआत में इन functions को safe बनाना गलती थी, और यह गलती higher-level languages ने भी की है
https://doc.rust-lang.org/stable/std/env/fn.set_var.html
glibc में environment modify होने के अधिक cases में
getenvको safe बनाने वाला patch है, लेकिन C में अब भीenvironको सीधे access किया जा सकता है, इसलिए modification होने वाली स्थिति में यह पूरी तरह safe नहीं हो सकता: https://github.com/bminor/glibc/commit/7a61e7f557a97ab597d6f...हर active environment variable के लिए amortized basis पर constant-size memory leak बनता है, लेकिन कहा जाता है कि variable itself में भी पहले से ऐसी leak है, और वह लंबाई के हिसाब से बदलती भी है तथा उन values को भी शामिल करती है जो अब इस्तेमाल नहीं होतीं
API के लिहाज़ से सही programs में भी इसके कारण memory अनंत रूप से बढ़ने वाले pathological use cases ज़रूर होंगे
यह दिलचस्प लेकिन असहज है कि API को कई threads से इस्तेमाल कर rule तोड़ने वाले programs को ठीक करने के लिए, API का पालन करने वाले programs में unbounded memory growth जैसा bug introduce किया जा रहा है। dogma से ज़्यादा pragmatism जैसा लगता है
unsafeकी मांग क्यों होनी चाहिए, यह सवाल हैभले ही C standard library maintainers
setenvको multi-thread safe बनाने का विरोध करें, कम से कम POSIX के भीतर, या पहले de facto standard बनाकर POSIX से बाद में उसे स्वीकार करवाकर, नई thread-safe API define की जानी चाहिएअगर “कुछ नहीं किया जा सकता” के कारण समझाने में लगाया समय इस समस्या को ठीक करने में लगाया गया होता, तो पुरानी
setenvका replacement बन चुका होता और कई software projects में उसे deprecate व remove किया जा सकता थाglibc इस समस्या को practically खत्म करने की दिशा में बदलाव कर रहा है, यह देखते हुए Musl maintainer की यह बात भी कम convincing लगती है कि Musl के अंदर इसे ठीक नहीं किया जा सकता
extern char **environ;का मौजूद होना हैजब तक
environpublicly accessible है, कोई guarantee नहीं किsetenvऔरgetenvका इस्तेमाल होगा ही। क्योंकि दोनों mandatory नहीं हैंअगर
environको हटाया जा सके, तोsetenvऔरgetenvको thread-safe बनाना काफ़ी आसान है। अगर नहीं हटाया जा सकता तो असंभव है, लेकिन यह तर्क दिया जा सकता है कि complete solution न होने पर भीsetenvऔरgetenvको thread-safe बनाना improvement हैexec()functions environment को argument के रूप में नहीं लेते या executable खोजने के लिएPATHsearch करते हैं, उन्हें भी locking की जरूरत पड़ेगीLinux पर environment variables से जुड़े bugs का सामना करना एक तरह की रस्म-ए-गुज़र जैसा लगता है, और दूसरे Unix पर अजीब तरह से यह कम समस्या बनता है
Linus और kernel POSIX bugs को व्यावहारिक तरीके से ऐसे ठीक करते हैं कि वे असल में फटें नहीं, लेकिन यह थोड़ा मज़ेदार है कि glibc अब भी पीछे है, जबकि लोगों को समस्या को थोड़ा भी कम करने की कोशिश किए दशकों हो चुके हैं
TZजैसी तमाम सिरदर्दी चीज़ें हैं, यह सही है, लेकिन अगरgetenv_r()उपलब्ध कराया जाता, उसेsetenv()के साथ synchronize किया जाता, औरgetenv()इस्तेमाल करने पर compile/link चरण में सिर्फ warning दे दी जाती, तो काफी समस्याएँ गायब हो जातींइससे भी आगे, environment pointer को read-only रखते हुए copy-on-write (COW) तरीका भी अपनाया जा सकता था
इसके बजाय समस्या को अलग-अलग applications पर धकेल दिया गया, जो बड़ी गलती है क्योंकि application authors को dependencies क्या कर रही हैं, यह लगभग पता नहीं चल सकता। बहुत पहले मेरी स्थिति भी ऐसी ही थी, और उस समय closed-source library vendor ने कहा था कि उस खिलौना Unix clone, Linux, का इस्तेमाल बंद कर दो
समस्या implementation नहीं, बल्कि API खुद है।
setenv(),unsetenv(),putenv(), खासकरenviron, multi-threaded programs में मूल रूप से unsafe हैंgetenv_r()भी पूरी तरह बचा नहीं सकता। क्योंकि जब एक thread environment variable की पुरानी value दिए गए buffer में copy कर रहा हो, उसी दौरान दूसरा threadsetenv()call कर सकता हैबेशक,
getenv()से value लेने के बाद अगर दूसरा threadsetenv()call करके उस memory को invalidate कर दे, तो उसेgetenv_r()ठीक कर देता है, लेकिन API को तोड़ने वाली दूसरी calls को रोकने का कोई तरीका नहीं हैlibc
getenv()/setenv()/putenv()/unsetenv()के अंदर mutex पकड़कर कुछ समस्याएँ कम कर सकता है, लेकिन libc अब भी यह guarantee नहीं दे सकता किgetenv()द्वारा लौटाई गई value calling code के इस्तेमाल करने तक valid रहेगीenvironपर direct access को safe बनाने का भी कोई अच्छा तरीका नहीं है।environको thread-local बनाया जा सकता है, लेकिन तब हर thread का environment view स्थायी रूप से अलग हो सकता है औरgetenv_r()call तथाenvironको सीधे देखने के नतीजे भी अलग हो सकते हैंयहाँ backward compatibility बनाए रखना सचमुच मुश्किल है, और सिर्फ functions को protect करने वाला mutex जोड़ना भी existing programs का अर्थ बदलकर उन्हें तोड़ सकता है
पहले
setenvके भयानक होने पर एक लेख था: https://www.evanjones.ca/setenv-is-not-thread-safe.htmlइस पर चर्चा भी हुई थी और पहले comment से ही बात थी कि यह Rust में समस्या पैदा करता है: https://news.ycombinator.com/item?id=38342642
यहाँ बाकी अधिकांश समस्याएँ development environment जैसी लगती हैं। Amazon data center की remote machine पर Docker से test किया गया, और वह equipment process crash report नहीं कर पाया
इसके अलावा container के अंदर backtrace पाने के लिए पर्याप्त debug symbol information भी नहीं थी। अगर पहली failure पर साफ backtrace मिल गया होता, तो बात तुरंत स्पष्ट हो जाती
शुरुआत में
setenvइस्तेमाल करने की ज़रूरत ही क्यों है, यह भी सवाल हैयह बात देखकर मुझे पुराने कुछ सहकर्मियों का बहुत भरोसे वाला 12-factor app आंदोलन याद आ गया। उन “factors” में से एक यह था कि application configuration environment variables से करनी चाहिए
मुझे यह हमेशा थोड़ा मूर्खतापूर्ण लगा, क्योंकि configuration का तरीका एक flat namespace में string-type values को टोकरी की तरह डालने वाली संरचना है
getenv()/setenv()/environके खतरे भी environment variables को configuration के लिए न इस्तेमाल करने का मजबूत आधार लगते हैंबेशक हमेशा कोई शानदार और अच्छे से supported विकल्प मौजूद नहीं होता। मुझे configuration files पसंद हैं, और development·staging·production values भरने वाले template configurations भी इस्तेमाल किए जा सकते हैं। आम तौर पर कमियों और pitfalls के बावजूद YAML इस्तेमाल करता हूं, लेकिन भले ही कोई बेहतर configuration file format हो सकता है, मुझे YAML environment variables से कहीं बेहतर लगता है
NT में environment variables को typed और templated बनाया जा सकता है, और Registry भी है, जो namespace वाला configuration database है। हालांकि वह verbose और अजीब है
इसके अलावा MSVC लगभग सभी standard library functions के thread-safe versions देता है
नए C/C++ developers को MSVC की POSIX compatibility की कमी पर अफसोस जताते अक्सर सुनता हूं, लेकिन लगता है वे गहराई से नहीं सोचते कि इसका असल मतलब क्या है। यह ज्यादा कुछ 1990s में लिखे C programs के साथ cross-compatible होने की इच्छा जैसा है
यह किसी function के signature भर से behavior का अनुमान लगाने की क्षमता में बाधा डालता है, और कई functions को impure बना देता है जो pure functions हो सकते थे
अगर कोई language feature हो जो किसी process में environment variables का इस्तेमाल न होने दे और उन्हें सिर्फ एक बार—variable-by-variable नहीं, बल्कि एक बार batch में ही पढ़ने दे—तो मैं उसे हर जगह इस्तेमाल करूंगा
getenv()अपने आप में पूरी तरह ठीक है, समस्याsetenv()हैसिद्धांततः उस रहस्यमय app के शुरू होने से पहले environment set हो चुका होता है, इसलिए इसे इस्तेमाल करने की जरूरत नहीं होनी चाहिए
लेकिन flat namespace, string values, और यह कि कौन-सी libraries और modules अंदर आएंगे इसका पता न होते हुए भी सभी द्वारा share किया जाने वाला free-for-all global space—ये बातें
setenv()की safety problem न भी हो, तब भी अच्छी idea नहीं हैंयहां लोग जिन समस्याओं की बात कर रहे हैं, उनमें से ज्यादातर environment को mutable global state के लिए key-value store की तरह abuse करने से आती लगती हैं। समझ नहीं आता कोई ऐसा क्यों करना चाहेगा
JVM environment को व्यवहार में immutable मानता है, और संभव है कि SoundCloud जैसी Scala·Java इस्तेमाल करने वाली कंपनियों ने 12-factor app आंदोलन को प्रभावित किया हो। मैंने कभी environment बदलने या threading issues पैदा करने वाली घटना नहीं देखी
Environment बदल भी जाए तो JVM start के समय बनी immutable copy वैसी ही रहती है, और सामान्य Java API से environment के साथ interaction करने वाला code उस modification को नहीं देखता
Configuration files की समस्या यह है कि parsing process-by-process होती है। इसलिए Linux/Unix इतना उलझा हुआ है। हर tool के configuration conventions और mechanisms अलग हैं, कोई standard नहीं है
Docker ecosystem में container के अंदर आप जो भी करें, बाहर के साथ interface या तो volumes mount करके हर app की complex configuration शैली follow करना है, या बस environment variables इस्तेमाल करना है
आजकल Docker पर चलने वाला अधिकांश modern software इतना Docker-friendly है कि environment से उसका behavior पूरी तरह control किया जा सकता है, और कई मामलों में यह पर्याप्त होता है
Docker Compose या Kubernetes इस्तेमाल करने पर process start करने का तरीका define करने वाली environment variables की list YAML file में होती है, इसलिए कुछ हद तक मनचाही structure मिल भी जाती है। मुझे YAML पसंद नहीं, लेकिन यह काफी काम करता है, और syntax issue दिन खराब कर सकता है, पर alternatives भी बेदाग नहीं हैं
मैं भी 12-factor app style इस्तेमाल करता हूं, लेकिन app में आने के बाद environment variables और data validate करके store कर देता हूं। उसके बाद कोई समस्या नहीं होती
कम नजर आने वाले bug की तह तक जाने वाला शानदार लेख है
intermittent bug, architecture-specific behavior, dependency के अंदर छिपा होना, Rust, Python GIL, gettext—सब कुछ इसमें था
ऐसी detailed troubleshooting reports खुद अनुभव करने के सबसे करीब की सामग्री होती हैं। जब dependency वह चीज इस्तेमाल कर रही हो और मुझे कैसे पता चलता वाली स्थिति हो, तो “बस X इस्तेमाल मत करो” कहना आसान नहीं होता
वे कहते हैं, “nightly CI machine Amazon AWS पर चलती है, और फायदा यह है कि container नहीं बल्कि असली root user इस्तेमाल किया जा सकता है”, और साथ ही “container के बाहर जरूरी files नहीं हैं और container बहुत minimal है, इसलिए
gdbआसानी से install नहीं किया जा सकता”क्या अब लोगों ने cloud और containers के बिना local पर build और debug करने की क्षमता खो दी है?
बहुत छोटी-सी चीज करने के लिए भी तमाम cloud complexity और deployment layers जरूरी हो गई हैं। PC revolution को 100% उलटकर हम भद्दे और महंगे mainframe computing के दौर में लौट आए हैं
वजह यह है कि पैसा cloud में है, और cloud DRM है। Software को वहां डाल दें तो subscription fee ली जा सकती है, बचना संभव नहीं, और perfect lock-in हमेशा बनाए रखा जा सकता है। कई बार users अपना data भी बाहर नहीं निकाल पाते
Product optimization के लिए real-time analytics भी आसानी से किया जा सकता है
Computing architecture, business model के downstream में है। Mainframe पहली बार इसलिए मरा था क्योंकि internet नहीं था और PC सस्ते थे, लेकिन इसलिए भी कि vendors ने lock-in power का बड़ा हिस्सा खो दिया था
अब बहुत अधिक profitable model को वापस जिंदा करने का तरीका मिल गया है। Users की परेशान करने वाली freedom खत्म हो गई है, और ईमानदारी से कहें तो users को ऐसी freedom मिल जाए तो वे अक्सर पैसा नहीं देते, जिससे quality software business खड़ा करना मुश्किल हो जाता है
Local पर crash reproduce न हुआ होगा, इसकी संभावना बहुत है। Developer machines ज्यादातर x86 रही होंगी और वहां crash नहीं आया होगा
Crash handling बेहतर होनी चाहिए थी, लेकिन लगता है उन्हें भी इस problem का अंदाजा है और यहां चर्चा का मुख्य बिंदु वह नहीं है
बदल सकने वाली global state बुरी चीज़ है। दोस्त हों तो दोस्तों को बदल सकने वाली global state इस्तेमाल नहीं करने देते
मुझे environment variables पसंद नहीं हैं। यह “Linux तरीका” है, लेकिन इसे महामारी की तरह टालता हूँ। ज़ोरदार सिफारिश करता हूँ
libc भयानक है, और दुनिया को अब इससे आगे बढ़ जाना चाहिए
समस्या Linux नहीं है, न ही बदल सकने वाली global state या resources, और न ही libc है
समस्या यह है कि दफ्तर में काम ठीक से करने का समय नहीं मिलता। मसलन, समस्या फटने से पहले उसे GDB से पकड़ना हो तो मैनेजर को code और उस code द्वारा छुई जाने वाली हर चीज़ को लगातार debug और backtrace करने का समय देना होगा
आधे-अधूरे code में बहुत ज़्यादा पैसा लगा हुआ है। दुखद है, लेकिन सच है
ऐसी समस्याएँ इतनी ज़्यादा थीं कि आखिरकार
LD_PRELOADसेgetenv/setenv/putenvको patch कर दिया