Skip to content

Commit 38b69b5

Browse files
committed
fix: address PR review comments for stdio discovery
- Add proper error handling for non-Error objects - Define MANIFEST_PREFIX and MANIFEST_ERROR_PREFIX constants - Fix HTTP discovery retry logic regression in tests - Make regex patterns multiline-safe for manifest and error parsing - Add timeout handling for stdio discovery in tests
1 parent 0c2da1f commit 38b69b5

File tree

9 files changed

+305
-6
lines changed

9 files changed

+305
-6
lines changed

.aider.chat.history.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# aider chat started at 2025-04-25 16:13:46
2+
3+
> Newer aider version v0.82.2 is available.
4+
> /Users/danielylee/.local/share/uv/tools/aider-chat/bin/python3 -m pip install --upgrade --upgrade-strategy only-if-needed aider-chat
5+
> Run pip install? (Y)es/(N)o [Yes]: y
6+
> Re-run aider to use new version.
7+
8+
# aider chat started at 2025-04-25 16:14:09
9+
10+
> You can skip this check with --no-gitignore
11+
> Add .aider\* to .gitignore (recommended)? (Y)es/(N)o [Yes]: n
12+
> /Users/danielylee/.local/bin/aider
13+
> Using gemini/gemini-2.5-pro-exp-03-25 model with API key from environment.
14+
> Aider v0.82.2
15+
> Main model: gemini/gemini-2.5-pro-exp-03-25 with diff-fenced edit format
16+
> Weak model: gemini/gemini-2.0-flash
17+
> Git repo: .git with 272 files
18+
> Repo-map: using 4096 tokens, auto refresh
19+
> https://aider.chat/HISTORY.html#release-notes
20+
> Would you like to see what's new in this version? (Y)es/(N)o [Yes]: y
21+
>
22+
> ^C again to exit
23+
24+
#### /add src/common/providers/https.ts
25+
26+
> Added src/common/providers/https.ts to the chat
27+
28+
#### I've received github issue like this:
29+
30+
####
31+
32+
#### I am testing the new streaming API for cloud functions in emulator.
33+
34+
#### Aborting streams with an AbortController on my client works fine.
35+
36+
####
37+
38+
#### How can the called function know that the user canceled the stream? The CallableResponse signal does not fire an event.
39+
40+
####
41+
42+
#### I want to pass an abort signal to the AI API I am calling in the function.
43+
44+
####
45+
46+
#### export const aiStreamText = onCall<AiStreamTextRequest>(
47+
48+
#### async (request, response) => {
49+
50+
#### response?.signal.addEventListener("abort", () => {
51+
52+
#### console.log(new Date().toISOString(), "on server: aborted by user"); // THIS DOES NOT FIRE ON ABORT
53+
54+
#### });
55+
56+
####
57+
58+
#### This logic should be available in @/src/common/providers/https.ts 's wrapOnCallHandler function. I'm curious why this might not be working - can you investigate the code and come up with understanding why this signal may not be triggering and abort event.
59+
60+
> Initial repo scan can be slow in larger repos, but only happens once.
61+
62+
Okay, I've analyzed the `wrapOnCallHandler` function in `src/common/providers/https.ts` regarding the `AbortSignal` behavior.
63+
64+
Here's the relevant flow:
65+
66+
1. An `AbortController` instance is created within `wrapOnCallHandler`.
67+
2. An event listener is attached to the `close` event of the underlying Express `response` object (`res`).
68+
3. When the `close` event fires (which should happen when the client disconnects), this listener calls `abortController.abort()`.
69+
4. The `signal` property of this `abortController` is passed to your handler function via the `response` argument (`response.signal`).
70+
5. Calling `abortController.abort()` should indeed trigger the `abort` event on its corresponding `signal`.
71+
72+
Based on the code, the intention is exactly as you expect: the `response.signal` should emit an `abort` event when the client connection closes.
73+
74+
If the `abort` event listener is not firing specifically in the emulator environment when the client aborts an SSE stream, the most likely reasons are:
75+
76+
1. **Emulator Event Handling:** The Firebase emulator might not be perfectly simulating the network conditions or not reliably triggering the `res.on('close', ...)` event handler when an SSE client disconnects abruptly. This seems the most probable cause if the code works correctly in a deployed environment.
77+
2. **Timing:** While less likely given your setup, if the handler function were to complete _before_ the `close` event is processed by Node.js/Express, the listener might not be active anymore. However, attaching the listener inside the async handler should prevent this.
78+
79+
The logic within `wrapOnCallHandler` itself appears correct for propagating the abort signal based on the `response`'s `close` event. The issue you're observing likely stems from how the emulator handles or surfaces that `close` event for streaming connections.
80+
81+
Since the SDK code seems logically correct for this mechanism, I cannot propose a specific code change within `https.ts` to fix what appears to be an emulator-specific behavior issue. You might want to check the Firebase emulator's GitHub repository for existing issues related to SSE connection handling or report this behavior if it's not already documented.
82+
83+
> Tokens: 14k sent, 460 received.
84+
>
85+
> ^C again to exit
86+
>
87+
> ^C KeyboardInterrupt
88+
89+
# aider chat started at 2025-05-07 11:25:45
90+
91+
> Newer aider version v0.82.3 is available.
92+
> /Users/danielylee/.local/share/uv/tools/aider-chat/bin/python3 -m pip install --upgrade --upgrade-strategy only-if-needed aider-chat
93+
> Run pip install? (Y)es/(N)o [Yes]: y
94+
> Re-run aider to use new version.

.aider.input.history

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
2+
# 2025-04-25 16:13:49.951230
3+
+y
4+
5+
# 2025-04-25 16:14:10.653143
6+
+n
7+
8+
# 2025-04-25 16:14:49.374087
9+
+/add src/common/providers/https.ts
10+
11+
# 2025-04-25 16:14:50.670790
12+
+I've received github issue like this:
13+
+
14+
+I am testing the new streaming API for cloud functions in emulator.
15+
+Aborting streams with an AbortController on my client works fine.
16+
+
17+
+How can the called function know that the user canceled the stream? The CallableResponse signal does not fire an event.
18+
+
19+
+I want to pass an abort signal to the AI API I am calling in the function.
20+
+
21+
+export const aiStreamText = onCall<AiStreamTextRequest>(
22+
+ async (request, response) => {
23+
+ response?.signal.addEventListener("abort", () => {
24+
+ console.log(new Date().toISOString(), "on server: aborted by user"); // THIS DOES NOT FIRE ON ABORT
25+
+ });
26+
+
27+
+This logic should be available in @/src/common/providers/https.ts 's wrapOnCallHandler function. I'm curious why this might not be working - can you investigate the code and come up with understanding why this signal may not be triggering and abort event.
Binary file not shown.

.aider.tags.cache.v4/cache.db

256 KB
Binary file not shown.

.gemini/logs.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[
2+
{
3+
"sessionId": 1748973879,
4+
"messageId": 0,
5+
"type": "user",
6+
"message": "I have a EventHandlerOptions somewhere in the codebase where it is missing documentation for eventFilters and eventFilterPathPatterns. Can you take a look and help come up with a reasonable documentation? Here's tangent documentation from EventArc which is the underlying thing that powers the event driven functions for reference: EventFilter\nFilters events based on exact matches on the CloudEvents attributes.\n\nJSON representation\n\n{\n \"attribute\": string,\n \"value\": string,\n \"operator\": string\n}\nFields\nattribute\t\nstring\n\nRequired. The name of a CloudEvents attribute. Currently, only a subset of attributes are supported for filtering. You can retrieve a specific provider's supported event types.\n\nAll triggers MUST provide a filter for the 'type' attribute.\n\nvalue\t\nstring\n\nRequired. The value for the attribute.\n\noperator\t\nstring\n\nOptional. The operator used for matching the events with the value of the filter. If not specified, only events that have an exact key-value pair specified in the filter are matched. The allowed values are path_pattern and match-path-pattern. path_pattern is only allowed for GCFv1 triggers.",
7+
"timestamp": "2025-06-03T18:06:50.454Z"
8+
},
9+
{
10+
"sessionId": 1748978654,
11+
"messageId": 0,
12+
"type": "user",
13+
"message": "sadasasdsa\\",
14+
"timestamp": "2025-06-03T19:24:15.923Z"
15+
},
16+
{
17+
"sessionId": 1748978654,
18+
"messageId": 1,
19+
"type": "user",
20+
"message": "dasda\\",
21+
"timestamp": "2025-06-03T19:24:21.455Z"
22+
},
23+
{
24+
"sessionId": 1748978654,
25+
"messageId": 2,
26+
"type": "user",
27+
"message": "sdsadasd\\",
28+
"timestamp": "2025-06-03T19:24:27.651Z"
29+
}
30+
]

scripts/bin-test/test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,12 @@ async function runHttpDiscovery(modulePath: string): Promise<DiscoveryResult> {
163163
return true;
164164
} catch (e: unknown) {
165165
const error = e as { code?: string };
166-
return error.code !== "ECONNREFUSED";
166+
if (error.code === "ECONNREFUSED") {
167+
// This is an expected error during server startup, so we should retry.
168+
return false;
169+
}
170+
// Any other error is unexpected and should fail the test immediately.
171+
throw e;
167172
}
168173
}, TIMEOUT_L);
169174

@@ -199,9 +204,15 @@ async function runStdioDiscovery(modulePath: string): Promise<DiscoveryResult> {
199204
stderr += chunk.toString("utf8");
200205
});
201206

207+
const timeoutId = setTimeout(() => {
208+
proc.kill(9);
209+
reject(new Error("Stdio discovery timed out after " + TIMEOUT_M + "ms"));
210+
}, TIMEOUT_M);
211+
202212
proc.on("close", () => {
213+
clearTimeout(timeoutId);
203214
// Try to parse manifest
204-
const manifestMatch = stderr.match(/__FIREBASE_FUNCTIONS_MANIFEST__:(.+)/);
215+
const manifestMatch = stderr.match(/__FIREBASE_FUNCTIONS_MANIFEST__:([\s\S]+)/);
205216
if (manifestMatch) {
206217
const base64 = manifestMatch[1];
207218
const manifestJson = Buffer.from(base64, "base64").toString("utf8");
@@ -211,7 +222,7 @@ async function runStdioDiscovery(modulePath: string): Promise<DiscoveryResult> {
211222
}
212223

213224
// Try to parse error
214-
const errorMatch = stderr.match(/__FIREBASE_FUNCTIONS_MANIFEST_ERROR__:(.+)/);
225+
const errorMatch = stderr.match(/__FIREBASE_FUNCTIONS_MANIFEST_ERROR__:([\s\S]+)/);
215226
if (errorMatch) {
216227
resolve({ success: false, error: errorMatch[1] });
217228
return;
@@ -221,6 +232,7 @@ async function runStdioDiscovery(modulePath: string): Promise<DiscoveryResult> {
221232
});
222233

223234
proc.on("error", (err) => {
235+
clearTimeout(timeoutId);
224236
reject(err);
225237
});
226238
});

spec/init.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// The MIT License (MIT)
2+
//
3+
// Copyright (c) 2025 Firebase
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in all
13+
// copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
// SOFTWARE.
22+
export const GCLOUD_PROJECT = "projct";
23+
process.env.GCLOUD_PROJECT = GCLOUD_PROJECT;

spec/v2/options.spec.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// The MIT License (MIT)
2+
//
3+
// Copyright (c) 2025 Firebase
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in all
13+
// copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
// SOFTWARE.
22+
23+
import { expect } from "chai";
24+
import * as sinon from "sinon";
25+
26+
import * as params from "../../src/params";
27+
import * as options from "../../src/v2/options";
28+
import { ResetValue, RESET_VALUE } from "../../src/common/options";
29+
import { GCLOUD_PROJECT } from "../init";
30+
31+
describe("optionsToEndpoint", () => {
32+
afterEach(() => {
33+
sinon.restore();
34+
});
35+
36+
it("should convert serviceAccount in shorthand form with @ to full email", () => {
37+
const opts: options.GlobalOptions = {
38+
serviceAccount: "my-service@",
39+
};
40+
41+
const result = options.optionsToEndpoint(opts);
42+
43+
expect(result.serviceAccountEmail).to.equal(
44+
`my-service@${GCLOUD_PROJECT}.iam.gserviceaccount.com`
45+
);
46+
});
47+
48+
it("should pass through serviceAccount that already has full email format", () => {
49+
const opts: options.GlobalOptions = {
50+
serviceAccount: "[email protected]",
51+
};
52+
53+
const result = options.optionsToEndpoint(opts);
54+
55+
expect(result.serviceAccountEmail).to.equal("[email protected]");
56+
});
57+
58+
it("should convert 'default' serviceAccount to null", () => {
59+
const opts: options.GlobalOptions = {
60+
serviceAccount: "default",
61+
};
62+
63+
const result = options.optionsToEndpoint(opts);
64+
65+
expect(result.serviceAccountEmail).to.be.null;
66+
});
67+
68+
it("should throw error when using shorthand without GCLOUD_PROJECT", () => {
69+
delete process.env.GCLOUD_PROJECT;
70+
const opts: options.GlobalOptions = {
71+
serviceAccount: "my-service@",
72+
};
73+
74+
expect(() => options.optionsToEndpoint(opts)).to.throw(
75+
"Unable to determine email for service account 'my-service@' because process.env.GCLOUD_PROJECT is not set."
76+
);
77+
process.env.GCLOUD_PROJECT = GCLOUD_PROJECT;
78+
});
79+
80+
it("should throw error for invalid serviceAccount format", () => {
81+
const opts: options.GlobalOptions = {
82+
serviceAccount: "invalid-format",
83+
};
84+
85+
expect(() => options.optionsToEndpoint(opts)).to.throw(
86+
"Invalid option for serviceAccount: 'invalid-format'. Valid options are 'default', a service account email, or '{serviceAccountName}@'"
87+
);
88+
});
89+
90+
it("should return if given RESET_VALUE for serviceAccount", () => {
91+
const opts: options.GlobalOptions = {
92+
serviceAccount: RESET_VALUE,
93+
};
94+
95+
const result = options.optionsToEndpoint(opts);
96+
97+
expect(result.serviceAccountEmail).to.be.instanceof(ResetValue);
98+
});
99+
100+
it("should pass through Expression for serviceAccount", () => {
101+
const param = params.defineString("sa");
102+
const opts: options.GlobalOptions = {
103+
serviceAccount: param,
104+
};
105+
106+
const result = options.optionsToEndpoint(opts);
107+
108+
expect(result.serviceAccountEmail).to.equal(param);
109+
});
110+
});

src/bin/firebase-functions.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,21 @@ if (args.length > 1) {
4949
functionsDir = args[0];
5050
}
5151

52+
const MANIFEST_PREFIX = "__FIREBASE_FUNCTIONS_MANIFEST__:";
53+
const MANIFEST_ERROR_PREFIX = "__FIREBASE_FUNCTIONS_MANIFEST_ERROR__:";
54+
5255
async function runStdioDiscovery() {
5356
try {
5457
const stack = await loadStack(functionsDir);
5558
const wireFormat = stackToWire(stack);
5659
const manifestJson = JSON.stringify(wireFormat);
5760
const base64 = Buffer.from(manifestJson).toString("base64");
58-
process.stderr.write(`__FIREBASE_FUNCTIONS_MANIFEST__:${base64}\n`);
61+
process.stderr.write(`${MANIFEST_PREFIX}${base64}\n`);
5962
process.exit(0);
6063
} catch (e) {
6164
console.error("Failed to generate manifest from function source:", e);
62-
const errorMessage = e instanceof Error ? e.message : String(e);
63-
process.stderr.write(`__FIREBASE_FUNCTIONS_MANIFEST_ERROR__:${errorMessage}\n`);
65+
const message = e instanceof Error ? e.message : String(e);
66+
process.stderr.write(`${MANIFEST_ERROR_PREFIX}${message}\n`);
6467
process.exit(1);
6568
}
6669
}

0 commit comments

Comments
 (0)