Related issues
[REQUIRED] Version info
node: 22.x
firebase-functions: latest (verified against the current lib/v2/providers/https.js onCallGenkit body on main)
firebase-tools: latest
firebase-admin: latest
(Bug is in the source on main; not a version-specific regression.)
[REQUIRED] Test case
import { onCallGenkit } from 'firebase-functions/v2/https';
import { genkit, z } from 'genkit';
const ai = genkit({ /* ... */ });
const demoFlow = ai.defineFlow(
{
name: 'demo',
inputSchema: z.object({ prompt: z.string() }),
streamSchema: z.string(),
},
async ({ prompt }, { sendChunk, context }) => {
// Both are `undefined` today — onCallGenkit forwards only auth/app/instanceIdToken.
console.log('rawRequest?', (context as any).rawRequest);
console.log('response signal?', (context as any).signal);
// Long-running phase: 1000 chunks over 100 seconds. There is no
// observable surface from inside this handler to detect that the
// client has disconnected.
for (let i = 0; i < 1000; i++) {
sendChunk(`chunk ${i}`);
await new Promise((r) => setTimeout(r, 100));
}
return 'done';
}
);
export const demo = onCallGenkit(demoFlow);
The current onCallGenkit body in firebase-functions/lib/v2/providers/https.js (on main):
const cloudFunction = onCall(opts, async (req, res) => {
const context = {};
copyIfPresent(context, req, 'auth', 'app', 'instanceIdToken');
if (!req.acceptsStreaming) {
const { result } = await action.run(req.data, { context });
return result;
}
const { stream, output } = action.stream(req.data, { context });
for await (const chunk of stream) {
await res.sendChunk(chunk);
}
return output;
});
Only those three string-keyed properties make it into the action context. req.rawRequest (Node's http.IncomingMessage) and the CallableResponse object (which carries .signal) are not forwarded.
[REQUIRED] Steps to reproduce
- Deploy the function above (or run it in the emulator).
- From a browser, invoke it with the streaming callable client and abort the stream after ~1 second.
- Observe in the function logs that the handler keeps emitting chunks for the full ~100 seconds. There is no signal inside the handler to react to the disconnect.
[REQUIRED] Expected behavior
onCallGenkit should forward rawRequest and CallableResponse.signal into the Genkit ActionContext so flow handlers can wire client-disconnect detection (e.g. propagate the abort signal into ai.generate({ abortSignal }) to stop billing tokens after the user clicks Stop).
A natural shape, given Genkit's context invariants (see "Implementation note" below):
const cloudFunction = onCall(opts, async (req, res) => {
const context: Record<string | symbol, unknown> = {};
copyIfPresent(context, req, 'auth', 'app', 'instanceIdToken');
(context as Record<symbol, unknown>)[Symbol.for('firebase.callable.rawRequest')] = req.rawRequest;
if (res?.signal) {
(context as Record<symbol, unknown>)[Symbol.for('firebase.callable.responseSignal')] = res.signal;
}
// ...
});
Documenting the two Symbol keys publicly would let downstream code reach them in a stable way.
Implementation note — why Symbol keys (rather than plain string keys)
Genkit imposes two invariants on the context object that rule out the obvious string-keyed forward:
- Genkit shallow-spreads the context (
{ ...options.context }) before passing it into the flow handler. This drops non-enumerable properties, so Object.defineProperty(context, 'rawRequest', { enumerable: false }) doesn't survive.
- Genkit
JSON.stringifys the context for OpenTelemetry span attributes. Node's IncomingMessage carries a circular Socket ↔ HTTPParser reference, so a string-keyed enumerable forward crashes tracing with TypeError: Converting circular structure to JSON.
Symbol-keyed enumerable properties survive shallow spread (modern spread copies own enumerable string- and symbol-keyed properties) but are ignored by JSON.stringify. They satisfy both invariants simultaneously, which is the only shape we found that works without patching Genkit.
[REQUIRED] Actual behavior
rawRequest and signal are undefined inside the flow handler. Client disconnects during silent phases (long tool calls, model "thinking", any non-emitting interval) are completely invisible to the handler. The only indirect tripwire is res.sendChunk rejecting on a destroyed socket, which only fires during active chunk emission — useless for the cases that matter most for cost (silent tool/thinking phases on AI flows).
Workaround
Replacing onCallGenkit with a custom wrapper that calls onCall directly and explicitly attaches the missing surfaces to the Genkit context (Symbol-keyed for the reasons above) recovers full disconnect-detection inside the flow.
Were you able to successfully deploy your functions?
Yes — the bug is purely runtime-observable, not deploy-time.
Related issues
CallableResponse.signaldoes not fire on client disconnect for plainonCall. Fixing this issue is necessary but not sufficient foronCallGenkit-based flows: even with a workingresponse.signal, the response is never forwarded into the GenkitActionContextin the first place, so a flow handler can't reach it. This issue (the missing context forwards) is independent and complementary.[REQUIRED] Version info
node: 22.x
firebase-functions: latest (verified against the current
lib/v2/providers/https.jsonCallGenkitbody onmain)firebase-tools: latest
firebase-admin: latest
(Bug is in the source on
main; not a version-specific regression.)[REQUIRED] Test case
The current
onCallGenkitbody infirebase-functions/lib/v2/providers/https.js(onmain):Only those three string-keyed properties make it into the action context.
req.rawRequest(Node'shttp.IncomingMessage) and theCallableResponseobject (which carries.signal) are not forwarded.[REQUIRED] Steps to reproduce
[REQUIRED] Expected behavior
onCallGenkitshould forwardrawRequestandCallableResponse.signalinto the GenkitActionContextso flow handlers can wire client-disconnect detection (e.g. propagate the abort signal intoai.generate({ abortSignal })to stop billing tokens after the user clicks Stop).A natural shape, given Genkit's context invariants (see "Implementation note" below):
Documenting the two Symbol keys publicly would let downstream code reach them in a stable way.
Implementation note — why Symbol keys (rather than plain string keys)
Genkit imposes two invariants on the context object that rule out the obvious string-keyed forward:
{ ...options.context }) before passing it into the flow handler. This drops non-enumerable properties, soObject.defineProperty(context, 'rawRequest', { enumerable: false })doesn't survive.JSON.stringifys the context for OpenTelemetry span attributes. Node'sIncomingMessagecarries a circularSocket ↔ HTTPParserreference, so a string-keyed enumerable forward crashes tracing withTypeError: Converting circular structure to JSON.Symbol-keyed enumerable properties survive shallow spread (modern spread copies own enumerable string- and symbol-keyed properties) but are ignored by
JSON.stringify. They satisfy both invariants simultaneously, which is the only shape we found that works without patching Genkit.[REQUIRED] Actual behavior
rawRequestandsignalareundefinedinside the flow handler. Client disconnects during silent phases (long tool calls, model "thinking", any non-emitting interval) are completely invisible to the handler. The only indirect tripwire isres.sendChunkrejecting on a destroyed socket, which only fires during active chunk emission — useless for the cases that matter most for cost (silent tool/thinking phases on AI flows).Workaround
Replacing
onCallGenkitwith a custom wrapper that callsonCalldirectly and explicitly attaches the missing surfaces to the Genkit context (Symbol-keyed for the reasons above) recovers full disconnect-detection inside the flow.Were you able to successfully deploy your functions?
Yes — the bug is purely runtime-observable, not deploy-time.