Skip to content

Commit 1e2a989

Browse files
#175 feat: support Lambda response streaming end-to-end
The non-direct invoke path used by the local Runtime Interface Emulator (the one SAM CLI talks to over /2015-03-31/functions/function/invocations) was buffering the runtime's /response body via io.ReadAll before writing a single slab back to the caller, and the rie.ResponseWriterProxy was storing that slab in a []byte. Both layers had to drain before the caller saw any byte, which made Server-Sent Events and other Lambda response-streaming workloads impossible to test locally. This change wires the real http.ResponseWriter all the way down: * rie.ResponseWriterProxy gains an Underlying http.ResponseWriter and becomes a streaming pass-through that copies staged headers/status on the first Write, then forwards every Write to the underlying writer and Flushes after each one. The body buffer is still kept for the pre-stream error paths in InvokeHandler. * rapidcore.Server.sendResponseUnsafe now detects streaming responses via additionalHeaders[Lambda-Runtime-Function-Response-Mode] (matched case-insensitively, since the runtime sends "streaming" and the interop constant is "Streaming") and uses a new streamingCopy helper that pipes the runtime's POST body through to the reply stream with a tiny 4KiB buffer and an explicit Flush after every Write. The buffered (legacy) branch is preserved and continues to enforce interop.MaxPayloadSize for non-streaming responses. * rie.InvokeHandler creates the proxy with the real ResponseWriter and skips the trailing WriteHeader/Write if streaming has already begun. Error code paths that would emit a synthetic body are gated on the new Started flag so we never try to overwrite an in-flight stream. End-to-end test: a Node.js handler using awslambda.streamifyResponse that emits one SSE frame per second for 10 seconds now reaches an EventSource client (and curl -N) one frame at a time, with the JSON HTTP-integration prelude correctly forwarded to the caller via the Content-Type and Lambda-Runtime-Function-Response-Mode headers. All existing unit tests under internal/lambda/... still pass. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 391c3f1 commit 1e2a989

3 files changed

Lines changed: 215 additions & 25 deletions

File tree

internal/lambda/rapidcore/server.go

Lines changed: 105 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"io"
1212
"math"
1313
"net/http"
14+
"strings"
1415
"sync"
1516
"time"
1617

@@ -288,6 +289,46 @@ func (s *Server) SetInternalStateGetter(cb interop.InternalStateGetter) {
288289
s.InternalStateGetter = cb
289290
}
290291

292+
// streamingCopy pumps bytes from src to dst with a small intermediate buffer
293+
// and an explicit Flush after every Write. This is the heart of the
294+
// response-streaming pass-through used by the local Runtime Interface
295+
// Emulator: every chunk the runtime writes to its /response HTTP body is
296+
// promptly forwarded to the caller (e.g. SAM CLI proxy → browser) instead of
297+
// being buffered until end-of-stream.
298+
//
299+
// The buffer is intentionally tiny so SSE frames (often only tens of bytes)
300+
// are not coalesced into larger reads when the runtime flushes one event at
301+
// a time. io.Copy's default 32KiB buffer would, in combination with TCP-level
302+
// merging, defeat that goal.
303+
func streamingCopy(dst io.Writer, src io.Reader) (int64, error) {
304+
const bufSize = 4096
305+
buf := make([]byte, bufSize)
306+
flusher, _ := dst.(http.Flusher)
307+
var total int64
308+
for {
309+
nr, rerr := src.Read(buf)
310+
if nr > 0 {
311+
nw, werr := dst.Write(buf[:nr])
312+
total += int64(nw)
313+
if werr != nil {
314+
return total, werr
315+
}
316+
if nw < nr {
317+
return total, io.ErrShortWrite
318+
}
319+
if flusher != nil {
320+
flusher.Flush()
321+
}
322+
}
323+
if rerr == io.EOF {
324+
return total, nil
325+
}
326+
if rerr != nil {
327+
return total, rerr
328+
}
329+
}
330+
}
331+
291332
func (s *Server) sendResponseUnsafe(invokeID string, additionalHeaders map[string]string, payload io.Reader, trailers http.Header, request *interop.CancellableRequest, runtimeCalledResponse bool) error {
292333
if s.invokeCtx == nil || invokeID != s.invokeCtx.Token.InvokeID {
293334
return interop.ErrInvalidInvokeID
@@ -309,37 +350,81 @@ func (s *Server) sendResponseUnsafe(invokeID string, additionalHeaders map[strin
309350
reportedErr = err
310351
}
311352
} else {
312-
data, err := io.ReadAll(payload)
313-
if err != nil {
314-
return fmt.Errorf("Failed to read response on %s: %s", invokeID, err)
353+
// Determine whether the runtime told us this is a streaming
354+
// response. Streaming runtimes (e.g. Node.js awslambda.streamifyResponse)
355+
// post chunks of bytes over time and rely on the platform to forward
356+
// each chunk to the caller as it arrives. The header lives in
357+
// additionalHeaders because the runtime API handler
358+
// (rapi/handler/invocationresponse.go) parses it from the runtime's
359+
// POST /response request.
360+
// The runtime API handler stores the response mode the runtime sent
361+
// (e.g. Node.js "streamifyResponse" sends `streaming`, lowercase),
362+
// while the interop constant has the capitalized form `Streaming`.
363+
// Compare case-insensitively so we recognize streaming responses
364+
// regardless of which casing the runtime used.
365+
functionResponseMode := additionalHeaders[directinvoke.FunctionResponseModeHeader]
366+
isStreaming := strings.EqualFold(functionResponseMode, string(interop.FunctionResponseModeStreaming))
367+
368+
startReadingResponseMonoTimeMs := metering.Monotime()
369+
370+
// Set Content-Type before any byte is written so the reply stream
371+
// can commit headers on the first Write.
372+
if ct, ok := additionalHeaders[directinvoke.ContentTypeHeader]; ok && ct != "" {
373+
s.invokeCtx.ReplyStream.Header().Add(directinvoke.ContentTypeHeader, ct)
315374
}
316-
if len(data) > interop.MaxPayloadSize {
317-
return &interop.ErrorResponseTooLarge{
318-
ResponseSize: len(data),
319-
MaxResponseSize: interop.MaxPayloadSize,
375+
if isStreaming {
376+
// Advertise the function response mode to the caller (SAM CLI)
377+
// so it can switch into streaming pass-through itself.
378+
s.invokeCtx.ReplyStream.Header().Add(directinvoke.FunctionResponseModeHeader, functionResponseMode)
379+
}
380+
381+
var written int64
382+
var copyErr error
383+
if isStreaming {
384+
// Pump bytes from the runtime's /response request body straight
385+
// through to the caller. A small buffer is used so even tiny
386+
// chunks (e.g. SSE frames a few dozen bytes long) flow with
387+
// minimal latency. Each Write on ReplyStream is flushed by the
388+
// rie.ResponseWriterProxy when an http.Flusher is available.
389+
//
390+
// Streaming responses do not enforce interop.MaxPayloadSize:
391+
// Lambda response streaming is explicitly designed for payloads
392+
// larger than 6MB and for unbounded SSE-style streams.
393+
written, copyErr = streamingCopy(s.invokeCtx.ReplyStream, payload)
394+
} else {
395+
// Buffered legacy path: collect the whole body, enforce the
396+
// 6MB response cap, and emit it in one Write.
397+
data, err := io.ReadAll(payload)
398+
if err != nil {
399+
return fmt.Errorf("Failed to read response on %s: %s", invokeID, err)
320400
}
401+
if len(data) > interop.MaxPayloadSize {
402+
return &interop.ErrorResponseTooLarge{
403+
ResponseSize: len(data),
404+
MaxResponseSize: interop.MaxPayloadSize,
405+
}
406+
}
407+
n, err := s.invokeCtx.ReplyStream.Write(data)
408+
written = int64(n)
409+
copyErr = err
410+
}
411+
if copyErr != nil {
412+
return fmt.Errorf("Failed to write response to %s: %s", invokeID, copyErr)
321413
}
322414

323-
startReadingResponseMonoTimeMs := metering.Monotime()
324-
s.invokeCtx.ReplyStream.Header().Add(directinvoke.ContentTypeHeader, additionalHeaders[directinvoke.ContentTypeHeader])
325-
written, err := s.invokeCtx.ReplyStream.Write(data)
326-
if err != nil {
327-
return fmt.Errorf("Failed to write response to %s: %s", invokeID, err)
415+
responseMode := interop.FunctionResponseModeBuffered
416+
if isStreaming {
417+
responseMode = interop.FunctionResponseModeStreaming
328418
}
329419

330420
s.sendResponseChan <- &interop.InvokeResponseMetrics{
331-
ProducedBytes: int64(written),
421+
ProducedBytes: written,
332422
StartReadingResponseMonoTimeMs: startReadingResponseMonoTimeMs,
333423
FinishReadingResponseMonoTimeMs: metering.Monotime(),
334424
TimeShapedNs: int64(-1),
335425
OutboundThroughputBps: int64(-1),
336-
// FIXME:
337-
// The runtime tells whether the function response mode is streaming or not.
338-
// Ideally, we would want to use that value here. Since I'm just rebasing, I will leave
339-
// as-is, but we should use that instead of relying on our memory to set this here
340-
// because we "know" it's a streaming code path.
341-
FunctionResponseMode: interop.FunctionResponseModeBuffered,
342-
RuntimeCalledResponse: runtimeCalledResponse,
426+
FunctionResponseMode: responseMode,
427+
RuntimeCalledResponse: runtimeCalledResponse,
343428
}
344429
}
345430

internal/lambda/rie/handlers.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,12 @@ func InvokeHandler(w http.ResponseWriter, r *http.Request, sandbox Sandbox, bs i
151151
}
152152
fmt.Println("START RequestId: " + invokePayload.ID + " Version: " + functionVersion)
153153

154-
// If we write to 'w' directly and waitUntilRelease fails, we won't be able to propagate error anymore
155-
invokeResp := &ResponseWriterProxy{}
154+
// We forward writes to 'w' through the proxy so that response chunks
155+
// produced by the runtime (Lambda response streaming / SSE) are flushed
156+
// to the caller as soon as they arrive. The proxy still keeps a copy of
157+
// the body it has not committed yet so that error code paths below can
158+
// emit a synthetic error response when streaming has not started.
159+
invokeResp := &ResponseWriterProxy{Underlying: w}
156160
if err := sandbox.Invoke(invokeResp, invokePayload); err != nil {
157161
switch err {
158162

@@ -165,6 +169,11 @@ func InvokeHandler(w http.ResponseWriter, r *http.Request, sandbox Sandbox, bs i
165169
w.WriteHeader(http.StatusInternalServerError)
166170
return
167171
case rapidcore.ErrInitDoneFailed:
172+
if invokeResp.Started {
173+
// Streaming response was already partially emitted; the
174+
// connection is the only signal we can give the caller now.
175+
return
176+
}
168177
w.WriteHeader(http.StatusBadGateway)
169178
w.Write(invokeResp.Body)
170179
return
@@ -188,6 +197,9 @@ func InvokeHandler(w http.ResponseWriter, r *http.Request, sandbox Sandbox, bs i
188197
return
189198
// AwaitRelease errors:
190199
case rapidcore.ErrInvokeDoneFailed:
200+
if invokeResp.Started {
201+
return
202+
}
191203
w.WriteHeader(http.StatusBadGateway)
192204
w.Write(invokeResp.Body)
193205
return
@@ -208,6 +220,13 @@ func InvokeHandler(w http.ResponseWriter, r *http.Request, sandbox Sandbox, bs i
208220

209221
printEndReports(invokePayload.ID, initDuration, memorySize, invokeStart, timeoutDuration)
210222

223+
// If streaming has already started, the runtime's /response body has
224+
// been forwarded chunk-by-chunk to the caller through invokeResp; do
225+
// not attempt to (re)write headers/status nor double-emit the body.
226+
if invokeResp.Started {
227+
return
228+
}
229+
211230
if invokeResp.StatusCode != 0 {
212231
w.WriteHeader(invokeResp.StatusCode)
213232
}

internal/lambda/rie/util.go

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,93 @@ func (t ErrorType) String() string {
2222
return fmt.Sprintf("Cannot stringify standalone.ErrorType.%d", int(t))
2323
}
2424

25+
// ResponseWriterProxy is the http.ResponseWriter wrapper used between the
26+
// rapidcore Server and the real caller-facing connection.
27+
//
28+
// Historically it acted as a tiny buffer: it captured a single status code and
29+
// a single body slab so that, after the invoke returned, the rie.InvokeHandler
30+
// could decide whether to forward the captured response or to overwrite it
31+
// with an error of its own. That design forced the runtime's /response body to
32+
// be fully read before any byte was written to the caller, which made
33+
// Lambda response streaming (e.g. Server-Sent Events) impossible.
34+
//
35+
// The proxy can now optionally hold an Underlying http.ResponseWriter. When
36+
// set, writes are forwarded straight to the underlying writer and immediately
37+
// flushed if the writer implements http.Flusher, while still keeping a copy of
38+
// the headers/status that we accumulate before the first write so we can
39+
// commit them on demand. Errors paths that fire BEFORE any data has been
40+
// streamed (Started == false) keep their existing behavior of being able to
41+
// write a synthetic response. Once Started is true, the rie.InvokeHandler
42+
// must not attempt to set headers or status again on the underlying writer.
2543
type ResponseWriterProxy struct {
44+
// Body keeps a copy of any bytes written while we have not yet committed
45+
// to streaming (Underlying == nil or Started == false). It is preserved
46+
// for backward compatibility with error code paths in rie.InvokeHandler
47+
// that re-emit invokeResp.Body on failure. Once we start streaming we
48+
// stop accumulating to avoid unbounded memory growth.
2649
Body []byte
2750
StatusCode int
51+
52+
// Underlying, when non-nil, is the real http.ResponseWriter we forward
53+
// writes to. Setting this enables true response streaming: every Write
54+
// passes straight through and triggers a Flush.
55+
Underlying http.ResponseWriter
56+
57+
// headers accumulates Header().Add(...) calls until the first Write.
58+
// On the first Write they are copied onto Underlying.Header().
59+
headers http.Header
60+
61+
// Started is set to true after the first Write that has been committed
62+
// to Underlying. Once true, headers and status are locked.
63+
Started bool
2864
}
2965

66+
// Header returns the staged headers map. When streaming has not yet started
67+
// these are local to the proxy; once Started, they are merged onto the real
68+
// writer's header map.
3069
func (w *ResponseWriterProxy) Header() http.Header {
31-
return http.Header{}
70+
if w.Started && w.Underlying != nil {
71+
return w.Underlying.Header()
72+
}
73+
if w.headers == nil {
74+
w.headers = make(http.Header)
75+
}
76+
return w.headers
3277
}
3378

79+
// Write forwards bytes to the underlying writer when available, flushing
80+
// after each chunk so that callers (browsers consuming SSE) see chunks as
81+
// soon as the runtime emits them.
3482
func (w *ResponseWriterProxy) Write(b []byte) (int, error) {
35-
w.Body = b
36-
return 0, nil
83+
if w.Underlying != nil {
84+
if !w.Started {
85+
// Promote staged headers + status onto the underlying writer.
86+
if w.headers != nil {
87+
underlyingHeaders := w.Underlying.Header()
88+
for k, vs := range w.headers {
89+
for _, v := range vs {
90+
underlyingHeaders.Add(k, v)
91+
}
92+
}
93+
}
94+
if w.StatusCode != 0 {
95+
w.Underlying.WriteHeader(w.StatusCode)
96+
}
97+
w.Started = true
98+
}
99+
n, err := w.Underlying.Write(b)
100+
if f, ok := w.Underlying.(http.Flusher); ok {
101+
f.Flush()
102+
}
103+
return n, err
104+
}
105+
106+
// No streaming target: behave like the original buffer-everything proxy
107+
// (note: the original returned (0, nil) which is technically a violation
108+
// of io.Writer; we return len(b) to be correct, since callers like
109+
// io.Copy interpret a short write as an error).
110+
w.Body = append(w.Body, b...)
111+
return len(b), nil
37112
}
38113

39114
func (w *ResponseWriterProxy) WriteHeader(statusCode int) {
@@ -43,3 +118,14 @@ func (w *ResponseWriterProxy) WriteHeader(statusCode int) {
43118
func (w *ResponseWriterProxy) IsError() bool {
44119
return w.StatusCode != 0 && w.StatusCode/100 != 2
45120
}
121+
122+
// Flush implements http.Flusher so that callers performing io.Copy on us
123+
// (or wrapping us) can drive intermediate flushes too.
124+
func (w *ResponseWriterProxy) Flush() {
125+
if w.Underlying == nil {
126+
return
127+
}
128+
if f, ok := w.Underlying.(http.Flusher); ok {
129+
f.Flush()
130+
}
131+
}

0 commit comments

Comments
 (0)