Skip to content

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

@bbyiringiro

Description

@bbyiringiro

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

  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):

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:

  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.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions