Repository navigation
Conversation
Asynchronous client APIs lacked an explicit ownership contract. Calling applications had no clear guidance whether an operational failure required them to release their completion context data or whether the library would dispatch a completion callback reporting the error. Internal completion queues, serialized request buffers, and transport state structures were routinely abandoned during network disconnects or handle closure. - Async requests allocate their send queue entry before they register the completion. - Completion registration owns watcher registrations, deregistrations and multi sub-op entries on every path. Unprocessed multi sub-ops are released. - The MT completion thread drains its queue before it exits, so late ZCLOSING completions are no longer lost. A close on the IO thread does not hang. - Allocate all SASL context in one allocation, so the documented free() releases everything cleanly. - The SSL_CTX is released when SSL_new() fails, and handle references stay balanced on SSL init failure. - CLI async error paths and reconfig release their allocations. - Init, resolve_hosts, reconnect input buffer, recordio, watcher table and logging OOM paths release their allocations. - Fix test-side leaks. - Add configure --enable-asan and the Maven c-test-asan profile to run the test suite under ASan/LSan. The work here also integrates AddressSanitizer and LeakSanitizer as an option for Autotools (--enable-asan) and Maven profile (-Pc-test-asan). Dynamic profiling of master under AddressSanitizer and LeakSanitizer initially identified 45,578 bytes leaked across 178 allocations. Following these fixes, both zktest-st and zktest-mt pass with 100% success 49/49 and 97/97 tests respectively and no sanitizer findings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anmolnar
left a comment
There was a problem hiding this comment.
You got a thumbs-up from Claude on this patch with a few nitpicks.
Requested changes
1. include/zookeeper.h: document the exception to the ownership contract
When zookeeper_close() is called from the IO thread (e.g. inside an auth completion, as in testCloseInAuthCompletion), completions queued after the completion thread exits are released in destroy() without invoking their callbacks. In that case the caller's data is never handed back. The code comment in destroy() explains this, but API users won't see it. Could we add a sentence to the zookeeper_close() docs and a short note to the completion typedef docs ("...except when the handle is closed from the IO thread")?
2. src/zookeeper.c, destroy(): comment the sync-completion case
Entries in the THREADED drain loop whose data is SYNCHRONOUS_MARKER are freed without calling notify_sync_completion(), so a thread blocked in a sync call would never be woken. That can only happen if the application calls a sync API concurrently with zookeeper_close(), which is already undefined behaviour, so I'm fine leaving it. A comment saying so would help the next reader.
3. src/zk_hashtable.c, do_insert_watcher_object(): log the dropped watcher
Previously a failed insert hit assert(res); now the watcher is silently dropped and activateWatcher() ignores the return value. Releasing the memory is right under OOM, but a LOG_ERROR here would make a lost watch diagnosable.
4. Description accuracy
src/zk_log.c: only the time buffer falls back to stack storage. If the format buffer TSD fails,log_message()now prints "Unable to allocate memory buffer" and drops the message, where it previously logged (and leaked). That's a reasonable trade-off, but the description says the fallback applies generally.src/zk_sasl.c,_zsasl_getsecret(): the fixed-capacity secret buffer is a clear improvement overrealloc(), but "ensuring safe reads by concurrent connections" overstates it. When two handles share a callback block, this is still an unsynchronised in-place write, and it's only benign when the password bytes don't change between calls. Could we soften the wording, or add a comment noting that sharing callbacks across handles assumes the credential doesn't change?zoo_amulti(): "without altering caller result arrays" isn't quite accurate. When a later sub-op fails, theresults[]entries for earlier sub-ops still havevalue/valuelen/statassigned. That behaviour predates this PR and is harmless, so this is only a wording fix.
The binary interface is unchanged. The shared libraries export the same symbols, no calling conventions changed, no public structure changed, and the header changes are comments only. Existing binaries will link against the zookeeper client library and run without recompiling. However there is one area of the API that is a bit subtle and was formerly underspecified. The code described the SASL callback allocation as a "freshly-malloc()ed callbacks array", which could suggest that realloc() be used to append entries. However, in practice that is lunacy, so it is very doubtful any caller resizes the array, or copies its entries into another array and frees the original, or other crazy and theoretical patterns. The documentation now states that the array is opaque, meant to be handed to zookeeper_init_sasl() as is, and released with one free() after every handle that uses it is closed. A password typed at the terminal is now limited to 1023 characters after the changes here and one longer than that will fail with SASL_BUFOVER. Passwords from a file or from a password callback already had this limit. Documentation is updated to states all the limits clearly.
|
Clients that already use the C client should be able to link and run without any changes, but there is one subtle and formerly underspecified area of the C API. I have attempted to address that with 98b296e . |
|
Thanks @anmolnar , let me address the nits and come back shortly. |
When zookeeper_close() is called from a callback that runs on the IO thread, completions and watcher events that are queued after the completion thread stops are released in destroy() without being invoked. The zookeeper_close() documentation now clear describes this case, and each completion typedef notes the exception to the ownership contract.
zoo_add_auth() queues the auth packet but does not wake the IO thread, so the packet goes out with the next ping, up to recv_timeout/3 later (3.3 s here). The old 3 s wait, rounded to whole seconds, could expire first. The assertion in voidCompletion() then saw the late completion with ZCLOSING when the handle closed, and the throw from the callback terminated the process. Wait up to 10 s against an exact deadline, polling every 10 ms. Record the result in the completion and check it on the test thread. Use the same wait for the ZOOKEEPER-1108 check instead of a fixed sleep. This change is to the test only. The flake also occurs on master. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
testHangingClient closes the handle while default_zoo_watcher() can still run and re-arm its child watch. When the re-arm request is made after the close starts, the client refuses it, and zoo_wget_children() returns ZMARSHALLINGERROR. The watcher accepted only ZOK and ZCLOSING, so the assertion failed in the callback and terminated the process. Accept ZMARSHALLINGERROR as a third valid result of this race. This change is to the test only. The race also occurs on master. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Hi @apurtell, in free_completions(), both cptr and sc should point to NULL, it results in dangling pointer otherwise. |
|
I pushed up more commits on the PR branch that address each nit sequentially. For item 2, my read of the code is that it handles sync completion in a way that doesn't involve this queue and wakes waiting threads before the drain. Updated related comments in While running tests after each change I noticed some flakes that also occur on master without any of the changes made on this PR. I've fixed them here to get clean repeatable runs but let me know if you'd prefer they go in separately. They are called out in their commit messages. Some of the claims that Claude wanted reworded or softened appeared only in the PR description text. The text has been updated so they won't mislead further. |
|
Hi @Gowrima . |
A number of the C client APIs lacked a clear ownership contract. Calling applications had no clear way to know whether a failure required them to release their completion context data, or whether they needed to leave it alone because the library would dispatch a completion callback. Internal completion queues, serialized request buffers, and transport state structures were routinely abandoned during network disconnects or handle closure.
Dynamic profiling of master under AddressSanitizer and LeakSanitizer initially identified 45,578 bytes leaked across 178 allocations. Following these fixes, both zktest-st and zktest-mt pass with 100% success 49/49 and 97/97 tests respectively and no sanitizer findings.
The work here also integrates AddressSanitizer and LeakSanitizer as an option for Autotools (--enable-asan) and Maven profile (-Pc-test-asan).
[ This is a long block of PR description text, and superficially it may resemble workslop, but it it not. ]
Approach
All fixes preserve existing public function signatures, symbols, and struct layouts. The only internal structure modified is private to
zk_adaptor.hand is not exposed in public headers. Applications compile, link, and run against the updated library without code changes.The ownership contract across all asynchronous entry points (
zoo_a*, excludingzoo_add_auth) is formalized as follows:This contract provides C client library users with a simple and clean rule for when they must (or must not) clean up local contexts and buffers.
Reordering dispatch pipelines so that all allocations, buffer serializations, and validations happen before completion registration ensures that registration and enqueueing are atomic. Any failure prior to enqueueing unwinds locally without affecting completion queues.
Where existing public APIs expose an opaque pointer that users reclaim using
free(), all nested structs and strings are packed into a single contiguous allocation. The returned pointer references the beginning of the block. A single standardfree()reclaims the entire tree without requiring new destructor functions or problematic caller side cleanup.Details
Async Request Pipeline (
src/zookeeper.c,include/zookeeper.h)The request pipeline was restructured to enforce the ownership contract and eliminate orphan buffers.
Asynchronous request functions (
zoo_acreate,zoo_adelete,zoo_aexists,zoo_aget,zoo_aset,zoo_aget_children,zoo_async,zoo_aget_acl,zoo_aset_acl,zoo_asyncc,zoo_amulti,zoo_awexists,zoo_awget,zoo_awget_children,zoo_aremove_watches) now callallocate_request_buffer()beforecreate_completion_entry(). Because queueing viaqueue_request_buffer()cannot error out, serialization or memory failures abort cleanly before completion registration, guaranteeing callbacks are never registered on failed dispatches.Output buffers are closed and reclaimed using
close_buffer_oarchive(&oa, rc < 0)across all public asynchronous APIs, internal control packets (send_info_packet,send_set_watches,send_ping,queue_sasl_request), and session termination messages. This prevents serialized request buffers from leaking during connection drops or client close.When
zoo_amulti()fails before it queues the request, it now releases all sub-op completion entries, the output archive and the serialized request buffer. As before, it returnsZUNIMPLEMENTEDfor an unsupported opcode andZMARSHALLINGERRORfor all other failures. Also as before,zoo_amulti()does not resetresultson failure. Entries for create and set-data sub-ops can keep thevalue,valuelenorstatvalues copied fromops. Thevalueandstatpointers refer only to caller memory, so noresultsentry refers to memory that the library frees.In
zoo_aremove_watches(), buffers and watcher structures are freed along all early exit branches.SASL Authentication (
src/zk_sasl.c,include/zookeeper.h)The SASL integration leaked memory because callback context structures were allocated independently of the primary callback array. These leaks have been resolved.
zoo_sasl_make_basic_callbacks()andzoo_sasl_make_password_callbacks()now allocate a single contiguous memory block containing thesasl_callback_tarray, all context structures and string copies. Callingfree()on this pointer reclaims the entire compound block. Authentication secret storage uses a fixed capacity buffer inside the contiguous allocation, updated in place each time Cyrus SASL requests the password. This avoidsrealloc(), which risks orphaning buffers on failure and can free a secret that another handle sharing the callbacks still uses. The secret pointer therefore stays valid until the caller frees the block. The in-place write is not synchronized. When handles share one block, this is benign only when the password does not change while they are open. The header documents this condition.Multithreaded Handle Teardown (
src/mt_adaptor.c,src/zookeeper.c)Teardown in
libzookeeper_mtpreviously suffered from completion abandonment and deadlock risks. In the updated design, the IO thread signals when it stops generating completions, and the completion thread continues running until the close is requested, the IO thread has terminated, and the completion queue is drained. SyntheticZCLOSINGcompletions are executed deterministically on the completion thread.When an application calls
zookeeper_close()from within an IO thread callback, the IO thread cannot join itself.adaptor_finish()detects this condition and marks the IO thread as finished, allowing the completion thread to complete draining without deadlock.Residual completions remaining during
destroy()after thread shutdown are freed.TLS/SSL (
src/zookeeper.c)Socket setup and TLS negotiation paths were hardened against resource leakage:
init_ssl_for_socket()maintains an invariant where the OpenSSL connection and context pointers are either both valid or both freed and set toNULLon every exit.close_zsock()releases the context together with the connection. Releasing the context independently was avoided to prevent races.init_ssl_for_socket()checks allSSL_CTX_new()return values, clears pointers on release, and reclaims context allocations ifSSL_new()fails.zookeeper_interestwhere TLS setup failures bypassedapi_epilog(). Becauseapi_prolog()increments the handle reference count, bypassing epilog logic prevented the count from reaching zero, leaking thezhandle_tstructure.zookeeper_init_internal()unwinds certificates, socket structures, and the client handle across all failure paths, including Windows socket initialization and out-of-memory errors.prime_connection()purges residual input buffers left by severed connections.CLI (
src/cli.c)Async command handlers keep tracking structures in local scope until dispatch succeeds. If dispatch returns non-
ZOK,async_failed()immediately frees command strings and context structures. Common exit handling inreconfigroutes all errors through a unified cleanup routine. Repeated arguments release previous allocations before storing new values. Finally, CLI shutdown releases SASL callback blocks viafree()and callssasl_client_done()across normal and interrupted exit paths.Other Changes (
recordio.c,zk_hashtable.c,zk_log.c,tests/*)resize_buffer()inrecordio.cretains existing buffer pointers ifrealloc()fails, preventing pointer abandonment.do_insert_watcher_object()inzk_hashtable.cfrees keys, lists, and watcher structures ifstrdup()orhashtable_insert()fails. The watch is then lost, and the caller is not told, so the function also logs the dropped watcher at error level.In
zk_log.c,getTSData()now frees its buffer and returns NULL whenpthread_setspecific()fails. Before, it returned a buffer that it did not store, so each call leaked a new buffer.log_message()handles a NULL buffer in two ways. For the time string, it uses a stack buffer. This also prevents a NULL dereference whencalloc()fails. Windows builds already used a stack buffer for the time string. The format buffer has no fallback.log_message()writes "log_message: Unable to allocate memory buffer" to stderr and drops the message. It already did this whencalloc()failed. Before this change, apthread_setspecific()failure did not drop the message, but it leaked the buffer. Dropping the message is a small behavior change, and we prefer it to a leak on every call. Only the multi-threaded library is affected. The single-threaded library uses static buffers.Added explicit deallocation for synchronous query vectors, closed serialized archives in test cases, and corrected auth buffer length calculations to eliminate buffer overreads.
Hardening
During the implementation and review of the multi-threaded teardown and lifecycle changes, three race conditions were root caused and resolved:
1. Close Race Condition During Handle Teardown
zookeeper_close()previously markedclose_requested = 1before incrementing the handle reference count. If worker threads exited quickly and dropped the last reference in that window, the handle could be freed by worker threads whilezookeeper_close()was still accessing it, resulting in a use-after-free followed by a double free.zookeeper_close()before publishingclose_requested. Handle destruction is guaranteed to occur only after the closing thread completes its execution.2. CLI Handle Teardown Synchronization with SASL Cleanup
main()could exit, freeing the SASL callback structures and callingsasl_client_done()while the handle'ssasl_dispose()call was still pending in Cyrus SASL.cli.c, defer SASL state cleanup andsasl_client_done()until after the handle has been completely destroyed via synchronous close frommain().3. Paired SSL Context and Connection Teardown
SSL_CTXindependently ofSSLinclose_zsock()introduced a race condition: an application thread callingclose_zsock()(such as viazoo_set_servers()) could destroy the context while the IO thread was in the middle of configuring it insideinit_ssl_for_socket().init_ssl_for_socket()either configures bothSSLandSSL_CTXsuccessfully or cleans up both on error.close_zsock()only destroys the context in tandem with the connection object.API Compatibility
Strict binary and source compatibility was maintained throughout these changes. Existing applications require no code modifications.
Sanitizer Analysis and Verification
Verification was performed on Linux Ubuntu 22.04 LTS, compiled with GCC 11.4.0, with glibc 2.35, OpenSSL 3.0.2, Cyrus SASL 2.1.27, and Eclipse Adoptium OpenJDK 17. Test suites were executed under native AddressSanitizer and LeakSanitizer instrumentation.
Baseline Analysis
Sanitizer runs on unmodified
masterbranch identified significant memory leaks and a buffer read error:These leaks were either client side compound structure leaks or test harness issues.
The vast majority was caused by circular handle references within the CppUnit test harness mocks, which prevented handles from completing teardown during
zookeeper_close().The main issue within the client library was the SASL callback memory management. Callback arrays and their associated context data were allocated separately, so calling
free()only released the top-level array while orphaning nested context structures. The contiguous allocation design resolves this by ensuring a singlefree()reclaims the entire compound block.The other issues are as described above.
Post-Change Verification
Following the changes detailed above, the CppUnit test suites run clean under AddressSanitizer and LeakSanitizer:
Valgrind Memcheck Profiling
Valgrind memcheck was run across test suites on
masterand the patched branch to inspect uninitialized reads and memory access pattern. No Valgrind errors originate in code introduced or modified by this change.Build System Integration
Sanitizer tooling is integrated into both build systems:
./configure --enable-asan. Appends-fsanitize=address -fno-omit-frame-pointerto compiler and linker flags while maintaining platform defines (-D_GNU_SOURCE).c-test-asanprofile:Automated test runs configure
LSAN_OPTIONS=suppressions=tests/lsan.supp. The suppression file contains a single entry targeting known glibc dynamic linker TLS allocations during SASL plugin initialization.