Skip to content

outbound-pg: a query returning zero rows deadlocks the host (regression in 4.0.0) #3671

Description

@shibukawa

Environment

  • Spin version (spin --version): spin 4.0.2 (bfc7543 2026-06-23)
  • Installed plugins (spin plugins list --installed): none
  • OS: macOS 26.0, aarch64 (Apple silicon); Spin installed from nixpkgs (fermyon-spin 4.0.2)
  • PostgreSQL 17.11, reached over TCP with allowed_outbound_hosts = ["postgres://127.0.0.1:5433"]

This is not a build or setup problem, so the prerequisites and troubleshooting pages do not apply:
the app builds and runs, and the fault is a deadlock inside factor-outbound-pg that I have traced
to specific lines below.

Summary

Since 4.0.0, the synchronous query of spin:postgres/postgres never returns when the statement
matches no rows. The guest blocks inside the host call indefinitely: no CPU is consumed, the
PostgreSQL backend goes idle having completed the query, and Spin logs nothing. The HTTP request
never receives a response.

A synchronous host call cannot be abandoned from the guest, so a component cannot time this out or
recover from it — the instance is stuck for the life of the request.

This makes "no row matched" unusable, which is ordinary control flow for most applications
(sql.ErrNoRows in Go, Option::None in Rust, and so on).

Reproduction

Any component running a SELECT that matches no rows, for example SELECT 1 WHERE false.

Observed:

  • a query returning one or more rows: fine, values and column metadata both correct
  • the same query with a predicate matching nothing: hangs forever
  • execute is unaffected

I hit this from a TinyGo wasip2 component driving spin:postgres/postgres@4.2.0 directly, but
the mechanism is entirely inside the host factor and is independent of the guest language or SDK.

Root cause

query_stream publishes the column metadata over a oneshot channel, sent from inside the row
stream and only when index == 0:

https://github.com/spinframework/spin/blob/v4.0.2/crates/factor-outbound-pg/src/client.rs#L348-L364

query drains the rows and then awaits that channel unconditionally:

https://github.com/spinframework/spin/blob/v4.0.2/crates/factor-outbound-pg/src/client.rs#L245-L260

With zero rows the send never happens. The receiver would normally resolve with Err(RecvError)
once the sender is dropped — which is what cols_rx.map(|result| result.unwrap_or_default()) is
written for — but the sender lives in cols_tx_opt, owned by the row-stream closure, and query
holds results alive until it returns. Sender and receiver stay alive together, so
cols_fut.await never completes.

query_async is not affected: it moves the stream into a tokio::spawned task, so the sender is
dropped when the stream ends and the receiver resolves to the default.

Regression range

3.6.3 does not have this code. Columns were inferred inline while draining, so a zero-row result
returned normally with columns.unwrap_or_default():

https://github.com/spinframework/spin/blob/v3.6.3/crates/factor-outbound-pg/src/client.rs#L188-L212

The query_stream plus oneshot shape appears in 4.0.0, is present in 4.0.2, and is unchanged on
main today (same lines at client.rs#L259 and client.rs#L359).

Secondary issue: no column metadata for an empty result

Even with the deadlock fixed, a zero-row result will carry no columns, because they are inferred
from the first row (infer_columns). A database/sql-style driver then cannot report column
names for an empty result set. tokio_postgres::Statement::columns() on a prepared statement
provides them without needing any row.

Suggested fix

Minimal: ensure the sender is dropped before the await — drop results once the drain loop ends
rather than holding it across cols_fut.await.

Better: take the column metadata from the prepared statement rather than from the first row. That
closes the deadlock and the empty-columns gap together, and removes the oneshot channel from the
synchronous path entirely.

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