Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
# CHANGELOG

## [3.8.2] - 2026-06-12

- [Fixed] `data_flatten` (and thus all `query`/`query_one`/`mutate` + subscription callbacks) now strips nested `list` items from list results. This prevents consumers (e.g. valiotworker `currentQueues = ...` from `all_queues_w_listeners`) from receiving lists as elements inside what should be list-of-dicts; such bad elements produced `TypeError: list indices must be integers or slices, not str` inside `__.find(..., lambda cq: ... cq['name'])` in `workerQueueEnabled` (and similar) when an event subscription callback fired. (OPS-3999)

## [3.8.1] - 2026-06-11

- [Fixed] Stale `httpx.AsyncClient` instances are now best-effort `aclose()`d instead of being dropped to the garbage collector. Previously three paths leaked the client's transports: `_get_async_client` when it detected a closed event loop, `async_execute`'s "Event loop is closed" retry, and `_close()`/`__del__`. Orphaned `_SelectorTransport.__del__` socket finalizers then ran during cyclic-GC sweeps on arbitrary threads, contributing to false TMPRL1101 deadlock detections in Temporal workers (see valiot/python-tooling#151). A new private `_drop_async_client()` helper awaits `aclose()` (swallowing failures when the original loop is gone) and is used by `_get_async_client`, the `async_execute` retry, and `async_cleanup`; `_close()` now schedules `aclose()` on the running loop via `call_soon_threadsafe` when one exists, falling back to GC only when no loop is available.
- [Fixed] `async_cleanup` now actually closes a live async client. Its previous usability probe called `httpx.AsyncClient.get_timeout()`, a method that doesn't exist, so every client went down the "let GC handle it" branch and leaked. (Same broken probe in `_get_async_client` meant the cached client was recreated on every call; recreation now closes the old client first, so nothing leaks.)
- [Changed] Removed the unused `_close_async_client` private method, superseded by `_drop_async_client`.

## [3.8.0] - 2026-05-28

- [Fixed] `addEnvironment` no longer wipes an existing environment's `wss`/`url`/`headers`/`post_timeout`/`ipv4_only` when re-registering the same environment name without those arguments. Re-registration now MERGES — omitted arguments keep their previous values. Previously a second `addEnvironment("prod", url=..., headers=...)` on the shared (Singleton) client — e.g. a library configuring its own environment — silently reset `wss` to `None`, which crashed the WSS reconnect loop in `_new_conn` with `TypeError: argument of type 'NoneType' is not a container` and produced endless "Failed connecting to None" errors. This was the root cause of the production incident. (OPS-3496)
- [Fixed] `_new_conn` now guards against an unset/unregistered environment or a missing `wss`, logging a clear ERROR and returning `False` instead of raising `AttributeError`/`TypeError` and killing the subscription router thread. Defense-in-depth alongside the `addEnvironment` fix. (OPS-3496)

Expand Down
10 changes: 6 additions & 4 deletions pygqlc/GraphQLClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,16 +127,18 @@ def _data_flatten_impl(data, single_child=False):
if len(keys) == 1:
return _data_flatten_impl(data[keys[0]], single_child)
else:
return data # ! various elements, nothing to flatten
return data
elif single_child and isinstance(data, list):
if len(data) == 1:
return _data_flatten_impl(data[0], single_child)
elif len(data) == 0:
return None # * Return none if no child was found
return None
else:
return data
return [x for x in data if not isinstance(x, list)]
else:
return data # ! not a dict, nothing to flatten
if isinstance(data, list):
return [x for x in data if not isinstance(x, list)]
return data


def data_flatten(data, single_child=False):
Expand Down
2 changes: 1 addition & 1 deletion pygqlc/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "3.8.1"
__version__ = "3.8.2"
31 changes: 30 additions & 1 deletion tests/pygqlc/test_helpers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from pygqlc.GraphQLClient import safe_pop
from pygqlc.GraphQLClient import safe_pop, data_flatten, _data_flatten_impl
from pygqlc.helper_modules.Singleton import Singleton


Expand Down Expand Up @@ -45,3 +45,32 @@ def __init__(self, letter="G"):
# Third call: no cached instance, so create one
third_instance = UselessLetterClass()
assert first_instance is not third_instance, "Should be a different instance"


def test_data_flatten_sanitizes_lists_to_avoid_nested_lists_in_object_lists():
"""TDD for OPS-3999: data_flatten on list-field shapes must not let nested lists
(or other non-dicts) appear as elements; this prevents valiotworker finding
list instead of dict in currentQueues (and similar list-of-objects results)
which led to TypeError: list indices must be integers or slices, not str in
workerQueueEnabled lambdas etc. We filter to dict/None only for safety on
object list responses (scalars lists would be affected but not used in
hot paths like queues)."""
env = {
"queues": [
{"name": "q1", "listeners": [{"id": 1}]},
["bad", "list", "as", "queue", "entry"],
{"name": "q2"},
None,
"scalar-bad",
]
}
result = data_flatten(env)
assert isinstance(result, list), "list field must still return list"
# no nested lists (the root cause of list-indices TypeError in consumers);
# scalars/None may remain for scalar lists but object lists won't get sublists
assert not any(isinstance(x, list) for x in result)
names = [x.get("name") for x in result if isinstance(x, dict)]
assert names == ["q1", "q2"]
# direct impl too
result_impl = _data_flatten_impl(env)
assert not any(isinstance(x, list) for x in result_impl)