confluentinc / confluentinc/confluent-kafka-javascript
Segfault (strlen on NULL) in admin.describeGroups() when brokers are unreachable — unguarded NewFromUtf8 in FromConsumerGroupDescription
- Dominant language
- TypeScript
- Stars
- 304
- Forks
- 45
- Avg merge
- 11h 47m
- Merged PRs (30d)
- 5
Description
## Environment
- OS: macOS arm64 (Apple Silicon)
- Node.js: v20.19.1
- @confluentinc/kafka-javascript: reproduced on **1.4.1** and **1.10.0** (prebuilt binaries); the unguarded code is still present on current `master` (`src/common.cc`, `FromConsumerGroupDescription`)
- Kafka: apache/kafka:3.9.1 single-node KRaft via docker compose (any broker works — the bug only needs the brokers to become unreachable)
## Summary
Calling `admin.describeGroups()` while all brokers are unreachable crashes the whole Node.js process with a segmentation fault (exit 139) instead of rejecting the promise. The native binding converts per-group error results without NULL-checking the string accessors of `rd_kafka_ConsumerGroupDescription_t`, so `Nan::New` → `v8::String::NewFromUtf8(NULL)` → `strlen(NULL)` crashes on the main thread. This makes `describeGroups` unusable in health-check / liveness-probe style code, because the exact moment you most need a clean error (broker outage) is the moment the process dies.
## Steps to reproduce
**docker-compose.yaml** (single-node KRaft broker):
```yaml
name: repro-kafka
services:
kafka:
image: apache/kafka:3.9.1
ports:
- "9092:9092"
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
healthcheck:
test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null 2>&1"]
interval: 5s
timeout: 10s
retries: 24
```
**repro.js**:
```js
const { KafkaJS } = require('@confluentinc/kafka-javascript');
const { execSync } = require('child_process');
(async () => {
const kafka = new KafkaJS.Kafka({ kafkaJS: { brokers: ['localhost:9092'], logLevel: KafkaJS.logLevel.NOTHING } });
const admin = kafka.admin();
await admin.connect();
console.log('connected');
setInterval(async () => {
try {
await admin.describeGroups(['any-group-id'], { timeout: 3000 });
console.log(new Date().toISOString(), 'ok');
} catch (e) {
console.log(new Date().toISOString(), 'unavailable:', e.message);
}
}, 1000);
await new Promise((r) => setTimeout(r, 5000));
console.log('stopping broker');
execSync('docker compose stop kafka');
})();
```
**Run**:
```sh
docker compose up -d --wait
npm i @confluentinc/kafka-javascript@1.10.0
node repro.js; echo "exit=$?"
```
The group does not need to exist. Against a *healthy* cluster, describing a nonexistent group returns a normal DEAD-state description and nothing crashes — the crash requires the brokers to be unreachable (stopped or paused) so that librdkafka produces a per-group **error** description.
## Observed
The process dies with SIGSEGV a few seconds after the broker stops (as soon as the in-flight describe operation times out):
```
connected
2026-09-01T14:06:56.735Z ok
stopping broker
exit=139
```
lldb backtrace (1.10.0 prebuilt, macOS arm64, Node v20.19.1):
```
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
* frame #0: libsystem_platform.dylib`_platform_strlen + 4
frame #1: node`v8::String::NewFromUtf8(v8::Isolate*, char const*, v8::NewStringType, int) + 156
frame #2: confluent-kafka-javascript.node`NodeKafka::Conversion::Admin::FromConsumerGroupDescription(rd_kafka_ConsumerGroupDescription_s const*) + 1228
frame #3: confluent-kafka-javascript.node`NodeKafka::Conversion::Admin::FromDescribeConsumerGroupsResult(rd_kafka_op_s const*) + 92
frame #4: confluent-kafka-javascript.node`NodeKafka::Workers::AdminClientDescribeGroups::HandleOKCallback() + 76
frame #5: node`Nan::AsyncWorker::WorkComplete() + 72
```
## Root cause
When brokers are unreachable, librdkafka completes the DescribeConsumerGroups op with per-group error descriptions built by `rd_kafka_ConsumerGroupDescription_new_error()` (`deps/librdkafka/src/rdkafka_admin.c`), which passes `partition_assignor = NULL`:
```c
static rd_kafka_ConsumerGroupDescription_t *
rd_kafka_ConsumerGroupDescription_new_error(const char *group_id,
rd_kafka_error_t *error) {
return rd_kafka_ConsumerGroupDescription_new(
group_id, rd_false, NULL, NULL, NULL, 0, ...);
}
```
and `rd_kafka_ConsumerGroupDescription_new()` deliberately keeps it NULL:
```c
grpdesc->partition_assignor = !partition_assignor
? (char *)partition_assignor
: rd_strdup(partition_assignor);
```
So `rd_kafka_ConsumerGroupDescription_partition_assignor(desc)` returns NULL for error-state descriptions. But `FromConsumerGroupDescription` in `src/common.cc` feeds the string accessors straight into `Nan::New` without a NULL check — three conversions: `groupId` (via `_group_id`), and `protocol` + `partitionAssignor` (both via `_partition_assignor`):
```cc
// protocol
Nan::Set(returnObject, Nan::New("protocol").ToLocalChecked(),
Nan::New(
rd_kafka_ConsumerGroupDescription_partition_assignor(desc))
.ToLocalChecked());
```
`Nan::New(NULL)` → `v8::String::NewFromUtf8(isolate, NULL)` → `strlen(NULL)` → SIGSEGV, on the main thread inside `HandleOKCallback`, so no JS-level try/catch or process-level handler can intercept it.
This is why a healthy-cluster DEAD response does *not* reproduce it: in that path the description is built from the broker protocol response where the assignor is an empty string, not NULL. Only error descriptions (unreachable brokers, coordinator lookup failure, request timeout) carry the NULL.
Note that the same file already guards this exact pattern elsewhere — `ToV8Object(rd_kafka_Node_t*)` NULL-checks `host` and `rack` "to prevent segfault" — so `FromConsumerGroupDescription` just misses the equivalent guards.
## Expected
A describeGroups response containing per-group errors should be delivered safely to JS (each group object carrying its `error` field), or the promise should reject — the process must not segfault.
## Related
- #374 (`fetchOffsets` segfault for unseen group) and #164 (groups e2e segfault) look like the same class of issue: NULL string accessors from error/absent group state reaching unguarded `NewFromUtf8`.
## Fix
Adding NULL guards (empty-string fallback, following the existing `rd_kafka_Node_host` guard style in the same file) to the three conversions fixes the segfault — verified locally on macOS arm64 with a from-source build of `master` + the guards:
- While brokers are down, `describeGroups` no longer crashes: it resolves with the per-group error delivered to JS (`groups[0].error` = `Local: Timed out`).
- `docker compose pause` / `unpause` outage cycle: the process survives the full cycle, reports unavailable during the outage, and recovers to healthy responses after unpause (exit 0). The same cycle segfaults without the guards.
- `docker compose stop` / `start` cycle: the segfault is gone as well; the process survives the outage window and resumes successful describes. (During validation of this variant I did hit a separate, pre-existing librdkafka assertion — `rd_assert(eonce->refcnt > 0)` in `rd_kafka_enq_once_del_source_return`, reached from `rd_kafka_admin_coord_response_parse` on the `rdk:main` thread — around the broker restart. That is an independent librdkafka admin-op lifecycle bug, unrelated to this conversion-layer fix; the assertion string is present in the published prebuilt binaries too. I can file it separately with the backtrace if useful.)
Fix branch: https://github.com/kty1965/confluent-kafka-javascript/tree/fix/describe-groups-null-guard
Happy to open a PR if this looks good.
Contributor guide
Research direction
Start in src/common.cc at FromConsumerGroupDescription and compare its string conversions with the existing NULL guards in ToV8Object. Run repro.js with the docker-compose broker outage to observe the failure. Done means describeGroups survives unreachable brokers and delivers the per-group error to JavaScript without a process crash.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp, javascript, node.js
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100