Skip to content

E2E flake: asyncio.InvalidStateError logged during gRPC teardown after lease timeout #835

Description

@mangelajo

Summary

The E2E test G2: lease timeout during slow beforeLease hook exits cleanly fails intermittently because an asyncio.exceptions.InvalidStateError is logged to stderr during gRPC channel teardown after a lease expires. The test assertion Expect(out).NotTo(ContainSubstring("Error:")) catches the substring InvalidStateError:.

Failed run: https://github.com/jumpstarter-dev/jumpstarter/actions/runs/28078498190/job/83127941530#step:10:1008

Log snippet

[06/24/26 06:25:57] INFO     [jumpstarter.client.lease] Acquiring lease
                             019ef84e-8cde-789f-8310-3f9579a30929 for selector
                             example.com/board=hooks for duration 0:00:05
[06/24/26 06:25:58] INFO     Lease 019ef84e-8cde-789f-8310-3f9579a30929 acquired
                             successfully! (0:00:00)
                    INFO     Waiting for ready connection at
                             /run/user/1001/jumpstarter-u2c12h_m/socket
[06/24/26 06:26:02] INFO     Lease 019ef84e-8cde-789f-8310-3f9579a30929 ended at
                             2026-06-24 06:26:02+00:00
                    INFO     Lease 019ef84e-8cde-789f-8310-3f9579a30929 ended at
                             2026-06-24 06:26:02.014601+00:00
[06/24/26 06:26:18] ERROR    [asyncio] Exception in callback
                             Future.set_result(None)
                             handle: <Handle Future.set_result(None)>
                             Traceback (most recent call last):
                               File
                             ".../lib/python3.12/asyncio/events.py", line 88, in _run
                                 self._context.run(self._callback, *self._args)
                             asyncio.exceptions.InvalidStateError: invalid state

Investigation

Scenario

  1. Exporter config exporter-hooks-slow-before.yaml has a beforeLease hook with sleep 8
  2. Client requests a lease with 5s duration
  3. Lease expires after 5s while the beforeLease hook is still running (~second 4 of 8)
  4. Client detects the lease ended and begins graceful teardown
  5. ~16 seconds after the lease ends, a gRPC internal callback fires Future.set_result(None) on a Future that's already been cancelled/completed
  6. Python's asyncio default exception handler logs ERROR [asyncio] ... asyncio.exceptions.InvalidStateError: invalid state

Root cause

This is a race condition in gRPC Python's async channel teardown. When the lease expires and the client exits, the gRPC aio.secure_channel is being closed. During teardown, an internal gRPC callback tries to resolve a Future that's already in a terminal state (cancelled or already resolved). This races with anyio's task group cancellation.

The application code already handles InvalidStateError in several places:

  • streams/router.py:60,66contextlib.suppress(asyncio.exceptions.InvalidStateError) in send_eof and aclose
  • streams/common.py:29 — catches asyncio.InvalidStateError in copy_stream

However, the error in this failure is not raised in any application code path. It fires as an asyncio event loop callback (_run in asyncio/events.py:88), which bypasses all try/except blocks and is logged directly by asyncio's default exception handler.

Fix

Install a scoped custom asyncio exception handler that suppresses InvalidStateError during gRPC teardown. The handler should be installed around the shell session lifecycle in _shell_with_signal_handling and should only suppress InvalidStateError, forwarding all other exceptions to the default handler.

Test file

e2e/test/hooks_test.go:371-380

It("G2: lease timeout during slow beforeLease hook exits cleanly", func() {
    startHooksExporter("exporter-hooks-slow-before.yaml")
    out, err := RunCmd("timeout", "60", "jmp", "shell",
        "--client", "test-client-hooks",
        "--selector", "example.com/board=hooks",
        "--duration", "5s", "--", "sleep", "30")
    _ = err
    Expect(out).NotTo(ContainSubstring("Error:"))
})

Activity

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

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