Skip to content

HTTP driver leaks the full malloc'd response body if an ERROR is thrown before the cursor's reset callback is registered #274

@iskakaushik

Description

@iskakaushik

Summary

In the HTTP driver, a fully materialized query response (ch_http_response_t, raw malloc/calloc memory from the curl layer) has exactly one deallocation path: ch_http_response_free(), invoked either inline on explicit error branches or via a MemoryContextResetCallback once the response is attached to a cursor. In http_simple_query() there is a window — after ch_http_simple_query() returns and before MemoryContextRegisterResetCallback() runs — in which any ereport(ERROR) longjmps past every free. Because the response is plain malloc memory owned by no memory context, it is leaked for the lifetime of the backend. All throw points in this window are allocation failures, so the bug triggers precisely under memory pressure and compounds it. The explicit error branches have the same defect in miniature: each performs a throwing palloc before its ch_http_response_free() call.

The streaming path (http_streaming_query) is not affected — it wraps the equivalent window in PG_TRY/PG_CATCH and its error-reporting helper uses PG_FINALLY to end the stream. The binary driver is also not affected as a leak: its response memory is palloc-backed in a dedicated context (src/binary/select.c, pg_chc_alloc in src/binary/binary.c).

All references below are pinned to commit 94256f0.

Where the memory comes from and how it is freed

  • src/http.c:205resp = calloc(1, sizeof(*resp));
  • src/http.c:216ch_http_stream_take_body(stream, &resp->data, &resp->datasize);
  • src/http_streaming.c:543-554 — take_body contract: "On return, *out_data is a malloc()'d buffer the caller must free()." The buffer is malloc'd at src/http_streaming.c:422 and grown with realloc at :200.
  • src/http.c:244-250ch_http_response_free() is the sole free path: free(resp->data); free(resp);
  • Because ch_http_simple_query buffers the whole result (fetch_size = INT32_MAX, src/http.c:201), resp->data is the entire result set, i.e. arbitrarily large.

The code is explicit that context callbacks are the intended cleanup mechanism (src/pglink.c:307-309):

/*
 * we could not control properly deallocation of libclickhouse memory, so
 * we use memory context callbacks for that
 */

The leak window in http_simple_query (src/pglink.c:249-331)

resp exists as untracked malloc memory from line 261 (resp = ch_http_simple_query(conn, query);) until line 327 (MemoryContextRegisterResetCallback(tempcxt, &cursor->callback);), which is the first point at which an abort would free it (via http_cursor_free, src/pglink.c:368-372). There is no PG_TRY in this function. Every statement below can raise ERROR (out of memory) while resp is unowned:

Happy path, lines 311-322:

Line Call Throws on
311 AllocSetContextCreate(PortalContext, "pg_clickhouse cursor", ...) OOM
315 cursor = palloc0(sizeof(ch_cursor)); OOM
318 cursor->read_state = palloc0(sizeof(ch_http_read_state)); OOM
319 cursor->query = pstrdup(query->sql); OOM
322 ch_http_read_state_init(...)initStringInfo (src/parser.c:44-45), pallocs 1 KB OOM

A throw at any of these leaks resp (struct + full response body) permanently.

Explicit error branches — free happens after a throwing palloc:

  • Transport error, lines 272-274: char *error = pnstrdup(resp->data, resp->datasize); runs before ch_http_response_free(resp);. This duplicates the entire buffered body (for a mid-transfer failure, potentially a large partial result) into palloc memory first; if pnstrdup throws, resp leaks. This branch is retried up to 3 times per query.
  • Canceled query, lines 285-286: kill_query(conn, resp->query_id); runs before the free; kill_query (src/pglink.c:189-200) allocates via psprintf/ch_quote_literal and can throw.
  • Non-200 status, lines 294-297: same pnstrdup-before-free pattern.
  • Same pattern in http_simple_insert, src/pglink.c:352-355.

Observable impact

  • Each occurrence permanently leaks a complete materialized ClickHouse response body (unbounded size) plus the response struct from the backend's address space; nothing reclaims it short of backend exit.
  • The trigger condition for the happy-path window is an allocation failure, so the leak fires exactly under memory exhaustion and makes it worse — a feedback loop.
  • The transport-error branch additionally doubles peak memory (pnstrdup of the full buffered body) before freeing, on every retry, under exactly the conditions where responses are failing.
  • In production, this class of behavior was observed during a committed-memory runaway while the remote ClickHouse endpoint was degraded (socket read timeouts and TLS errors), which repeatedly exercises the transport-error branch.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions