Skip to content

[Bug] PL/Python statement_timeout can leave a zombie subprocess despite finally: wait() #2181

Open

Description

@Alena0704

Apache Cloudberry version

main, probably REL_2_STABLE

What happened

When statement_timeout cancels a PL/Python function waiting in subprocess.Popen.communicate(), the SQL statement can return a cancellation error while its timeout child remains a zombie, despite an explicit process.wait() in a finally block.

The zombie remains a child of the backend after subsequent SQL statements and disappears when the backend session closes. The monitored command itself has already finished.

This was discovered while reviewing #2117, but also reproduces with a harmless sleep command and does not require diskquota or segment recovery.

What you think should happen instead

The exited subprocess should be reaped before the cancellation error is returned to the client.

How to reproduce

The measured runs used coordinator connections with options='-c gp_role=utility' and autocommit enabled.

To reproduce under the same conditions, start an interactive psql session connected to the coordinator. Replace the placeholders with your connection details:

PGOPTIONS='-c gp_role=utility' psql -h <coordinator_host> -p <coordinator_port> -d <database>

Run the following statements with autocommit enabled and without an explicit transaction. Record the value returned by SELECT pg_backend_pid();.

CREATE EXTENSION IF NOT EXISTS plpython3u;

CREATE OR REPLACE FUNCTION public.zombie_repro()
RETURNS void LANGUAGE plpython3u AS $$
import subprocess

p = subprocess.Popen(
    ['timeout', '-k', '1', '5', 'sleep', '0.3'],
    stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT
)
try:
    p.communicate()
finally:
    p.wait()
$$;

SELECT pg_backend_pid();

SET statement_timeout = '100ms';
SELECT public.zombie_repro();

RESET statement_timeout;
SELECT 1;

Keep the psql session open after the timeout error. From another terminal on the coordinator host, inspect the backend’s child processes, replacing <backend_pid> with the recorded PID:

ps -o pid,ppid,stat,comm --ppid <backend_pid>

When the issue reproduces, a timeout process appears with STAT containing Z and PPID matching the backend PID. It persists after subsequent SQL statements and disappears when the session closes.

The result is timing-dependent and does not occur on every invocation. If necessary, repeat the procedure using a fresh connection for each attempt.

Observed reproduction rates:

Every attempt returned SQLSTATE 57014 from the function call. Zombie detection was performed after RESET statement_timeout, a 200 ms delay, and SELECT 1. A separate check confirmed persistence after two seconds and another SELECT 1.

Each attempt closed its connection afterwards, so the counts do not include children left by previous attempts.

Operating System

Any

Anything else

The results above were verified in this environment:

  • Ubuntu 22.04, Linux x86_64.
  • Python 3.10.12.
  • Cloudberry 3.0.0-devel / PostgreSQL 16.9, with assertions enabled.
  • Server source commit: 070d9d33b3bacea45d991f54c100b4bc077c8d58.

Possible cause to investigate: StatementTimeoutHandler signals both the backend process group and the backend PID, while PLy_handle_cancel_interrupt queues KeyboardInterrupt through Py_AddPendingCall. Repeated cancellation may interrupt cleanup. This is a hypothesis, not a confirmed root cause.

Are you willing to submit PR?

  • Yes, I am willing to submit a PR!

Code of Conduct

Activity

  1. krylosov-aa commented on Oct 24, 2026

    @krylosov-aa
    Contributor

    I figured it out. Will make a PR.

    statement_timeout cancels a running PL/Python function with two KeyboardInterrupts instead of one. StatementTimeoutHandler() sends SIGINT to the backend's process group and then to the backend itself, and PLy_handle_cancel_interrupt() queues a KeyboardInterrupt with Py_AddPendingCall() for each signal.

    The second KeyboardInterrupt causes two problems:

    • It aborts the except or finally block that handles the first one, so cleanup code doesn't run. This is the zombie: finally: p.wait() never calls wait()
    • If the function has no handler, it stays queued and fails the next PL/Python call in the session
  2. Alena0704 commented on Oct 24, 2026

    @Alena0704
    CollaboratorAuthor

    I figured it out. Will make a PR.

    statement_timeout cancels a running PL/Python function with two KeyboardInterrupts instead of one. StatementTimeoutHandler() sends SIGINT to the backend's process group and then to the backend itself, and PLy_handle_cancel_interrupt() queues a KeyboardInterrupt with Py_AddPendingCall() for each signal.

    The second KeyboardInterrupt causes two problems:

    • It aborts the except or finally block that handles the first one, so cleanup code doesn't run. This is the zombie: finally: p.wait() never calls wait()
    • If the function has no handler, it stays queued and fails the next PL/Python call in the session

    Okay, assigned you. Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

type: BugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions