Skip to content

Linux: _MultiHandle crash when cancelling chatsStream mid-flight (FoundationNetworking/libcurl) #437

Description

@mbode

Summary

On Linux, chatsStream(query:) crashes with Signal 6 (SIGABRT) when the returned AsyncThrowingStream is cancelled mid-flight — i.e., before the SSE response has finished streaming — while concurrent URLSession requests are in flight. The crash is:

Object 0x7f33b00de390 of class _MultiHandle deallocated with non-zero retain count 2.
This object's deinit, or something called from it, may have created a strong
reference to self which outlived deinit, resulting in a dangling reference.
*** Signal 6: Program crashed: Aborted at 0x000003e80002a5f0

The crash is in libcurl's _MultiHandle deallocation, reached via CFRunLoopRunSpecific in the stack.

Environment

  • MacPaw/OpenAI: 0.5.0 (latest release; main has no changes to StreamingClient.swift or the delegate path beyond 0.5.0)
  • Swift: 6.3.3 (swift-6.3.3-RELEASE), x86_64-unknown-linux-gnu
  • OS: Amazon Linux 2023
  • libcurl: 8.17.0 (OpenSSL/3.5.5)
  • FoundationNetworking: swift-corelibs-foundation (libcurl-backed URLSession)

Root cause

StreamingClient consumes the SSE response via a delegate-based URLSessionDataTask that holds libcurl's _MultiHandle across runloop iterations (CFRunLoopRunSpecific appears in the crash stack). When the AsyncThrowingStream<ChatStreamResult, Error> returned by chatsStream(query:) is cancelled mid-flight while other URLSession requests are in flight, the _MultiHandle is deallocated with a non-zero retain count, aborting the process.

The crash requires both elements:

  1. Concurrent URLSession activity — a second URLSession (e.g. URLSession(configuration:)) issuing requests while the SSE stream is open. Without this, the crash does not reproduce (the cancellation path completes cleanly).
  2. Mid-flight cancellation of the chatsStream AsyncThrowingStream — letting the stream complete naturally does not crash, even with concurrent URLSession activity.

Reproduction

A minimal repro requires an OpenAI-compatible streaming endpoint, a concurrent URLSession request to a live HTTP server, and cancellation of the stream before it completes:

let openai = OpenAI(configuration: Configuration(token: "...", host: "..."))
let query = ChatQuery(
    messages: [.init(role: .user, content: "List numbers 1 through 50, one per line.")],
    model: "your-model",
    stream: true
)

// Start the SSE stream on URLSession.shared (the default).
let task = Task {
    let stream = openai.chatsStream(query: query)
    for try await chunk in stream { /* drain */ }
}

// Give the stream a moment to establish its connection.
try await Task.sleep(nanoseconds: 500_000_000)

// Fire concurrent URLSession(configuration:) requests to a live HTTP
// server while the SSE stream is open. Any localhost HTTP server
// works (here we assume one is running on port 8080).
await withTaskGroup(of: Void.self) { group in
    for _ in 0..<10 {
        group.addTask {
            let session = URLSession(configuration: URLSessionConfiguration.default)
            _ = try? await session.data(from: URL(string: "http://127.0.0.1:8080/")!)
        }
    }
}

// Cancel the SSE stream mid-flight while the concurrent requests
// are settling. This triggers the _MultiHandle crash.
task.cancel()
await task.value  // crashes with _MultiHandle deallocated, Signal 6 (~3/5 runs)

The crash reproduces ~3/5 runs on debug builds. Release builds narrow the crash window but do not eliminate it.

Note: A plain chatsStream + cancel without concurrent URLSession activity does not crash — the concurrent activity is load-bearing (it widens the timing window for the _MultiHandle deallocation race).

Stack trace (abbreviated)

Object 0x7f33b00de390 of class _MultiHandle deallocated with non-zero retain count 2.
*** Signal 6: Backtracing from 0x7f33bfa8d02c... done ***
*** Program crashed: Aborted at 0x000003e80002a5f0 ***
  0      0x00007f33bfa3ff1a  sigsuspend + 74 in libc.so.6
  1 [ra] 0x00007f33c1d4abc9  _dispatch_sig_thread + 8 in libdispatch.so
  ...
  0      0x00007f33bfb1017e  epoll_wait + 94 in libc.so.6
  1 [ra] 0x00007f33c1d49f92  _dispatch_mgr_invoke + 129 in libdispatch.so
  2 [ra] 0x00007f33c1d49efd  _dispatch_mgr_thread + 108 in libdispatch.so
  3 [ra] 0x00007f33c1d4d105  _dispatch_worker_thread + 628 in libdispatch.so

The CFRunLoopRunSpecific frame (from libFoundation.so) appears in the full trace — this is the libcurl runloop integration in FoundationNetworking's URLSession that the delegate-based URLSessionDataTask schedules on.

Workaround

We bypass the StreamingClient delegate path on Linux by wrapping OpenAI in a custom OpenAIAsync conformer that overrides chatsStream(query:) to drain the SSE response via URLSession.shared.bytes(for:) (a raw byte drain with no delegate, no StreamingClient). The raw byte-drain path cancels cleanly — no _MultiHandle corruption. Every other OpenAIAsync method delegates to the wrapped OpenAI unchanged. This is Linux-only (behind #if canImport(FoundationNetworking)); on Apple platforms URLSession is native and the delegate path works correctly.

Our red-capable feedback loop confirmed the diagnosis:

  • MacPaw/OpenAI delegate path + concurrent URLSession(configuration:) + mid-flight cancel → crashes ~3/5 runs.
  • Raw URLSession.shared.bytes(for:) drain + same concurrent URLSession(configuration:) + same mid-flight cancel → 5/5 green.
  • The only variable changed is the SSE transport — same endpoint, same request body, same concurrent activity, same cancellation timing.

Why filing this

  • The bug is in code that hasn't changed between 0.5.0 and main (StreamingClient.swift, the delegate-based URLSessionDataTask path).
  • Issue How to cancel closure openAI.chatsStream(query: query) #70 asked about cancellation of chatsStream but was about the API surface, not this crash.
  • No existing issue mentions the _MultiHandle crash or Linux libcurl cancellation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions