firebase / firebase/firebase-functions
onCallGenkit drops rawRequest and CallableResponse from the Genkit ActionContext, blocking client-disconnect detection
- Dominant language
- TypeScript
- Stars
- 1.1k
- Forks
- 232
- Avg merge
- 20h 46m
- Merged PRs (30d)
- 15
Description
## Related issues
- #1684 — `CallableResponse.signal` does not fire on client disconnect for plain `onCall`. Fixing this issue is **necessary but not sufficient** for `onCallGenkit`-based flows: even with a working `response.signal`, the response is never forwarded into the Genkit `ActionContext` in the first place, so a flow handler can't reach it. This issue (the missing context forwards) is independent and complementary.
- #1585 — duplicate of #1684.
### [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
```ts
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`):
```ts
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
1. Deploy the function above (or run it in the emulator).
2. From a browser, invoke it with the streaming callable client and abort the stream after ~1 second.
3. 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):
```ts
const cloudFunction = onCall(opts, async (req, res) => {
const context: Record = {};
copyIfPresent(context, req, 'auth', 'app', 'instanceIdToken');
(context as Record)[Symbol.for('firebase.callable.rawRequest')] = req.rawRequest;
if (res?.signal) {
(context as Record)[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:
1. 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.
2. Genkit `JSON.stringify`s 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.
Contributor guide
Assessment
This issue has not been assessed yet.