Skip to content

bug: Connection.__init__ ordering bug: _receive_timeout set after recv task is spawned (breaks under asyncio.eager_task_factory) #97

Description

@jjallaire

Summary

agent-client-protocol==0.10.0 introduced receive_timeout on Connection, but Connection.__init__ assigns self._receive_timeoutafter spawning the recv task. On event loops that have asyncio.eager_task_factory installed, _receive_loop runs synchronously inside create_task and tries to read self._receive_timeout before __init__ has set it → AttributeError, connection torn down.

  • Affected: 0.10.0
  • Last known good: 0.9.0 (feature didn't exist)
  • Environments observed: any consumer running on a loop with eager tasks, e.g. anything embedding textualtextual/app.py:2251-2252 calls loop.set_task_factory(asyncio.eager_task_factory). In our case that's inspect-ai's TUI.

Traceback

File ".../acp/connection.py", line 156, in _receive_loop
line = await asyncio.wait_for(self._reader.readline(), timeout=self._receive_timeout)
AttributeError: 'Connection' object has no attribute '_receive_timeout'
RuntimeWarning: coroutine 'StreamReader.readline' was never awaited

(The readline never awaited warning is a downstream symptom: Python evaluates wait_for's args first, so self._reader.readline() creates an orphaned coroutine before the attribute lookup fails.)

Root cause

acp/connection.py (0.10.0), abbreviated:

def__init__(self, ..., receive_timeout: float|None=None) ->None:
...
iflistening:
self._recv_task=self._tasks.create( # L90: spawn recv taskself._receive_loop(),
name="acp.Connection.receive",
on_error=self._on_receive_error,
)
...
self._dispatcher.start()
self._observers=list(observersor [])
self._receive_timeout=receive_timeout# L107: too late

TaskSupervisor.create (acp/task/supervisor.py:31-44) calls plain asyncio.create_task(coroutine, name=name). Under eager_task_factory (Python 3.12+), that runs the coroutine synchronously up to its first await. The first await in _receive_loop is:

line=awaitasyncio.wait_for(self._reader.readline(), timeout=self._receive_timeout)

Both arguments are evaluated before wait_for is called. The self._receive_timeout lookup fires before L107 has assigned it. Without the eager factory the bug is latent — __init__ returns first and _receive_timeout is set before the loop schedules the task.

Proposed fix

Set every attribute the receive loop reads before_tasks.create(self._receive_loop(), ...). One-line move:

def__init__(self, ..., receive_timeout: float|None=None) ->None:
self._handler=handler
...
self._receive_timeout=receive_timeout# <-- move upiflistening:
self._recv_task=self._tasks.create(
self._receive_loop(),
...
)

More general guideline worth applying across the class: any __init__ that spawns tasks should treat the spawn as a publication point — instance state read by those tasks has to be initialized before the spawn, because eager task factory turns the spawn into "run until first await right now."

Reproduction steps

importasynciofromacp.client.connectionimportClientSideConnection# ... or however you instantiate a Connection in testsasyncdefmain():
asyncio.get_running_loop().set_task_factory(asyncio.eager_task_factory)
# construct any Connection; AttributeError raises during init
...
asyncio.run(main())

Expected result

No crash

Actual result

Crash with:

File ".../acp/connection.py", line 156, in _receive_loop
line = await asyncio.wait_for(self._reader.readline(), timeout=self._receive_timeout)
AttributeError: 'Connection' object has no attribute '_receive_timeout'
RuntimeWarning: coroutine 'StreamReader.readline' was never awaited

Versions / environment

SDK version 0.10.0; ACP protocol 1; Python 3.12.10; macOS 26.3.1

Open to submitting a fix?

  • I’m willing to open a PR for this bug.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions