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
4 changes: 2 additions & 2 deletions src/py/extra/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from .http.model import (
from .http.model import ( # NOQA: F401
HTTPRequest,
HTTPResponse,
HTTPResponseLine,
HTTPRequestError,
) # NOQA: F401
)
from .decorators import on, expose, pre, post # NOQA: F401
from .server import run # NOQA: F401
from .model import Service # NOQA: F401
Expand Down
70 changes: 59 additions & 11 deletions src/py/extra/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,15 @@ def onException(
) -> None:
e = context.get("exception")
if e:
exception(e)
if isinstance(e, asyncio.InvalidStateError):
# Known race condition between wait_for timeout and sock_accept
# callback. Log as warning rather than exception to avoid alarm.
warning(
"AsyncIO InvalidStateError (likely accept race)",
Error=str(e),
Comment on lines +38 to +41
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The event-loop exception handler now downgrades all InvalidStateErrors to a warning without a stack trace or the loop-provided context message. This risks hiding real InvalidStateError bugs outside the accept loop and makes debugging harder; consider logging the context.get('message') and/or including stack=True for this warning (or otherwise scoping this suppression to the accept-loop path only).

Suggested change
# callback. Log as warning rather than exception to avoid alarm.
warning(
"AsyncIO InvalidStateError (likely accept race)",
Error=str(e),
# callback. Log as warning rather than exception to avoid alarm.
# Still include the loop context message and stack trace so that
# unexpected InvalidStateError bugs remain debuggable.
warning(
"AsyncIO InvalidStateError (likely accept race)",
Error=str(e),
Message=context.get("message"),
stack=True,

Copilot uses AI. Check for mistakes.
)
else:
exception(e)


class ServerOptions(NamedTuple):
Expand Down Expand Up @@ -392,13 +400,48 @@ async def Serve(
)

try:
# We use a persistent accept task instead of creating a new one each
# iteration via wait_for. This avoids the known race condition where
# wait_for cancels the sock_accept future but the internal _sock_accept
# callback still fires, causing InvalidStateError and leaking the
# accepted socket file descriptor.
accept_task: asyncio.Task | None = None
while state.isRunning:
if options.condition and not options.condition():
break
try:
res = await asyncio.wait_for(
loop.sock_accept(server), timeout=options.polling or 1.0
if accept_task is None:
accept_task = loop.create_task(
loop.sock_accept(server)
)

# asyncio.wait does NOT cancel the future on timeout,
# unlike asyncio.wait_for. The accept_task persists across
# timeout iterations until a connection actually arrives.
done, _ = await asyncio.wait(
{accept_task},
timeout=options.polling or 1.0,
Comment on lines +408 to +423
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

accept_task is annotated as asyncio.Task | None without a type parameter. With this repo’s mypy --strict (which enables disallow_any_generics), asyncio.Task must be parameterized (e.g., the result type of loop.sock_accept). Also, because it’s optional, asyncio.wait({accept_task}, ...) will likely require an assert accept_task is not None (or refactoring to keep accept_task non-optional) to satisfy type checking.

Copilot uses AI. Check for mistakes.
)

if accept_task not in done:
# Timeout, no connection yet — loop back and reuse
# the same accept_task.
continue

# Connection arrived (or error). Consume the result and
# clear accept_task so a new one is created next iteration.
try:
res = accept_task.result()
except OSError as e:
if e.errno == 24:
# Too many open files — backpressure
await asyncio.sleep(0.1)
else:
exception(e)
accept_task = None
continue

accept_task = None
if res is None:
continue
else:
Expand All @@ -409,17 +452,22 @@ async def Serve(
)
tasks.add(task)
task.add_done_callback(tasks.discard)
except asyncio.TimeoutError:
except asyncio.InvalidStateError:
# Defensive: should not happen with the new pattern,
# but guard against it to prevent server crash.
warning("InvalidStateError in accept loop (recovered)")
accept_task = None
continue
except OSError as e:
# This can be: [OSError] [Errno 24] Too many open files
if e.errno == 24:
# Implement backpressure or wait mechanism here
await asyncio.sleep(0.1) # Short delay before retrying
else:
exception(e)
except asyncio.CancelledError:
break

finally:
if accept_task and not accept_task.done():
accept_task.cancel()
try:
await accept_task
except (asyncio.CancelledError, Exception):
pass
Comment on lines +462 to +470
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catching asyncio.CancelledError and breaking will swallow task cancellation and cause Serve() to return normally instead of propagating cancellation to the caller. Prefer re-raising after triggering shutdown/cleanup (e.g., state.stop() then raise) so cancellation semantics remain correct.

Suggested change
break
finally:
if accept_task and not accept_task.done():
accept_task.cancel()
try:
await accept_task
except (asyncio.CancelledError, Exception):
pass
# Ensure that task cancellation propagates to the caller
# while still triggering a clean shutdown.
state.stop()
raise
finally:
if accept_task and not accept_task.done():
accept_task.cancel()
try:
await accept_task

Copilot uses AI. Check for mistakes.
server.close()
for task in tasks:
task.cancel()
Expand Down
Loading