|  | 
| 2 | 2 | 
 | 
| 3 | 3 | from __future__ import annotations | 
| 4 | 4 | 
 | 
|  | 5 | +import gc | 
| 5 | 6 | import os | 
| 6 | 7 | import json | 
| 7 | 8 | import asyncio | 
| 8 | 9 | import inspect | 
|  | 10 | +import tracemalloc | 
| 9 | 11 | from typing import Any, Union, cast | 
| 10 | 12 | from unittest import mock | 
| 11 | 13 | 
 | 
| @@ -192,6 +194,67 @@ def test_copy_signature(self) -> None: | 
| 192 | 194 |             copy_param = copy_signature.parameters.get(name) | 
| 193 | 195 |             assert copy_param is not None, f"copy() signature is missing the {name} param" | 
| 194 | 196 | 
 | 
|  | 197 | +    def test_copy_build_request(self) -> None: | 
|  | 198 | +        options = FinalRequestOptions(method="get", url="/foo") | 
|  | 199 | + | 
|  | 200 | +        def build_request(options: FinalRequestOptions) -> None: | 
|  | 201 | +            client = self.client.copy() | 
|  | 202 | +            client._build_request(options) | 
|  | 203 | + | 
|  | 204 | +        # ensure that the machinery is warmed up before tracing starts. | 
|  | 205 | +        build_request(options) | 
|  | 206 | +        gc.collect() | 
|  | 207 | + | 
|  | 208 | +        tracemalloc.start(1000) | 
|  | 209 | + | 
|  | 210 | +        snapshot_before = tracemalloc.take_snapshot() | 
|  | 211 | + | 
|  | 212 | +        ITERATIONS = 10 | 
|  | 213 | +        for _ in range(ITERATIONS): | 
|  | 214 | +            build_request(options) | 
|  | 215 | +            gc.collect() | 
|  | 216 | + | 
|  | 217 | +        snapshot_after = tracemalloc.take_snapshot() | 
|  | 218 | + | 
|  | 219 | +        tracemalloc.stop() | 
|  | 220 | + | 
|  | 221 | +        def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None: | 
|  | 222 | +            if diff.count == 0: | 
|  | 223 | +                # Avoid false positives by considering only leaks (i.e. allocations that persist). | 
|  | 224 | +                return | 
|  | 225 | + | 
|  | 226 | +            if diff.count % ITERATIONS != 0: | 
|  | 227 | +                # Avoid false positives by considering only leaks that appear per iteration. | 
|  | 228 | +                return | 
|  | 229 | + | 
|  | 230 | +            for frame in diff.traceback: | 
|  | 231 | +                if any( | 
|  | 232 | +                    frame.filename.endswith(fragment) | 
|  | 233 | +                    for fragment in [ | 
|  | 234 | +                        # to_raw_response_wrapper leaks through the @functools.wraps() decorator. | 
|  | 235 | +                        # | 
|  | 236 | +                        # removing the decorator fixes the leak for reasons we don't understand. | 
|  | 237 | +                        "orb/_response.py", | 
|  | 238 | +                        # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. | 
|  | 239 | +                        "orb/_compat.py", | 
|  | 240 | +                        # Standard library leaks we don't care about. | 
|  | 241 | +                        "/logging/__init__.py", | 
|  | 242 | +                    ] | 
|  | 243 | +                ): | 
|  | 244 | +                    return | 
|  | 245 | + | 
|  | 246 | +            leaks.append(diff) | 
|  | 247 | + | 
|  | 248 | +        leaks: list[tracemalloc.StatisticDiff] = [] | 
|  | 249 | +        for diff in snapshot_after.compare_to(snapshot_before, "traceback"): | 
|  | 250 | +            add_leak(leaks, diff) | 
|  | 251 | +        if leaks: | 
|  | 252 | +            for leak in leaks: | 
|  | 253 | +                print("MEMORY LEAK:", leak) | 
|  | 254 | +                for frame in leak.traceback: | 
|  | 255 | +                    print(frame) | 
|  | 256 | +            raise AssertionError() | 
|  | 257 | + | 
| 195 | 258 |     def test_request_timeout(self) -> None: | 
| 196 | 259 |         request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) | 
| 197 | 260 |         timeout = httpx.Timeout(**request.extensions["timeout"])  # type: ignore | 
| @@ -846,6 +909,67 @@ def test_copy_signature(self) -> None: | 
| 846 | 909 |             copy_param = copy_signature.parameters.get(name) | 
| 847 | 910 |             assert copy_param is not None, f"copy() signature is missing the {name} param" | 
| 848 | 911 | 
 | 
|  | 912 | +    def test_copy_build_request(self) -> None: | 
|  | 913 | +        options = FinalRequestOptions(method="get", url="/foo") | 
|  | 914 | + | 
|  | 915 | +        def build_request(options: FinalRequestOptions) -> None: | 
|  | 916 | +            client = self.client.copy() | 
|  | 917 | +            client._build_request(options) | 
|  | 918 | + | 
|  | 919 | +        # ensure that the machinery is warmed up before tracing starts. | 
|  | 920 | +        build_request(options) | 
|  | 921 | +        gc.collect() | 
|  | 922 | + | 
|  | 923 | +        tracemalloc.start(1000) | 
|  | 924 | + | 
|  | 925 | +        snapshot_before = tracemalloc.take_snapshot() | 
|  | 926 | + | 
|  | 927 | +        ITERATIONS = 10 | 
|  | 928 | +        for _ in range(ITERATIONS): | 
|  | 929 | +            build_request(options) | 
|  | 930 | +            gc.collect() | 
|  | 931 | + | 
|  | 932 | +        snapshot_after = tracemalloc.take_snapshot() | 
|  | 933 | + | 
|  | 934 | +        tracemalloc.stop() | 
|  | 935 | + | 
|  | 936 | +        def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None: | 
|  | 937 | +            if diff.count == 0: | 
|  | 938 | +                # Avoid false positives by considering only leaks (i.e. allocations that persist). | 
|  | 939 | +                return | 
|  | 940 | + | 
|  | 941 | +            if diff.count % ITERATIONS != 0: | 
|  | 942 | +                # Avoid false positives by considering only leaks that appear per iteration. | 
|  | 943 | +                return | 
|  | 944 | + | 
|  | 945 | +            for frame in diff.traceback: | 
|  | 946 | +                if any( | 
|  | 947 | +                    frame.filename.endswith(fragment) | 
|  | 948 | +                    for fragment in [ | 
|  | 949 | +                        # to_raw_response_wrapper leaks through the @functools.wraps() decorator. | 
|  | 950 | +                        # | 
|  | 951 | +                        # removing the decorator fixes the leak for reasons we don't understand. | 
|  | 952 | +                        "orb/_response.py", | 
|  | 953 | +                        # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason. | 
|  | 954 | +                        "orb/_compat.py", | 
|  | 955 | +                        # Standard library leaks we don't care about. | 
|  | 956 | +                        "/logging/__init__.py", | 
|  | 957 | +                    ] | 
|  | 958 | +                ): | 
|  | 959 | +                    return | 
|  | 960 | + | 
|  | 961 | +            leaks.append(diff) | 
|  | 962 | + | 
|  | 963 | +        leaks: list[tracemalloc.StatisticDiff] = [] | 
|  | 964 | +        for diff in snapshot_after.compare_to(snapshot_before, "traceback"): | 
|  | 965 | +            add_leak(leaks, diff) | 
|  | 966 | +        if leaks: | 
|  | 967 | +            for leak in leaks: | 
|  | 968 | +                print("MEMORY LEAK:", leak) | 
|  | 969 | +                for frame in leak.traceback: | 
|  | 970 | +                    print(frame) | 
|  | 971 | +            raise AssertionError() | 
|  | 972 | + | 
| 849 | 973 |     async def test_request_timeout(self) -> None: | 
| 850 | 974 |         request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) | 
| 851 | 975 |         timeout = httpx.Timeout(**request.extensions["timeout"])  # type: ignore | 
|  | 
0 commit comments