Repository navigation
Functions emulator with --inspect-functions sometimes runs an event as the previous function #11263
Description
[REQUIRED] Environment info
firebase-tools: 15.32.1 and 15.33.0 (latest at the time of writing)
Platform: Ubuntu (GitHub Actions ubuntu-latest, and node:24-bookworm in Docker), Node 24. I couldn't reproduce it on macOS.
[REQUIRED] Test case
In debug mode (--inspect-functions), one shared worker runs every function. For each event, the emulator first tells the worker which function to run over IPC (RuntimeWorkerPool.submitRequest → sendDebugMsg), then sends the event over HTTP (RuntimeWorker.request). The runtime sets FUNCTION_TARGET_NAME / FUNCTION_SIGNATURE globals when the IPC message arrives (handleMessage), and the HTTP handler reads them when the request arrives.
The two travel on different channels, and the HTTP request goes out on a kept-alive socket (Node's default agent). Under CPU load on Linux the worker sometimes reads the HTTP request before the IPC message, so the event runs as the previous function:
- If the previous function is a different 1st gen database event type (e.g. a
ref.deleteevent handed to anonUpdate),cloudFunctiondoesn't wrapcontext.resource, andextractInstanceAndPaththrowsTypeError: Cannot read properties of undefined (reading 'match'). The worker is killed and the events queued behind it are lost. - If both are
onWrite, nothing throws: the wrong handler runs with another trigger's event, and the right one never runs.
A minimal project:
firebase.json
{
"database": [{ "instance": "repro", "rules": "rules.json" }],
"functions": { "source": "functions" },
"emulators": { "database": { "port": 9460 }, "functions": { "port": 5460 }, "ui": { "enabled": false } }
}rules.json
{ "rules": { ".read": true, ".write": true } }functions/package.json
{ "name": "repro", "main": "index.js", "engines": { "node": "24" }, "dependencies": { "firebase-admin": "13.6.0", "firebase-functions": "7.4.0" } }functions/index.js
const functions = require('firebase-functions/v1');
const check = (name, prefix) => (_, ctx) => {
const r = typeof ctx.resource === 'string' ? ctx.resource : ctx.resource.name;
if (!r.includes(prefix)) console.log(`MISROUTED: ${name} got ${r}`);
return null;
};
const db = functions.database.instance('repro');
exports.aUpdated = db.ref('/a/{id}').onUpdate(check('aUpdated', '/refs/a/'));
exports.bDeleted = db.ref('/b/{id}').onDelete(check('bDeleted', '/refs/b/'));
exports.cWritten = db.ref('/c/{id}').onWrite(check('cWritten', '/refs/c/'));
exports.dWritten = db.ref('/d/{id}').onWrite(check('dWritten', '/refs/d/'));
exports.eCreated = db.ref('/e/{id}').onCreate(check('eCreated', '/refs/e/'));fire.mjs
const req = (method, path, body) => fetch(`http://127.0.0.1:9460/${path}.json?ns=repro`, {
method, headers: { Authorization: 'Bearer owner' }, body: body === undefined ? undefined : JSON.stringify(body),
});
const N = 1500;
for (let i = 0; i < N; i += 50) {
const seed = {}; for (let j = i; j < i + 50; j++) seed[j] = { v: 0 };
await req('PATCH', 'a', seed); await req('PATCH', 'b', seed);
}
await new Promise((r) => setTimeout(r, 5000));
for (let i = 0; i < N; i++) {
await Promise.all([req('PUT', `a/${i}`, { v: 1 }), req('DELETE', `b/${i}`), req('PUT', `c/${i}`, { v: i }), req('PUT', `d/${i}`, { v: i }), req('PUT', `e/${i}`, { v: i })]);
}
await new Promise((r) => setTimeout(r, 20000));Dockerfile
FROM node:24-bookworm
COPY --from=eclipse-temurin:21-jre /opt/java/openjdk /opt/java/openjdk
ENV JAVA_HOME=/opt/java/openjdk PATH=/opt/java/openjdk/bin:$PATH
RUN npm i -g firebase-tools@15.33.0[REQUIRED] Steps to reproduce
docker build -t fb-repro .
docker run --rm --cpus=2 -v "$PWD:/repro" -w /repro fb-repro bash -c '
(cd functions && npm i --silent)
for i in 1 2 3; do node -e "for(;;){}" & done # CPU load, like a busy CI runner
firebase emulators:exec --project demo-repro --only database,functions --inspect-functions 9339 "node fire.mjs" > out.log 2>&1
grep -c "reading .match." out.log; grep -c MISROUTED out.log'The busy loops matter: without load I didn't see it. To watch the race itself, add this as the first line of the app.all("/*") handler in lib/emulator/functionsEmulatorRuntime.js:
if (req.path.includes("/triggers/") && !req.path.includes("-" + FUNCTION_TARGET_NAME + "-")) console.log("STALE_TARGET running " + FUNCTION_TARGET_NAME + " for " + req.path);[REQUIRED] Expected behavior
Every event runs the function it was sent to.
[REQUIRED] Actual behavior
About 4–5 of the 7,500 events per run go to the previous function. From one run:
> STALE_TARGET running eCreated for /functions/projects/repro/triggers/us-central1-cWritten-0
> STALE_TARGET running dWritten for /functions/projects/repro/triggers/us-central1-eCreated-0
> STALE_TARGET running cWritten for /functions/projects/repro/triggers/us-central1-bDeleted-0
Each one across event types then logs:
⚠ functions: TypeError: Cannot read properties of undefined (reading 'match')
at extractInstanceAndPath (.../firebase-functions/lib/v1/providers/database.js:226:25)
at RefBuilder.changeConstructor (.../firebase-functions/lib/v1/providers/database.js:140:31)
at cloudFunction (.../firebase-functions/lib/v1/cloud-functions.js:48:25)
at .../firebase-tools/lib/emulator/functionsEmulatorRuntime.js:412:16
at runFunction (.../firebase-tools/lib/emulator/functionsEmulatorRuntime.js:400:15)
at runBackground (.../firebase-tools/lib/emulator/functionsEmulatorRuntime.js:411:11)
at processBackground (.../firebase-tools/lib/emulator/functionsEmulatorRuntime.js:395:11)
⚠ Your function was killed because it raised an unhandled error.
A real app's E2E suite on GitHub Actions hits this about 15 times per run, and specs that wait on triggers fail at random.
Debug mode also makes it hard to trace: Beginning execution of "X" logs the trigger that started the shared worker, not the function being run, so after a restart every line names the same function.
Workaround: preload a non-keep-alive default agent into the CLI. Each event then opens a new connection, and the worker reads the IPC message first. With it, no events went to the wrong function in three runs (30,000 events fired), and the E2E suite ran with no crashes.
// NODE_OPTIONS="--require /abs/path/no-keepalive.cjs"
require('node:http').globalAgent = new (require('node:http').Agent)({ keepAlive: false })Possible fix: don't rely on ordering across two channels. For example, the runtime could queue debug payloads from handleMessage and have each request take the next one (the emulator sends exactly one before each request). Or the target could travel with the request itself, e.g. in a header.
Activity
IzaakGough commentedon Oct 14, 2026
Hey @mismith, thanks for the detailed report! I managed to reproduce this with your setup, with a few differences:
I'm going to look into opening a fix based on your suggestion of sending the target with the request in a header. I think that's the cleanest of the options.
I'll keep you updated here.