#13485·appwrite

Bug Report: Synchronous function executions deadlock under concurrency — app/http.php never sets Swoole hook_flags

Author: AlimFreightCreated Sep 4, 2026Updated Sep 12, 2026
Labelsproduct / databasesproduct / functionsproduct / self-hostedproduct / vcsproduct / avatarsproduct / sitessdk / cli

Reproduction steps

Self-hosted 2.0.0 (PostgreSQL). Any function that calls back into the Appwrite API — the normal server-function pattern — executed synchronously with a little concurrency:

  1. Deploy a function that makes a few API calls back to Appwrite (e.g. tablesDB.listRows) using a server SDK and an API key.
  2. Fire N synchronous executions at once:
for i in $(seq 4); do
  curl -s -X POST "$ENDPOINT/v1/functions/$FN/executions" \
    -H "X-Appwrite-Project: $PROJECT" -H "X-Appwrite-Key: $KEY" \
    -H 'Content-Type: application/json' \
    -d '{"body":"{}","async":false}' &
done; wait
  1. With N as low as 4, every execution fails at the SDK's client timeout. The same work run with "async": true always succeeds, because the functions worker runs those one at a time.

Expected behavior

Concurrent synchronous executions complete. A function calling back into the API should not be able to starve the server that is waiting for it.

Actual behavior

All concurrent executions hang until the calling SDK's timeout and then fail. The API log shows:

WARNING Server::timer_callback() (ERRNO 9007): No idle worker is available

Meanwhile the API answers other requests (pings, keyed row reads, even from inside the runtime container) in single-digit milliseconds, and no row-list request in the API's own log exceeds 100 ms. The requests never reach a worker.

Prior report

This looks like the same failure as #5629 "Appwrite main container freezes when executing cloud-functions" (closed as completed), where the last comments report it still reproducing and the offered workaround is to run a second Appwrite API container and point function callbacks at it. That workaround is consistent with the analysis below: it works because the function's callbacks then land on a server whose workers are not the ones blocked waiting for the executor. The root cause below explains why, and needs no second container.

Root cause

app/http.php builds the Swoole server with a plain settings array:

$swoole = new Server(
    host: "0.0.0.0",
    port: System::getEnv('PORT', 80),
    settings: [
        Constant::OPTION_WORKER_NUM => $totalWorkers,
        Constant::OPTION_HTTP_COMPRESSION => false,
        Constant::OPTION_PACKAGE_MAX_LENGTH => $payloadSize,
        Constant::OPTION_OUTPUT_BUFFER_SIZE => $payloadSize,
    ],
    resources: $container,
);

utopia-php/http's Adapter\Swoole\Server accepts Mode|array. Because an array is passed, none of the tuned presets in Adapter\Swoole\Mode apply, so Swoole's own defaults govern — in particular hook_flags is unset (no runtime hooks) and dispatch_mode stays at the default.

That combination produces the deadlock:

  1. A synchronous execution blocks its worker for the whole run, waiting on the executor over plain curl. Without hook_flags, that call cannot yield.
  2. The function's own API calls arrive as a burst while those workers are blocked.
  3. With no idle worker, the reactor queues them onto workers that are themselves blocked — waiting for exactly those requests.
  4. Both sides wait until the caller's timeout.

This is unchanged from 2.0.0-rc.2: app/http.php is byte-identical across rc.2, rc.3 and 2.0.0, and utopia-php/http is still pinned at 2.0.0-rc25.

Things that do not fix it, for the record: raising _APP_WORKER_PER_CORE, raising the connection-pool size, dispatch_mode => 3 alone, and enable_coroutine => false (which breaks Appwrite's own CoroutineLock). max_concurrency also is not the answer — it throttles the whole server.

Proposed fix

Pass a Mode (the adapter already defines tuned presets), or at minimum set the hook flags in app/http.php:

Constant::OPTION_HOOK_FLAGS => SWOOLE_HOOK_ALL,

The shipped Swoole build has curl-native and coroutine_pgsql enabled, so blocking calls become coroutine-aware and a synchronous execution yields its worker instead of holding it.

With that one option added (plus dispatch_mode => 3), on the same instance and hardware:

concurrent sync executions before after
4 4/4 fail at the client timeout all succeed, 0.12 s
12 12/12 fail all succeed, 0.05 s each
30 30/30 fail all succeed, ~2 s median

Specification

  • Version: 2.0.0 (self-hosted, also reproduced on 2.0.0-rc.2 and rc.3)
  • Database: PostgreSQL 18
  • Swoole 6.2.2 (curl-native enabled, coroutine_pgsql enabled)
  • Runtime of the calling function: Go, but the mechanism is runtime-independent