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.
Environment
spin --version):spin 4.0.2 (bfc7543 2026-06-23)spin plugins list --installed): nonefermyon-spin4.0.2)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-pgthat I have tracedto specific lines below.
Summary
Since 4.0.0, the synchronous
queryofspin:postgres/postgresnever returns when the statementmatches 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.ErrNoRowsin Go,Option::Nonein Rust, and so on).Reproduction
Any component running a
SELECTthat matches no rows, for exampleSELECT 1 WHERE false.Observed:
executeis unaffectedI hit this from a TinyGo
wasip2component drivingspin:postgres/postgres@4.2.0directly, butthe mechanism is entirely inside the host factor and is independent of the guest language or SDK.
Root cause
query_streampublishes the column metadata over aoneshotchannel, sent from inside the rowstream and only when
index == 0:https://github.com/spinframework/spin/blob/v4.0.2/crates/factor-outbound-pg/src/client.rs#L348-L364
querydrains 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())iswritten for — but the sender lives in
cols_tx_opt, owned by the row-stream closure, andqueryholds
resultsalive until it returns. Sender and receiver stay alive together, socols_fut.awaitnever completes.query_asyncis not affected: it moves the stream into atokio::spawned task, so the sender isdropped 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_streamplus oneshot shape appears in 4.0.0, is present in 4.0.2, and is unchanged onmaintoday (same lines atclient.rs#L259andclient.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). Adatabase/sql-style driver then cannot report columnnames for an empty result set.
tokio_postgres::Statement::columns()on a prepared statementprovides them without needing any row.
Suggested fix
Minimal: ensure the sender is dropped before the await — drop
resultsonce the drain loop endsrather 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.