Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/commands/emulators-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const command = new Command("emulators:exec <script>")
)
.option(commandUtils.FLAG_ONLY, commandUtils.DESC_ONLY)
.option(commandUtils.FLAG_INSPECT_FUNCTIONS, commandUtils.DESC_INSPECT_FUNCTIONS)
.option(commandUtils.FLAG_PYTHON_DISABLE_GUNICORN, commandUtils.DESC_PYTHON_DISABLE_GUNICORN)
.option(commandUtils.FLAG_IMPORT, commandUtils.DESC_IMPORT)
.option(commandUtils.FLAG_EXPORT_ON_EXIT, commandUtils.DESC_EXPORT_ON_EXIT)
.option(commandUtils.FLAG_VERBOSITY, commandUtils.DESC_VERBOSITY)
Expand Down
1 change: 1 addition & 0 deletions src/commands/emulators-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
.description("start the local Firebase emulators")
.option(commandUtils.FLAG_ONLY, commandUtils.DESC_ONLY)
.option(commandUtils.FLAG_INSPECT_FUNCTIONS, commandUtils.DESC_INSPECT_FUNCTIONS)
.option(commandUtils.FLAG_PYTHON_DISABLE_GUNICORN, commandUtils.DESC_PYTHON_DISABLE_GUNICORN)
.option(commandUtils.FLAG_IMPORT, commandUtils.DESC_IMPORT)
.option(commandUtils.FLAG_EXPORT_ON_EXIT, commandUtils.DESC_EXPORT_ON_EXIT)
.option(commandUtils.FLAG_VERBOSITY, commandUtils.DESC_VERBOSITY)
Expand All @@ -35,7 +36,7 @@
try {
({ deprecationNotices } = await controller.startAll(options));
await sendVSCodeMessage({ message: VSCODE_MESSAGE.EMULATORS_STARTED });
} catch (e: any) {

Check warning on line 39 in src/commands/emulators-start.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
await sendVSCodeMessage({ message: VSCODE_MESSAGE.EMULATORS_START_ERRORED });
await controller.cleanShutdown();
throw e;
Expand All @@ -61,7 +62,7 @@
if (info) {
reservedPorts.push(info.port);
}
controller.filterEmulatorTargets(options).forEach((emulator: Emulators) => {

Check warning on line 65 in src/commands/emulators-start.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `{ only: string; config: any; }`
reservedPorts.push(...(EmulatorRegistry.getInfo(emulator)?.reservedPorts || []));
});
}
Expand Down Expand Up @@ -94,7 +95,7 @@

emulatorsTable.push(
...controller
.filterEmulatorTargets(options)

Check warning on line 98 in src/commands/emulators-start.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `{ only: string; config: any; }`
.map((emulator) => {
const emulatorName = Constants.description(emulator).replace(/ emulator/i, "");
const isSupportedByUi = EMULATORS_SUPPORTED_BY_UI.includes(emulator);
Expand Down Expand Up @@ -126,9 +127,9 @@
extensionsTable = extensionsEmulatorInstance.extensionsInfoTable();
}
const hubInfo = EmulatorRegistry.getInfo(Emulators.HUB);
logger.info(`\n${successMessageTable}

Check warning on line 130 in src/commands/emulators-start.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Invalid type "Table" of template literal expression

${emulatorsTable}

Check warning on line 132 in src/commands/emulators-start.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Invalid type "Table" of template literal expression
${
hubInfo
? clc.blackBright(` Emulator Hub host: ${hubInfo.host} port: ${hubInfo.port}`)
Expand Down
4 changes: 4 additions & 0 deletions src/emulator/commandUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
export const DESC_INSPECT_FUNCTIONS =
"emulate Cloud Functions in debug mode with the node inspector on the given port (9229 if not specified)";

export const FLAG_PYTHON_DISABLE_GUNICORN = "--python-disable-gunicorn";
export const DESC_PYTHON_DISABLE_GUNICORN =
"run Python Functions emulator processes without gunicorn, using the Flask server in non-debug mode";

export const FLAG_IMPORT = "--import [dir]";
export const DESC_IMPORT = "import emulator data from a previous export (see emulators:export)";

Expand Down Expand Up @@ -83,7 +87,7 @@
* specify an emulator address.
*/
export function printNoticeIfEmulated(
options: any,

Check warning on line 90 in src/emulator/commandUtils.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
emulator: Emulators.DATABASE | Emulators.FIRESTORE,
): void {
if (emulator !== Emulators.DATABASE && emulator !== Emulators.FIRESTORE) {
Expand Down Expand Up @@ -111,7 +115,7 @@
* an emulator port that the command actually talks to production.
*/
export async function warnEmulatorNotSupported(
options: any,

Check warning on line 118 in src/emulator/commandUtils.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
emulator: Emulators.DATABASE | Emulators.FIRESTORE,
): Promise<void> {
if (emulator !== Emulators.DATABASE && emulator !== Emulators.FIRESTORE) {
Expand All @@ -138,8 +142,8 @@
}
}

export async function errorMissingProject(options: any): Promise<void> {

Check warning on line 145 in src/emulator/commandUtils.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 145 in src/emulator/commandUtils.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing JSDoc comment
if (!options.project) {

Check warning on line 146 in src/emulator/commandUtils.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .project on an `any` value
throw new FirebaseError(
"Project is not defined. Either use `--project` or use `firebase use` to set your active project.",
);
Expand Down
1 change: 1 addition & 0 deletions src/emulator/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,7 @@ export async function startAll(
host: functionsAddr.host,
port: functionsAddr.port,
debugPort: inspectFunctions,
pythonDisableGunicorn: Boolean(options.pythonDisableGunicorn),
verbosity: options.logVerbosity,
projectAlias: options.projectAlias,
extensionsEmulator: extensionEmulator,
Expand Down
188 changes: 188 additions & 0 deletions src/emulator/functionsEmulator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { expect } from "chai";
import * as fs from "fs";
import * as path from "path";
import * as portfinder from "portfinder";
import * as sinon from "sinon";

import { FunctionsEmulator } from "./functionsEmulator";
import * as functionsPython from "../functions/python";

describe("FunctionsEmulator", () => {
const sandbox = sinon.createSandbox();

afterEach(async () => {
sandbox.restore();
await makeEmulator(true).stop();
});

function makeEmulator(pythonDisableGunicorn = false): FunctionsEmulator {
return new FunctionsEmulator({
projectId: "project-id",
projectDir: "/workspace",
emulatableBackends: [],
debugPort: false,
pythonDisableGunicorn,
});
}

function makePythonBackend() {
return {
functionsDir: "/workspace/functions",
codebase: "default",
env: {},
secretEnv: [],
runtime: "python311",
};
}

it("should inject a sitecustomize shim when pythonDisableGunicorn is enabled", async () => {
sandbox.stub(portfinder, "getPortPromise").resolves(9191);
const runWithVirtualEnv = sandbox.stub(functionsPython, "runWithVirtualEnv").returns({} as any);
const mkdtempSync = sandbox.stub(fs, "mkdtempSync").returns("/tmp/firebase-tools-python-shim");
const writeFileSync = sandbox.stub(fs, "writeFileSync");

const emulator = makeEmulator(true);
const logLabeled = sandbox.stub((emulator as any).logger, "logLabeled");

await (emulator as any).startPython(makePythonBackend(), { PYTHONPATH: "/existing/path" });

expect(runWithVirtualEnv).to.have.been.calledOnce;
expect(runWithVirtualEnv.firstCall.args[0]).to.deep.equal(["functions-framework"]);
expect(runWithVirtualEnv.firstCall.args[1]).to.equal("/workspace/functions");
expect(runWithVirtualEnv.firstCall.args[2]).to.include({
FIREBASE_FUNCTIONS_EMULATOR_DISABLE_GUNICORN: "1",
PYTHONUNBUFFERED: "1",
DEBUG: "False",
HOST: "127.0.0.1",
PORT: "9191",
});
expect(runWithVirtualEnv.firstCall.args[2].PYTHONPATH).to.equal(
`/tmp/firebase-tools-python-shim${path.delimiter}/existing/path`,
);
expect(mkdtempSync).to.have.been.calledOnce;
expect(writeFileSync).to.have.been.calledOnceWith(
"/tmp/firebase-tools-python-shim/sitecustomize.py",
sinon.match.string,
"utf8",
);
const shim = writeFileSync.firstCall.args[1];
expect(shim).to.include("PathFinder.find_spec(");
expect(shim).to.include('"sitecustomize", _firebase_remaining_paths');
expect(shim).to.include("module_from_spec");
expect(shim).to.include('sys.modules["sitecustomize"] = _firebase_user_sitecustomize_module');
expect(shim).to.include("exec_module");
expect(logLabeled).to.have.been.calledOnceWith(
"BULLET",
"functions",
"Python emulator gunicorn disabled; using Flask server in non-debug mode.",
);
});

it("should leave python runtime envs unchanged when pythonDisableGunicorn is disabled", async () => {
sandbox.stub(portfinder, "getPortPromise").resolves(9292);
const runWithVirtualEnv = sandbox.stub(functionsPython, "runWithVirtualEnv").returns({} as any);
const mkdtempSync = sandbox.stub(fs, "mkdtempSync");
const writeFileSync = sandbox.stub(fs, "writeFileSync");

const emulator = makeEmulator(false);
const logLabeled = sandbox.stub((emulator as any).logger, "logLabeled");

await (emulator as any).startPython(makePythonBackend(), {});

expect(runWithVirtualEnv).to.have.been.calledOnce;
expect(runWithVirtualEnv.firstCall.args[2]).to.include({
PYTHONUNBUFFERED: "1",
DEBUG: "False",
HOST: "127.0.0.1",
PORT: "9292",
});
expect(runWithVirtualEnv.firstCall.args[2]).to.not.have.property(
"FIREBASE_FUNCTIONS_EMULATOR_DISABLE_GUNICORN",
);
expect(runWithVirtualEnv.firstCall.args[2].PYTHONPATH).to.equal(process.env.PYTHONPATH);
expect(mkdtempSync).to.not.have.been.called;
expect(writeFileSync).to.not.have.been.called;
expect(logLabeled).to.not.have.been.called;
});

it("should recursively remove the shim dir during stop and clear the cached path", async () => {
sandbox.stub(portfinder, "getPortPromise").resolves(9393);
const runWithVirtualEnv = sandbox.stub(functionsPython, "runWithVirtualEnv").returns({} as any);
const mkdtempSync = sandbox.stub(fs, "mkdtempSync");
mkdtempSync.onFirstCall().returns("/tmp/firebase-tools-python-shim-one");
mkdtempSync.onSecondCall().returns("/tmp/firebase-tools-python-shim-two");
sandbox.stub(fs, "writeFileSync");
const rmSync = sandbox.stub(fs, "rmSync");

const emulator = makeEmulator(true);

await (emulator as any).startPython(makePythonBackend(), {});
await emulator.stop();
await (emulator as any).startPython(makePythonBackend(), {});

expect(rmSync).to.have.been.calledOnceWith("/tmp/firebase-tools-python-shim-one", {
recursive: true,
});
expect(mkdtempSync).to.have.been.calledTwice;
expect(runWithVirtualEnv.secondCall.args[2].PYTHONPATH).to.equal(
"/tmp/firebase-tools-python-shim-two",
);
});

it("should ignore ENOENT during shim dir cleanup and clear the cached path", async () => {
sandbox.stub(portfinder, "getPortPromise").resolves(9494);
const runWithVirtualEnv = sandbox.stub(functionsPython, "runWithVirtualEnv").returns({} as any);
const mkdtempSync = sandbox.stub(fs, "mkdtempSync");
mkdtempSync.onFirstCall().returns("/tmp/firebase-tools-python-shim-missing");
mkdtempSync.onSecondCall().returns("/tmp/firebase-tools-python-shim-recreated");
sandbox.stub(fs, "writeFileSync");
const rmSync = sandbox
.stub(fs, "rmSync")
.throws(Object.assign(new Error("missing"), { code: "ENOENT" }));

const emulator = makeEmulator(true);
const log = sandbox.stub((emulator as any).logger, "log");

await (emulator as any).startPython(makePythonBackend(), {});
await emulator.stop();
await (emulator as any).startPython(makePythonBackend(), {});

expect(rmSync).to.have.been.calledOnceWith("/tmp/firebase-tools-python-shim-missing", {
recursive: true,
});
expect(mkdtempSync).to.have.been.calledTwice;
expect(runWithVirtualEnv.secondCall.args[2].PYTHONPATH).to.equal(
"/tmp/firebase-tools-python-shim-recreated",
);
expect(log).to.not.have.been.called;
});

it("should recreate the shim dir after cleanup fails with a non-ENOENT error", async () => {
sandbox.stub(portfinder, "getPortPromise").resolves(9595);
const runWithVirtualEnv = sandbox.stub(functionsPython, "runWithVirtualEnv").returns({} as any);
const mkdtempSync = sandbox.stub(fs, "mkdtempSync");
mkdtempSync.onFirstCall().returns("/tmp/firebase-tools-python-shim-error");
mkdtempSync.onSecondCall().returns("/tmp/firebase-tools-python-shim-recreated");
sandbox.stub(fs, "writeFileSync");
const rmSync = sandbox.stub(fs, "rmSync").throws(new Error("permission denied"));

const emulator = makeEmulator(true);
const log = sandbox.stub((emulator as any).logger, "log");

await (emulator as any).startPython(makePythonBackend(), {});
await emulator.stop();
await (emulator as any).startPython(makePythonBackend(), {});

expect(rmSync).to.have.been.calledOnceWith("/tmp/firebase-tools-python-shim-error", {
recursive: true,
});
expect(mkdtempSync).to.have.been.calledTwice;
expect(runWithVirtualEnv.secondCall.args[2].PYTHONPATH).to.equal(
"/tmp/firebase-tools-python-shim-recreated",
);
expect(log).to.have.been.calledWith(
"DEBUG",
sinon.match("Failed to clean up python-disable-gunicorn shim dir"),
);
});
});
113 changes: 111 additions & 2 deletions src/emulator/functionsEmulator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import * as express from "express";
import * as clc from "colorette";
Expand Down Expand Up @@ -70,6 +71,91 @@ import {
import { ExtensionsEmulator } from "./extensionsEmulator";

const EVENT_INVOKE_GA4 = "functions_invoke"; // event name GA4 (alphanumertic)
const PYTHON_DISABLE_GUNICORN_ENV = "FIREBASE_FUNCTIONS_EMULATOR_DISABLE_GUNICORN";

// On macOS, gunicorn can trigger fork-safety crashes in Python emulator
// processes. functions-framework only falls back to Flask in non-debug mode
// when gunicorn cannot be imported, so the emulator injects this
// sitecustomize shim to force that fallback without enabling Flask
// debug/reloader behavior.

const PYTHON_DISABLE_GUNICORN_SHIM = `import builtins
import importlib.machinery
import importlib.util
import os
import sys

if os.environ.get("${PYTHON_DISABLE_GUNICORN_ENV}") == "1":
_original_import = builtins.__import__

def _firebase_import_without_gunicorn(name, globals=None, locals=None, fromlist=(), level=0):
if name == "functions_framework._http.gunicorn":
raise ImportError("Gunicorn disabled for local Firebase Functions emulator")
return _original_import(name, globals, locals, fromlist, level)
Comment on lines +91 to +94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The _firebase_import_without_gunicorn hook currently uses a fixed signature (name, globals=None, locals=None, fromlist=(), level=0). To ensure maximum compatibility across different Python versions, alternative interpreters, or third-party packages that might call __import__ with different positional/keyword arguments, it is more robust to use *args and **kwargs and extract the name argument dynamically.

Suggested change
def _firebase_import_without_gunicorn(name, globals=None, locals=None, fromlist=(), level=0):
if name == "functions_framework._http.gunicorn":
raise ImportError("Gunicorn disabled for local Firebase Functions emulator")
return _original_import(name, globals, locals, fromlist, level)
def _firebase_import_without_gunicorn(*args, **kwargs):
name = args[0] if args else kwargs.get("name")
if name == "functions_framework._http.gunicorn":
raise ImportError("Gunicorn disabled for local Firebase Functions emulator")
return _original_import(*args, **kwargs)


builtins.__import__ = _firebase_import_without_gunicorn

_firebase_shim_dir = os.path.dirname(__file__)
_firebase_remaining_paths = [
search_path
for search_path in sys.path
if os.path.abspath(search_path or os.curdir) != os.path.abspath(_firebase_shim_dir)
]
_firebase_user_sitecustomize = importlib.machinery.PathFinder.find_spec(
"sitecustomize", _firebase_remaining_paths
)
if (
_firebase_user_sitecustomize
and _firebase_user_sitecustomize.loader
and _firebase_user_sitecustomize.origin
and os.path.abspath(_firebase_user_sitecustomize.origin) != os.path.abspath(__file__)
):
_firebase_user_sitecustomize_module = importlib.util.module_from_spec(
_firebase_user_sitecustomize
)
sys.modules["sitecustomize"] = _firebase_user_sitecustomize_module
_firebase_user_sitecustomize.loader.exec_module(_firebase_user_sitecustomize_module)
`;

let pythonDisableGunicornShimDir: string | undefined;

function getPythonDisableGunicornShimDir(): string {
if (!pythonDisableGunicornShimDir) {
pythonDisableGunicornShimDir = fs.mkdtempSync(
path.join(os.tmpdir(), "firebase-tools-python-shim-"),
);
fs.writeFileSync(
path.join(pythonDisableGunicornShimDir, "sitecustomize.py"),
PYTHON_DISABLE_GUNICORN_SHIM,
"utf8",
);
}

return pythonDisableGunicornShimDir;
}
Comment thread
IzaakGough marked this conversation as resolved.

function cleanupPythonDisableGunicornShimDir(logDebug: (message: string) => void): void {
if (!pythonDisableGunicornShimDir) {
return;
}

const shimDir = pythonDisableGunicornShimDir;
pythonDisableGunicornShimDir = undefined;

try {
fs.rmSync(shimDir, { recursive: true });
} catch (e: any) {
if (e?.code === "ENOENT") {
return;
}

logDebug(`Failed to clean up python-disable-gunicorn shim dir ${shimDir}: ${e}`);
}
}

function prependPythonPath(envs: Record<string, string | undefined>, injectedPath: string): string {
return envs.PYTHONPATH ? `${injectedPath}${path.delimiter}${envs.PYTHONPATH}` : injectedPath;
}

/*
* The Realtime Database emulator expects the `path` field in its trigger
Expand Down Expand Up @@ -123,6 +209,7 @@ export interface FunctionsEmulatorArgs {
projectDir: string;
emulatableBackends: EmulatableBackend[];
debugPort: number | boolean;
pythonDisableGunicorn?: boolean;
account?: Account;
port?: number;
host?: string;
Expand Down Expand Up @@ -229,6 +316,7 @@ export class FunctionsEmulator implements EmulatorInstance {
private dynamicBackends: EmulatableBackend[] = [];
private watchers: chokidar.FSWatcher[] = [];
private watchCleanups: Array<() => Promise<void>> = [];
private pythonDisableGunicornNoticeLogged = false;

debugMode = false;

Expand Down Expand Up @@ -571,6 +659,8 @@ export class FunctionsEmulator implements EmulatorInstance {
if (this.destroyServer) {
await this.destroyServer();
}

cleanupPythonDisableGunicornShimDir((message) => this.logger.log("DEBUG", message));
}

async discoverTriggers(
Expand Down Expand Up @@ -1737,7 +1827,7 @@ export class FunctionsEmulator implements EmulatorInstance {
const port = await portfinder.getPortPromise({
port: 8081 + randomInt(0, 1000), // Add a small jitter to avoid race condition.
});
const childProcess = runWithVirtualEnv(args, backend.functionsDir, {
const pythonRuntimeEnvs: Record<string, string> = {
...process.env,
...envs,
// Required to flush stdout/stderr immediately to the piped channels.
Expand All @@ -1746,7 +1836,26 @@ export class FunctionsEmulator implements EmulatorInstance {
DEBUG: "False",
HOST: "127.0.0.1",
PORT: port.toString(),
});
};

if (this.args.pythonDisableGunicorn) {
pythonRuntimeEnvs[PYTHON_DISABLE_GUNICORN_ENV] = "1";
pythonRuntimeEnvs.PYTHONPATH = prependPythonPath(
pythonRuntimeEnvs,
getPythonDisableGunicornShimDir(),
);

if (!this.pythonDisableGunicornNoticeLogged) {
this.logger.logLabeled(
"BULLET",
"functions",
"Python emulator gunicorn disabled; using Flask server in non-debug mode.",
);
this.pythonDisableGunicornNoticeLogged = true;
}
}

const childProcess = runWithVirtualEnv(args, backend.functionsDir, pythonRuntimeEnvs);

return {
process: childProcess,
Expand Down
Loading
Loading