Skip to content

Functions emulator orphans the Python runtime: functions-framework survives shutdown still bound to its port #11069

Description

@IzaakGough

What happens

The Functions emulator starts the Python runtime through runWithVirtualEnv, which always spawns via a shell (. venv/bin/activate && functions-framework). Every shutdown path in functionsRuntimeWorker.ts calls runtime.process.kill(), which signals that shell and not the Python process underneath it. The shell dies, functions-framework survives, reparents to PID 1, and keeps its TCP port bound.

This is the same root cause as #10847, fixed for the deploy-time discovery server in #10893. startPython in src/emulator/functionsEmulator.ts isn't covered by that fix: it neither spawns detached nor calls trackVirtualEnvChild, so neither the process-group kill nor the CLI-exit cleanup reaches it.

Reproduction

firebase-tools 15.30.0, macOS (Darwin 25.6.0), Python 3.14, firebase-functions 0.4.3.

  1. A Python functions project with a venv and one on_request function.
  2. firebase emulators:start --only functions --project demo-repro
  3. Invoke it once so the runtime worker actually starts: curl http://127.0.0.1:5001/demo-repro/us-central1/hello
  4. Stop the emulator with kill -INT <cli-pid>. It shuts down cleanly, logging "Stopping" for every emulator.

While running, the tree is shell -> Python -> reloader (columns are pid, ppid, command):

86175 85898 /bin/sh -c . ".../venv/bin/activate" && functions-framework
86178 86175 .../venv/bin/functions-framework
86179 86178 .../venv/bin/functions-framework

After the CLI has exited, the shell is gone but the Python process is not:

$ ps -o pid,ppid -p 86178
  PID  PPID
86178     1                     # reparented to init

$ lsof -nP -a -p 86178 -iTCP -sTCP:LISTEN
Python 86178 ... TCP 127.0.0.1:8674 (LISTEN)

$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8674/
200                             # still serving

Sending SIGINT to the CLI's pid alone is deliberate here: it isolates the CLI's own cleanup from the terminal signalling the whole foreground process group, which is what masks this during ordinary Ctrl-C.

Impact

Milder than #10847, because startPython picks its port through portfinder and so a stale orphan does not block the next run. Instead each emulator session that invokes a Python function leaks a Python process that keeps running and serving until it is killed by hand.

modulesDir() in the deploy path also goes through the shell wrapper, but it is a short-lived python -c that is awaited to exit and binds no port, so it does not leak in practice.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions