firebase / firebase/firebase-functions

onCallGenkit drops rawRequest and CallableResponse from the Genkit ActionContext, blocking client-disconnect detection

Open
#1,888 2 comments 0 reactions 0 assignees View on GitHub
bug reproduced
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.