Skip to content

Add 3.13t and 3.14t to CI#45

Open
clin1234 wants to merge 10 commits into
P403n1x87:mainfrom
clin1234:nogil
Open

Add 3.13t and 3.14t to CI#45
clin1234 wants to merge 10 commits into
P403n1x87:mainfrom
clin1234:nogil

Conversation

@clin1234

Copy link
Copy Markdown

Description of the Change

This added the free-threaded stable Python interpreters for CI (currently 3.13 and 3.14)

Alternate Designs

To be filled later

Regressions

To be filled later

Verification Process

To be filled later

@clin1234

Copy link
Copy Markdown
Author

And test logs:

============================= test session starts ==============================
platform linux -- Python 3.15.0a1, pytest-9.0.0, pluggy-1.6.0
rootdir: /workspaces/austin-python
configfile: pyproject.toml
plugins: asyncio-1.3.0, cov-7.0.0
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 56 items

test/format/test_compress.py ..                                          [  3%]
test/format/test_mojo.py ......                                          [ 14%]
test/format/test_pprof.py .                                              [ 16%]
test/format/test_speedscope.py ...                                       [ 21%]
test/stats/test_austin_file_reader.py .                                  [ 23%]
test/stats/test_austin_stats.py ....                                     [ 30%]
test/stats/test_hierarchical_stats.py ...                                [ 35%]
test/test_aio.py FFF...                                                  [ 46%]
test/test_cli.py ........                                                [ 60%]
test/test_config.py ..                                                   [ 64%]
test/test_semver.py ......                                               [ 75%]
test/test_simple.py F......                                              [ 87%]
test/test_threads.py FF...                                               [ 96%]
test/tools/test_diff.py s                                                [ 98%]
test/tools/test_resolve.py x                                             [100%]

=================================== FAILURES ===================================
_______________________________ test_async_time ________________________________

self = <test.test_aio.TestAsyncAustin object at 0x57acfe17810>

    async def on_terminate(self):
        data = self._meta
        assert "duration" in data
>       assert "errors" in data
E       AssertionError: assert 'errors' in {'austin': '4.0.0', 'count': '0', 'duration': '452', 'interval': '100', ...}

test/test_aio.py:62: AssertionError

The above exception was the direct cause of the following exception:

    @pytest.mark.asyncio
    async def test_async_time():
        austin = TestAsyncAustin()
    
        await austin.start(
            [
                "-Ci",
                "100",
                "python",
                "-c",
                "from time import sleep; sleep(2)",
            ]
        )
>       await asyncio.wait_for(austin.wait(), 30)

test/test_aio.py:90: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
/usr/lib/python3.15/asyncio/tasks.py:488: in wait_for
    return await fut
           ^^^^^^^^^
austin/aio.py:181: in wait
    await self._run_task
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <test.test_aio.TestAsyncAustin object at 0x57acfe17810>
mojo = <austin.format.mojo.AsyncMojoStreamReader object at 0x57acfe13190>

    async def _run(self, mojo: AsyncMojoStreamReader) -> None:
        # Start collecting samples
        self._state = AustinState.RUNNING
    
        async for e in mojo:
            if isinstance(e, AustinSample):
                try:
                    await t.cast(t.Awaitable[None], self._sample_callback(e))
                except Exception as exc:
                    raise AustinError("Error in call to sample callback") from exc
            elif isinstance(e, AustinMetadata):
                self._meta[e.name] = e.value
                if self._metadata_callback is not None:
                    try:
                        await t.cast(t.Awaitable[None], self._metadata_callback(e))
                    except Exception as exc:
                        raise AustinError("Error in call to metadata callback") from exc
    
        self._state = AustinState.TERMINATING
    
        # Call the terminate callback
        try:
            if self._terminate_callback is not None:
                await t.cast(t.Awaitable[None], self._terminate_callback())
        except Exception as exc:
>           raise AustinError("Error in call to terminate callback") from exc
E           austin.errors.AustinError: Error in call to terminate callback

austin/aio.py:109: AustinError
______________________________ test_async_memory _______________________________

self = <test.test_aio.TestAsyncAustin object at 0x57ad10b3ad0>

    async def on_terminate(self):
        data = self._meta
        assert "duration" in data
>       assert "errors" in data
E       AssertionError: assert 'errors' in {'austin': '4.0.0', 'count': '0', 'duration': '502', 'interval': '100', ...}

test/test_aio.py:62: AssertionError

The above exception was the direct cause of the following exception:

    @pytest.mark.asyncio
    async def test_async_memory():
        austin = TestAsyncAustin()
    
        async def sample_callback(data):
            austin._sample_received = True
    
        austin._sample_callback = sample_callback
        await austin.start(
            [
                "-mCi",
                "100",
                "python",
                "-c",
                "[i for i in range(10000000)]",
            ]
        )
>       await asyncio.wait_for(austin.wait(), 30)

test/test_aio.py:115: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
/usr/lib/python3.15/asyncio/tasks.py:488: in wait_for
    return await fut
           ^^^^^^^^^
austin/aio.py:181: in wait
    await self._run_task
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <test.test_aio.TestAsyncAustin object at 0x57ad10b3ad0>
mojo = <austin.format.mojo.AsyncMojoStreamReader object at 0x57ad10b4ed0>

    async def _run(self, mojo: AsyncMojoStreamReader) -> None:
        # Start collecting samples
        self._state = AustinState.RUNNING
    
        async for e in mojo:
            if isinstance(e, AustinSample):
                try:
                    await t.cast(t.Awaitable[None], self._sample_callback(e))
                except Exception as exc:
                    raise AustinError("Error in call to sample callback") from exc
            elif isinstance(e, AustinMetadata):
                self._meta[e.name] = e.value
                if self._metadata_callback is not None:
                    try:
                        await t.cast(t.Awaitable[None], self._metadata_callback(e))
                    except Exception as exc:
                        raise AustinError("Error in call to metadata callback") from exc
    
        self._state = AustinState.TERMINATING
    
        # Call the terminate callback
        try:
            if self._terminate_callback is not None:
                await t.cast(t.Awaitable[None], self._terminate_callback())
        except Exception as exc:
>           raise AustinError("Error in call to terminate callback") from exc
E           austin.errors.AustinError: Error in call to terminate callback

austin/aio.py:109: AustinError
_____________________________ test_async_terminate _____________________________

    @pytest.mark.skipif(
        sys.platform == "win32", reason="Signal handling not supported on Windows"
    )
    @pytest.mark.asyncio
    async def test_async_terminate():
        austin = TestAsyncAustin()
    
        async def sample_callback(sample):
            assert sample
            if not austin._sample_received:
                austin.terminate()
            austin._sample_received = True
    
        async def terminate_callback():
            austin._terminate = True
    
        austin._sample_callback = sample_callback
        austin._terminate_callback = terminate_callback
    
>       await austin.start(["-Ci", "10ms", "python"])

test/test_aio.py:139: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <test.test_aio.TestAsyncAustin object at 0x57ad10b6050>
args = ['-Ci', '10ms', 'python']

    async def start(self, args: t.Optional[t.Sequence[str]] = None) -> None:
        """Create the start coroutine.
    
        Use with the ``asyncio`` event loop.
        """
        self._args = AustinArgumentParser().parse_args(args)
    
        self._state = AustinState.STARTING
    
        try:
            _args = list(args if args is not None else sys.argv[1:])  # Make a copy
            _args.insert(0, "-P")
            self._proc = await asyncio.create_subprocess_exec(
                self.binary_path,
                *_args,
                stdin=asyncio.subprocess.PIPE,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
        except FileNotFoundError:
            raise AustinError("Austin executable not found.") from None
    
        if not self._proc.stdout:
            raise AustinError("Standard output stream is unexpectedly missing")
        if not self._proc.stderr:
            raise AustinError("Standard error stream is unexpectedly missing")
    
        mojo = AsyncMojoStreamReader(self._proc.stdout)
    
        # Retrieve the Austin version, then call the ready callback
        async for e in mojo:
            if isinstance(e, AustinMetadata):
                self._meta[e.name] = e.value
    
                try:
                    if self._metadata_callback is not None:
                        await t.cast(t.Awaitable[None], self._metadata_callback(e))
                except Exception as exc:
                    raise AustinError("Error in call to metadata callback") from exc
    
                if e.name == "austin":
                    self._check_version()
                    break
        else:
>           raise AustinError("Cannot determine Austin version from output")
E           austin.errors.AustinError: Cannot determine Austin version from output

austin/aio.py:157: AustinError
_________________________________ test_simple __________________________________

self = <test.test_simple.TestSimpleAustin object at 0x57acfe1e410>

    def on_terminate(self):
        data = self._meta
        assert "duration" in data
>       assert "errors" in data
E       AssertionError: assert 'errors' in {'austin': '4.0.0', 'count': '0', 'duration': '465', 'interval': '1000', ...}

test/test_simple.py:57: AssertionError

The above exception was the direct cause of the following exception:

    def test_simple():
        austin = TestSimpleAustin()
    
        austin.start(["-Ci", "1000", "python", "-c", "from time import sleep; sleep(1)"])
>       austin.wait()

test/test_simple.py:74: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <test.test_simple.TestSimpleAustin object at 0x57acfe1e410>

    def wait(self) -> int:
        if self._proc is None:
            raise AustinError("Austin process is not running")
    
        self._state = AustinState.RUNNING
    
        assert self._mojo is not None
    
        for e in self._mojo:
            if isinstance(e, AustinSample):
                try:
                    self._sample_callback(e)
                except Exception as exc:
                    raise AustinError("Error in call to sample callback") from exc
            elif isinstance(e, AustinMetadata):
                self._meta[e.name] = e.value
                if self._metadata_callback is not None:
                    try:
                        self._metadata_callback(e)
                    except Exception as exc:
                        raise AustinError("Error in call to metadata callback") from exc
    
        self._state = AustinState.TERMINATING
    
        # Call the terminate callback
        try:
            if self._terminate_callback is not None:
                self._terminate_callback()
        except Exception as exc:
>           raise AustinError("Error in call to terminate callback") from exc
E           austin.errors.AustinError: Error in call to terminate callback

austin/simple.py:152: AustinError
________________________________ test_threaded _________________________________

    def test_threaded():
        austin = TestThreadedAustin()
    
        austin.start(["-i", "1000", "python", "-c", "from time import sleep; sleep(2)"])
>       assert austin.wait() == 0
               ^^^^^^^^^^^^^

test/test_threads.py:70: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
austin/threads.py:103: in wait
    self.join()
austin/threads.py:96: in join
    raise self._exc
austin/threads.py:74: in _thread_bootstrap
    super().start(args)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <test.test_threads.TestThreadedAustin object at 0x57acfe1e110>
args = ['-i', '1000', 'python', '-c', 'from time import sleep; sleep(2)']

    def start(self, args: t.Optional[t.Sequence[str]] = None) -> None:
        """Start the Austin process."""
        self._args = AustinArgumentParser().parse_args(args)
    
        self._state = AustinState.STARTING
    
        try:
            self._proc = subprocess.Popen(
                [
                    str(self.binary_path),
                    "-P",
                    *(args if args is not None else sys.argv[1:]),
                ],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
            )
        except FileNotFoundError:
            raise AustinError("Austin executable not found.") from None
    
        if not self._proc.stdout:
            raise AustinError("Standard output stream is unexpectedly missing")
        if not self._proc.stderr:
            raise AustinError("Standard error stream is unexpectedly missing")
    
        self._mojo = MojoStreamReader(self._proc.stdout)
    
        # Retrieve the Austin version, then call the ready callback
        for e in self._mojo:
            if isinstance(e, AustinMetadata):
                self._meta[e.name] = e.value
    
                try:
                    if self._metadata_callback is not None:
                        self._metadata_callback(e)
                except Exception as exc:
                    raise AustinError("Error in call to metadata callback") from exc
    
                if e.name == "austin":
                    self._check_version()
                    break
        else:
>           raise AustinError("Cannot determine Austin version from output")
E           austin.errors.AustinError: Cannot determine Austin version from output

austin/simple.py:114: AustinError
___________________________ test_threaded_terminate ____________________________

    @pytest.mark.skipif(
        sys.platform == "win32", reason="Signal handling not supported on Windows"
    )
    def test_threaded_terminate():
        austin = TestThreadedAustin()
    
        def sample_callback(*args):
            if not austin._sample_received:
                austin.terminate()
            austin._sample_received = True
    
        def terminate_callback(*args):
            austin._terminate = True
    
        austin._sample_callback = sample_callback
        austin._terminate_callback = terminate_callback
    
        austin.start(["-i", "100", "python", "-c", "from time import sleep; sleep(1)"])
    
>       assert austin.wait() != 0
               ^^^^^^^^^^^^^

test/test_threads.py:97: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
austin/threads.py:103: in wait
    self.join()
austin/threads.py:96: in join
    raise self._exc
austin/threads.py:74: in _thread_bootstrap
    super().start(args)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <test.test_threads.TestThreadedAustin object at 0x57ad10b94d0>
args = ['-i', '100', 'python', '-c', 'from time import sleep; sleep(1)']

    def start(self, args: t.Optional[t.Sequence[str]] = None) -> None:
        """Start the Austin process."""
        self._args = AustinArgumentParser().parse_args(args)
    
        self._state = AustinState.STARTING
    
        try:
            self._proc = subprocess.Popen(
                [
                    str(self.binary_path),
                    "-P",
                    *(args if args is not None else sys.argv[1:]),
                ],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
            )
        except FileNotFoundError:
            raise AustinError("Austin executable not found.") from None
    
        if not self._proc.stdout:
            raise AustinError("Standard output stream is unexpectedly missing")
        if not self._proc.stderr:
            raise AustinError("Standard error stream is unexpectedly missing")
    
        self._mojo = MojoStreamReader(self._proc.stdout)
    
        # Retrieve the Austin version, then call the ready callback
        for e in self._mojo:
            if isinstance(e, AustinMetadata):
                self._meta[e.name] = e.value
    
                try:
                    if self._metadata_callback is not None:
                        self._metadata_callback(e)
                except Exception as exc:
                    raise AustinError("Error in call to metadata callback") from exc
    
                if e.name == "austin":
                    self._check_version()
                    break
        else:
>           raise AustinError("Cannot determine Austin version from output")
E           austin.errors.AustinError: Cannot determine Austin version from output

austin/simple.py:114: AustinError
=============================== warnings summary ===============================
venv/lib/python3.15t/site-packages/google/protobuf/internal/well_known_types.py:91
  /workspaces/austin-python/venv/lib/python3.15t/site-packages/google/protobuf/internal/well_known_types.py:91: DeprecationWarning: datetime.datetime.utcfromtimestamp() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.fromtimestamp(timestamp, datetime.UTC).
    _EPOCH_DATETIME_NAIVE = datetime.datetime.utcfromtimestamp(0)

test/format/test_mojo.py: 36 warnings
  /workspaces/austin-python/austin/format/mojo.py:97: DeprecationWarning: '_UnionGenericAlias' is deprecated and slated for removal in Python 3.17
    if isinstance(f.type, t._UnionGenericAlias)

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.15.0-alpha-1 ________________

Name                                 Stmts   Miss Branch BrPart  Cover   Missing
--------------------------------------------------------------------------------
austin/__init__.py                       0      0      0      0   100%
austin/aio.py                           76     19     26      9    71%   90-93, 94->88, 96->88, 99-100, 111, 136, 138, 144->143, 148->153, 150-151, 167-173, 177, 184
austin/base.py                          94     14     18      3    85%   52, 71-72, 151, 155, 178, 180, 224-225, 230-233, 238
austin/cli.py                           79      1     46     12    90%   101, 116->124, 124->133, 133->141, 141->149, 149->154, 154->162, 162->167, 167->176, 176->exit, 225->227, 235->238
austin/config.py                        27      5      0      0    81%   59-63
austin/errors.py                         2      0      0      0   100%
austin/events.py                        35      1      0      0    97%   102
austin/format/__init__.py                9      0      0      0   100%
austin/format/collapsed_stack.py       119     24     30      9    78%   21-22, 29-30, 45, 49-50, 66, 69, 75-76, 79, 85, 131, 156, 177-179, 192-193, 195, 234-236, 240
austin/format/compress.py               39     14      6      1    67%   77-117, 121
austin/format/mojo.py                  510     80    100     12    83%   108-109, 282-285, 458, 470, 474, 482-485, 489-492, 502, 506-507, 574, 587, 597, 602, 612, 626-632, 642, 675-687, 704->703, 721-722, 731-743, 750, 755, 758, 763, 768, 773, 778, 783, 788, 795, 809-815, 825, 842-843, 850-852, 854, 878, 879->exit, 943, 946-951, 957, 968-969
austin/format/pprof/__init__.py        110     23     24      8    74%   66->69, 70, 73, 78-81, 92-93, 153, 157-164, 166, 169-173
austin/format/pprof/__main__.py         31     31     10      0     0%   24-84
austin/format/pprof/profile_pb2.py      48      0      0      0   100%
austin/format/speedscope.py            119     32     32      4    68%   149, 153-156, 157->168, 199-250, 254
austin/simple.py                        70     17     26     10    70%   93, 95, 101->100, 105->110, 107-108, 110->100, 118-121, 125, 133-136, 137->131, 139->131, 142-143, 154-157
austin/stats.py                        130     13     40      9    85%   102, 106, 123->131, 150->159, 159->exit, 196, 225, 232->231, 236, 239, 248, 273-286, 289
austin/threads.py                       33      7      8      3    71%   75, 93, 95->exit, 101, 105-110
austin/tools/diff.py                    73     58     20      1    17%   44-52, 63-72, 87-126, 136-154, 158
austin/tools/mojodbg.py                 44     34      8      1    21%   37-60, 64-81, 85
austin/tools/resolve.py                130     92     56      1    21%   39-63, 77-103, 106-114, 117-141, 144-157, 169-193, 220-221, 228
--------------------------------------------------------------------------------
TOTAL                                 1778    465    450     83    70%
Coverage XML written to file coverage.xml
=========================== short test summary info ============================
FAILED test/test_aio.py::test_async_time - austin.errors.AustinError: Error i...
FAILED test/test_aio.py::test_async_memory - austin.errors.AustinError: Error...
FAILED test/test_aio.py::test_async_terminate - austin.errors.AustinError: Ca...
FAILED test/test_simple.py::test_simple - austin.errors.AustinError: Error in...
FAILED test/test_threads.py::test_threaded - austin.errors.AustinError: Canno...
FAILED test/test_threads.py::test_threaded_terminate - austin.errors.AustinEr...
======= 6 failed, 48 passed, 1 skipped, 1 xfailed, 37 warnings in 15.29s =======

@P403n1x87

Copy link
Copy Markdown
Owner

@clin1234 thanks for this, but note that Austin itself does not support free-threaded mode yet, so there wouldn't be much value in adding this feature here just now.

@P403n1x87

Copy link
Copy Markdown
Owner

The tests pull Austin from the devel branch, where support for FT was added recently. So once the CI config is in place the tests should pass 🤞

@clin1234 clin1234 marked this pull request as ready for review June 1, 2026 02:17
@clin1234

clin1234 commented Jun 2, 2026

Copy link
Copy Markdown
Author

This should give austin enough time to catch at least one sample,
just in case the sysconfig.get_config_var("Py_GIL_DISABLED")
workaround is rejected.
@clin1234

clin1234 commented Jun 2, 2026

Copy link
Copy Markdown
Author

Also, an interesting document generated with Claude Code and https://github.com/devdanzin/ft-review-toolkit:

  Free-Threading Analysis Report

  Extension: austin-python (pure Python library, 11 source files, ~1800 LOC)

  Migration Status

  Status: Active — 6 free-threading commits over 7 months, most recent today
  (2026-06-02).
  Architecture: No C extension code. The entire ft migration is a CI +
  test-compatibility story: making the test suite pass under CPython 3.14t.
  _Py_atomic_*, Py critical sections, and _PyEval_StopTheWorld are all out of scope.

  Recent commits on nogil branch:
  ee6829f  Bump sample range within test_async_memory()
  77ed89f  Don't assert memory sampler has at least one frame in no-GIL builds
  9b405e8  Don't check if there error, sampling, and saturation data on no-GIL builds
  49d21dc  Update pyproject.toml  ← where the CI failure was captured

  ---
  Executive Summary

  - Readiness: Moderate
  - RACE findings: 3 — data races in production code, real bugs under GIL and
  free-threading
  - UNSAFE findings: 2 — cross-thread access patterns without explicit happens-before
  - PROTECT findings: 2 — minor synchronization gaps
  - MIGRATE findings: 3 — test correctness issues introduced by today's fix attempts

  ---
  Findings by Priority

  RACE Findings (fix immediately) — 3

  #: 1
  Finding: AustinStats.flatten(), get_process(), __deepcopy__() iterate/read
    self.processes without holding self._lock; update() holds it. flatten() crashes
  with
     RuntimeError: dictionary changed size during iteration under the GIL today, and is

    a structural data race under free-threading.
  File:Line: stats.py:215-223, 225-227, 318-320
  Severity: HIGH
  Agents: all five agents
  ────────────────────────────────────────
  #: 2
  Finding: BaseMojoStreamReader.__handlers__ check-then-act lazy class-attribute init:
    two threads constructing the first instance of a subclass both see None and race on

    the class attribute write.
  File:Line: format/mojo.py:310-318
  Severity: HIGH
  Agents: shared-state, lock-discipline, atomic, unsafe-api
  ────────────────────────────────────────
  #: 3
  Finding: AustinConfiguration.__borg__ Borg shared dict: every __init__ calls reload()

    which writes self._data into the shared class dict with no lock. Two concurrent
    binary_path cache misses each construct AustinConfiguration() twice, producing four

    concurrent __borg__ mutations.
  File:Line: config.py:43-55
  Severity: HIGH
  Agents: all five agents

  UNSAFE Findings (fix before declaring free-threading support) — 2

  #: 4
  Finding: ThreadedAustin bootstrap thread writes _proc, _state, _meta (inherited from
    SimpleAustin/BaseAustin); main thread reads via is_running(), terminate(), version.

    No lock, no threading.Event, no happens-before edge. Under free-threading the
  writes
     are not guaranteed visible to the reader.
  File:Line: threads.py:69-81, simple.py:77-157, base.py:133-237
  Severity: HIGH
  Agents: shared-state, atomic, lock-discipline, STW
  ────────────────────────────────────────
  #: 5
  Finding: ThreadedAustin.join(timeout) reads self._exc immediately after
    Thread.join(timeout). If the join timed out the bootstrap thread is still running
    and _exc has no happens-before guarantee. The exception is silently swallowed.
  File:Line: threads.py:83-96
  Severity: MEDIUM
  Agents: lock-discipline, unsafe-api

  PROTECT Findings (add synchronization) — 2

  #: 6
  Finding: functools.cached_property on binary_path, version, python_version — CPython
    3.12+ dropped the internal lock; double-compute possible on concurrent first
  access.
     Benign for version/python_version (pure reads). For binary_path it amplifies
    Finding 3 (double AustinConfiguration() invocation).
  File:Line: base.py:185-232
  Severity: LOW
  Agents: shared-state, atomic, unsafe-api
  ────────────────────────────────────────
  #: 7
  Finding: COLLAPSED_STACK_FORMATTER module-level AustinEventCollapsedStackFormatter
    instance — .mode is written on every AustinMetadata event; unsafe if shared across
    threads. Mitigated: no production code imports it, but the public name invites
    future sharing.
  File:Line: stats.py:48
  Severity: LOW
  Agents: shared-state

  MIGRATE Findings (test correctness) — 3

  #: 8
  Finding: is False identity check always evaluates to False. Commits 9b405e8 and
    77ed89f (today) introduced guards if  sysconfig.get_config_var("Py_GIL_DISABLED")
  is
     False: to skip assertions on free-threaded builds. sysconfig.get_config_var
  returns
     0 (int) on GIL builds, not the singleton False. 0 is False → False. The block is
    never entered on any build. On GIL builds the assertions for "errors", "sampling",
    "saturation", and _sample_received are silently disabled. The test passes
  everywhere
     but tests nothing.
  File:Line: test/test_aio.py:63, 71
  Severity: HIGH
  Agents: ft-history-analyzer
  ────────────────────────────────────────
  #: 9
  Finding: 3.13t silently removed from CI matrix in commit e90a92b with no explanation.
  File:Line: .github/workflows/tests.yml
  Severity: MEDIUM
  Agents: ft-history-analyzer
  ────────────────────────────────────────
  #: 10
  Finding: range(10000000) bumped to range(100000000) in test_async_memory to give
    Austin time to capture samples on faster free-threaded builds. Makes the test 10×
    slower on all builds regardless of GIL status.
  File:Line: test/test_aio.py:115
  Severity: LOW
  Agents: ft-history-analyzer

  ---
  SAFE Patterns (confirmed safe)

  - All @dataclass(frozen=True) classes in events.py (AustinFrame, AustinMetrics,
  AustinSample, AustinMetadata)
  - EMPTY and UNKNOWN frozen constants in mojo.py
  - All Enum classes (AustinState, AustinStatsType, MojoEvents)
  - Per-instance state of MojoStreamReader/AsyncMojoStreamReader — each reader instance
  is owned exclusively by one thread
  - ThreadedAustin.wait() → untimed Thread.join() → read _exc path (happens-before
  guarantee from join())
  - AsyncAustin — single-threaded asyncio event loop, no cross-thread state sharing

  ---
  Recommendations

  Immediate (Findings 1, 2, 3, 8 — bugs active today)

  1. Fix AustinStats reader lock coverage (stats.py)
  def get_process(self, pid):
      with self._lock:
          return self.processes[pid]

  def flatten(self):
      with self._lock:
          snapshot = list(self.processes.values())
      for process in snapshot:
          yield from process.collapse(self.stats_type)

  def __deepcopy__(self, memo=None):
      with self._lock:
          state = {k: v for k, v in self.__dict__.items() if k != "_lock"}
          state_copy = deepcopy(state)
      copy = type(self)(self.stats_type)
      copy.__dict__.update(state_copy)
      return copy
  Note: hold the lock for the snapshot only, not across yield — holding across yield
  would deadlock if the consumer calls update() between yields.

  2. Eliminate __handlers__ lazy-init race (format/mojo.py)
  class BaseMojoStreamReader(AustinEventIterator):
      __handlers__: Dict[int, Callable[[], None]] = {}

      def __init_subclass__(cls, **kwargs):
          super().__init_subclass__(**kwargs)
          cls.__handlers__ = {
              f.__event__: f
              for f in cls.__dict__.values()
              if hasattr(f, "__event__")
          }
      # Remove the if self.__handlers__ is None: block from __init__

  3. Add a class-level lock to AustinConfiguration (config.py)
  _borg_lock: ClassVar[Lock] = Lock()

  def reload(self) -> None:
      try:
          with open(self.RC) as fin:
              data = toml.load(fin)
      except FileNotFoundError:
          data = {}
      with AustinConfiguration._borg_lock:
          self._data = data

  8. Fix the is False guard — the one-character fix (test/test_aio.py:63, 71)
  # Wrong — always False:
  if sysconfig.get_config_var("Py_GIL_DISABLED") is False:

  # Correct — True on GIL builds (0 is falsy), False on no-GIL builds (1 is truthy):
  if not sysconfig.get_config_var("Py_GIL_DISABLED"):
  This single change restores test coverage on all standard (GIL) builds while
  preserving the no-GIL skip.

  Short-term (Findings 4, 5 — requires ThreadedAustin refactor)

  4. Add a threading.Event for subprocess readiness in ThreadedAustin — set it after
  self._proc is assigned in the bootstrap thread; terminate() waits on it before
  reading _proc. Use a threading.Lock to gate _state transitions so is_running() reads
  are visible across threads.

  5. Guard join(timeout) against reading _exc while bootstrap thread is still alive —
  check not self._thread.is_alive() before raising self._exc.

  Longer-term (Findings 6, 7, 9, 10)

  6. Document binary_path idempotency invariant; accept double-compute or replace with
  a locked once-init if AustinConfiguration Borg race is fixed first.

  7. Rename COLLAPSED_STACK_FORMATTER to _COLLAPSED_STACK_FORMATTER or remove it — no
  production code imports it.

  9. Document why 3.13t was dropped from CI, or restore it with a comment explaining
  any Austin binary compatibility constraints.

  10. After fixing Finding 8, verify that range(100000000) at 100ms sampling interval
  reliably produces at least one sample on all CI platforms; if not, consider
  @pytest.mark.skipif(sysconfig.get_config_var("Py_GIL_DISABLED"), ...) on the
  _sample_received assertion instead of inflating the workload universally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants