Skip to content

[Bug]: Server freezes for 5 minutes when a forked ssh crashes in gRPC, killing the server and leaking Nebius instances #4362

Description

@peterschmidt85

Steps to reproduce

  1. Run dstack server with a Nebius backend on a host where core dumps go to systemd-coredump, such as the ECS-optimized Amazon Linux 2023 AMI. Enable OpenTelemetry: set DSTACK_OTEL_TRACES_ENABLED, DSTACK_OTEL_LOGS_ENABLED, DSTACK_OTEL_METRICS_ENABLED and OTEL_EXPORTER_OTLP_ENDPOINT.
  2. Run a service with several replicas and an HTTP probe, for example replicas: 7 and probes: [{type: http, url: /, interval: 15s}]. For every probe, the server opens an SSH tunnel to the replica and closes it again.
  3. Keep the server calling the Nebius API. For example, apply a Nebius fleet and a service with several replicas on it that keeps retrying when there is no capacity.
  4. Poll /healthcheck.

We ran these steps with dstack 0.22.3 as released: 2 servers on ECS (t3.xlarge, Amazon Linux 2023) behind a load balancer health check, Postgres, the probed service on an SSH fleet, and a fake Nebius API.

Actual behaviour

Within minutes the server stops logging and stops answering /healthcheck. In our test, every server froze within 4 minutes of starting. At the same moment, the host kernel logs a crash in the copy that the server had just forked to run ssh, and systemd-coredump starts on it:

kernel: event_engine[77937]: segfault at 7f8547ffe370 ... in cygrpc.cpython-311-x86_64-linux-gnu.so
systemd[1]: Started systemd-coredump@1-77939-0.service - Process Core Dump

While the server is frozen:

  • its main thread is blocked in read(), waiting for the copy;
  • the copy, still a full copy of the server, is in do_exit while its core is written;
  • in our production image, py-spy showed the main thread in create_subprocess_exec ← SSHTunnel.aclose ← get_service_replica_tunnel ← _execute_probe, a probe closing its tunnel.

If nothing kills it first, the server resumes after 300 s, when systemd-coredump hits its 5-minute timeout. In our production deployment, resumed servers then logged Failed to close SSH tunnel, exit status: -11 (SIGSEGV) or -6 (SIGABRT). Behind a load balancer health check, the server is killed and replaced before that. Nebius instances it was creating at that moment keep running, but dstack never records them.

In our production deployment, the server froze 45 times between Sep 8 and Oct 9, 2026. 44 of those freezes match a crashed copy's core dump in the host's records. 3 happened while Nebius instances were being created and left 5 H200 instances running untracked, for up to 5 days.

Expected behaviour

The server keeps answering /healthcheck and records every instance it creates.

dstack version

0.22.3 (grpcio 1.84.0, uvloop 0.23.0, nebius 0.6.20, Python 3.11). It also reproduces with grpcio 1.83.0, uvloop 0.22.1 and nebius 0.6.17, the versions in our production image.

Notes

Root cause

  1. dstack server doesn't set uvicorn's event loop, so uvicorn uses uvloop. uvloop starts every subprocess with fork() and holds Python's global lock until the copy calls exec, so the whole server waits for each copy.

  2. In the copy, two things run before exec:

    • gRPC (used by the Nebius SDK) restarts its threads;
    • OpenTelemetry's after-fork handlers start new worker threads.

    Together they can crash the copy (a segfault or abort in cygrpc) before it reaches exec.

  3. With systemd-coredump, the crashed copy stays alive until its core is written or the 5-minute timeout hits. The server waits all that time, the health check kills it, and any instance it was creating leaks.

What each condition does, from runs of the steps above:

  • Event loop: with the standard asyncio loop instead of uvloop, nothing crashes.
  • OpenTelemetry: with it off, there were 0 freezes in about 4,500 forks made while gRPC was in use. Enabling only Sentry doesn't trigger it.
  • Library versions: both version sets listed under "dstack version" freeze.

In our deployment, OpenTelemetry has been on since Jul 20, and services are probed all the time. Freezes were rare, 2 between Jul 20 and Sep 27, until Sep 28, when a user's services began creating H200 instances on Nebius. Forks made while gRPC was in use went from 2–330 a day to about 1,600. On Oct 1, an upgrade of the Nebius SDK from 0.3.101 to 0.6.17 kept gRPC active between calls, and that rose to 3,400–5,200 per server per hour. A replacement server often froze again within minutes of starting.

Suggested fix

Run the server on the standard asyncio event loop. It starts subprocesses with vfork(), which runs no after-fork handlers in the child, so neither gRPC nor OpenTelemetry runs in the copy.

  • In server.py, pass loop="asyncio" to uvicorn.run.
  • Wherever the server is started with the uvicorn CLI, add --loop asyncio.

Tested

On the setup above, with OpenTelemetry on:

Build Result
dstack 0.22.3 as released 2 freezes, 0.4 and 3.6 min after a server started
dstack 0.22.3 with our production library versions 2 freezes, 0.3 and 1.6 min after a server started
dstack 0.22.3 with loop="asyncio" 0 crashes and 0 freezes in 58 minutes
our production image 3 freezes in 15 minutes
our production image with --loop asyncio 0 crashes and 0 freezes in 65 minutes

Activity

  1. changed the title [-][Bug]: Server freezes and gets killed while creating Nebius instances, leaving them untracked[/-] [+][Bug]: Server freezes if it starts `ssh` while creating Nebius instances, leaving the instances untracked[/+] on Oct 9, 2026
  2. changed the title [-][Bug]: Server freezes if it starts `ssh` while creating Nebius instances, leaving the instances untracked[/-] [+][Bug]: Server freezes if it starts `ssh` while the Nebius SDK's gRPC is in use, leaving instances it was creating untracked[/+] on Oct 9, 2026
  3. changed the title [-][Bug]: Server freezes if it starts `ssh` while the Nebius SDK's gRPC is in use, leaving instances it was creating untracked[/-] [+][Bug]: Server freezes for 5 minutes when a forked `ssh` crashes in gRPC, killing the server and leaking Nebius instances[/+] on Oct 9, 2026
  4. r4victor commented on Oct 12, 2026

    @r4victor
    Collaborator

    My read is that the root is a combination of the following factors:

    • uvloop always using fork() for asyncio.create_subprocess_exec() on Linux.
    • gRPC not supporting processes that fork().
    • dstack calling asyncio.create_subprocess_exec() and thus forking frequently for opening SSH tunnels.

    In general, "safely forking a multithreaded process is problematic". From Posix on fork():

    A process shall be created with a single thread. If a multi-threaded process calls fork(), the new process shall contain a replica of the calling thread and its entire address space, possibly including the states of mutexes and other resources. Consequently, to avoid errors, the child process may only execute async-signal-safe operations until such time as one of the exec functions is called.

    gRPC registers pthread_atfork handlers to restart and fix background threads in the child. This is buggy and may result in a crash. asyncio unlike uvloop uses vfork() instead of fork() that does not trigger any at fork handlers from gRPC or OTel.

    Our options:

    1. Move to asyncio event loop as done in Run dstack server on the asyncio event loop #4363. This is undesirable since uvloop is still times more performant than asyncio. asyncio can be sufficient for dstack server but gateway would need to be able to handle more RPS, and with OTel integration and other dependencies we'll have gRPC there so we need a solution that allows for both uvloop and gRPC to co-exist.
    2. Replace asyncio.create_subprocess_exec() with subprocess.Popen() called in a thread pool – subprocess.Popen() moved to vfork() since Python 3.10.
    3. libuv now uses posix_spawn/vfork instead of fork on all platforms, released in libuv 1.53.0 (2026-09-24) (unix: use posix_spawn instead of fork libuv/libuv#3520). uvloop is updating libuv and expected to move to posix_spawn soon as well (Upgrade libuv to v1.53.0 MagicStack/uvloop#775). We need to wait a bit.
    4. Try setting GRPC_EXPERIMENTS=-event_engine_fork – it disables some of the recently enabled thread-restoring-after-fork logic in grpc that may be the trigger for the crashes. Even better, we can set GRPC_ENABLE_FORK_SUPPORT=false since dstack forks only for exec().

    I'm inclined to do 4 for now if it works (2 if 4 does not work), and 3 when the new uvloop is released.

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 workingsevere

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions