Skip to content

Commit 39e5774

Browse files
authored
Fix stream serialization to resolve when user releases lock instead of waiting for stream to close (#678)
1 parent 61fdb41 commit 39e5774

5 files changed

Lines changed: 704 additions & 14 deletions

File tree

‎.changeset/stream-lock-polling.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@workflow/core": patch
3+
---
4+
5+
Fix stream serialization to resolve when user releases lock instead of waiting for stream to close. This prevents Vercel functions from hanging when users incrementally write to streams within steps (e.g., `await writer.write(data); writer.releaseLock()`). Uses a polling approach to detect when the stream lock is released and all pending writes are flushed.
6+

‎docs/content/docs/foundations/streaming.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -486,8 +486,8 @@ try {
486486
Stream locks acquired in a step only apply within that step, not across other steps. This enables multiple writers to write to the same stream concurrently.
487487
</Callout>
488488

489-
<Callout type="info">
490-
If a lock is not released, the step process cannot terminate. Even though the step returns and the workflow continues, the underlying process will remain active until it times out.
489+
<Callout type="warn">
490+
If a lock is not released, the step function's HTTP request cannot terminate. Even though the step returns and the workflow continues, the underlying request will remain active until it times out—wasting compute resources unnecessarily.
491491
</Callout>
492492

493493
**Close streams when done:**
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
createFlushableState,
4+
flushablePipe,
5+
LOCK_POLL_INTERVAL_MS,
6+
pollReadableLock,
7+
pollWritableLock,
8+
} from './flushable-stream.js';
9+
10+
describe('flushable stream behavior', () => {
11+
it('promise should resolve when writable stream lock is released (polling)', async () => {
12+
// Test the pattern: user writes, releases lock, polling detects it, promise resolves
13+
const chunks: string[] = [];
14+
let streamClosed = false;
15+
16+
// Create a simple mock for the sink
17+
const mockSink = new WritableStream<string>({
18+
write(chunk) {
19+
chunks.push(chunk);
20+
},
21+
close() {
22+
streamClosed = true;
23+
},
24+
});
25+
26+
// Create a TransformStream like we do in getStepRevivers
27+
const { readable, writable } = new TransformStream<string, string>();
28+
const state = createFlushableState();
29+
30+
// Start piping in background
31+
flushablePipe(readable, mockSink, state).catch(() => {
32+
// Errors handled via state.reject
33+
});
34+
35+
// Start polling for lock release
36+
pollWritableLock(writable, state);
37+
38+
// Simulate user interaction - write and release lock
39+
const userWriter = writable.getWriter();
40+
await userWriter.write('chunk1');
41+
await userWriter.write('chunk2');
42+
43+
// Release lock without closing stream
44+
userWriter.releaseLock();
45+
46+
// Wait for pipe to process + polling interval
47+
await new Promise((r) => setTimeout(r, LOCK_POLL_INTERVAL_MS + 50));
48+
49+
// The promise should resolve
50+
await expect(
51+
Promise.race([
52+
state.promise,
53+
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 400)),
54+
])
55+
).resolves.toBeUndefined();
56+
57+
// Chunks should have been written
58+
expect(chunks).toContain('chunk1');
59+
expect(chunks).toContain('chunk2');
60+
61+
// Stream should NOT be closed (user only released lock)
62+
expect(streamClosed).toBe(false);
63+
});
64+
65+
it('promise should resolve when writable stream closes naturally', async () => {
66+
const chunks: string[] = [];
67+
let streamClosed = false;
68+
69+
const mockSink = new WritableStream<string>({
70+
write(chunk) {
71+
chunks.push(chunk);
72+
},
73+
close() {
74+
streamClosed = true;
75+
},
76+
});
77+
78+
const { readable, writable } = new TransformStream<string, string>();
79+
const state = createFlushableState();
80+
81+
// Start piping in background
82+
flushablePipe(readable, mockSink, state).catch(() => {
83+
// Errors handled via state.reject
84+
});
85+
86+
// Start polling (won't trigger since stream will close first)
87+
pollWritableLock(writable, state);
88+
89+
// User writes and then closes the stream
90+
const userWriter = writable.getWriter();
91+
await userWriter.write('data');
92+
await userWriter.close();
93+
94+
// Wait a tick for the pipe to process
95+
await new Promise((r) => setTimeout(r, 50));
96+
97+
// The promise should resolve
98+
await expect(
99+
Promise.race([
100+
state.promise,
101+
new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 200)),
102+
])
103+
).resolves.toBeUndefined();
104+
105+
// Chunks should have been written
106+
expect(chunks).toContain('data');
107+
108+
// Stream should be closed (user closed it)
109+
expect(streamClosed).toBe(true);
110+
});
111+
112+
it('should handle write errors during pipe operations', async () => {
113+
const chunks: string[] = [];
114+
115+
// Create a sink that throws on write
116+
const mockSink = new WritableStream<string>({
117+
write(chunk) {
118+
chunks.push(chunk);
119+
if (chunk === 'error') {
120+
throw new Error('Write failed');
121+
}
122+
},
123+
});
124+
125+
const { readable, writable } = new TransformStream<string, string>();
126+
const state = createFlushableState();
127+
128+
// Store the flushablePipe promise so we can await it to ensure
129+
// all internal rejections are handled before the test ends
130+
const pipePromise = flushablePipe(readable, mockSink, state).catch(() => {
131+
// Errors handled via state.reject
132+
});
133+
134+
pollWritableLock(writable, state);
135+
136+
// Write data that will cause an error
137+
const userWriter = writable.getWriter();
138+
await userWriter.write('chunk1');
139+
// The write that triggers the error may reject on the userWriter side too
140+
// since the error propagates back through the transform stream
141+
await userWriter.write('error').catch(() => {
142+
// Expected - error propagates back through the transform stream
143+
});
144+
145+
// Wait for the pipe promise to settle to ensure all internal
146+
// promise rejections are handled before the test ends
147+
await pipePromise;
148+
149+
// The promise should be rejected
150+
await expect(state.promise).rejects.toThrow('Write failed');
151+
152+
// First chunk should have been written before error
153+
expect(chunks).toContain('chunk1');
154+
});
155+
156+
it('should test with pollReadableLock', async () => {
157+
// Create a readable stream that we can control
158+
let controller: ReadableStreamDefaultController<string>;
159+
const source = new ReadableStream<string>({
160+
start(c) {
161+
controller = c;
162+
},
163+
});
164+
165+
const chunks: string[] = [];
166+
const mockSink = new WritableStream<string>({
167+
write(chunk) {
168+
chunks.push(chunk);
169+
},
170+
});
171+
172+
const state = createFlushableState();
173+
174+
// Start piping in background
175+
flushablePipe(source, mockSink, state).catch(() => {
176+
// Errors handled via state.reject
177+
});
178+
179+
// Start polling for readable lock release
180+
pollReadableLock(source, state);
181+
182+
// Enqueue some data and then close
183+
controller?.enqueue('data1');
184+
controller?.enqueue('data2');
185+
controller?.close();
186+
187+
// Wait for the pipe to complete
188+
await new Promise((r) => setTimeout(r, 100));
189+
190+
// The promise should resolve
191+
await expect(state.promise).resolves.toBeUndefined();
192+
193+
// Chunks should have been written
194+
expect(chunks).toContain('data1');
195+
expect(chunks).toContain('data2');
196+
});
197+
198+
it('should handle concurrent writes correctly', async () => {
199+
const chunks: string[] = [];
200+
201+
const mockSink = new WritableStream<string>({
202+
write(chunk) {
203+
chunks.push(chunk);
204+
},
205+
});
206+
207+
const { readable, writable } = new TransformStream<string, string>();
208+
const state = createFlushableState();
209+
210+
// Start piping in background
211+
flushablePipe(readable, mockSink, state).catch(() => {
212+
// Errors handled via state.reject
213+
});
214+
215+
pollWritableLock(writable, state);
216+
217+
// Perform multiple concurrent writes
218+
const userWriter = writable.getWriter();
219+
await Promise.all([
220+
userWriter.write('chunk1'),
221+
userWriter.write('chunk2'),
222+
userWriter.write('chunk3'),
223+
]);
224+
225+
userWriter.releaseLock();
226+
227+
// Wait for polling to detect lock release
228+
await new Promise((r) => setTimeout(r, LOCK_POLL_INTERVAL_MS + 50));
229+
230+
// Promise should resolve
231+
await expect(state.promise).resolves.toBeUndefined();
232+
233+
// All chunks should be written
234+
expect(chunks).toHaveLength(3);
235+
expect(chunks).toContain('chunk1');
236+
expect(chunks).toContain('chunk2');
237+
expect(chunks).toContain('chunk3');
238+
});
239+
240+
it('should prevent multiple simultaneous polling operations on writable', async () => {
241+
const { readable, writable } = new TransformStream<string, string>();
242+
const mockSink = new WritableStream<string>();
243+
const state = createFlushableState();
244+
245+
// Start piping in background
246+
flushablePipe(readable, mockSink, state).catch(() => {});
247+
248+
// Start polling multiple times
249+
pollWritableLock(writable, state);
250+
pollWritableLock(writable, state);
251+
pollWritableLock(writable, state);
252+
253+
// Should only have one interval active
254+
expect(state.writablePollingInterval).toBeDefined();
255+
256+
// Write and release to clean up
257+
const userWriter = writable.getWriter();
258+
await userWriter.write('data');
259+
userWriter.releaseLock();
260+
261+
// Wait for cleanup
262+
await new Promise((r) => setTimeout(r, LOCK_POLL_INTERVAL_MS + 50));
263+
});
264+
265+
it('should prevent multiple simultaneous polling operations on readable', async () => {
266+
let controller: ReadableStreamDefaultController<string>;
267+
const source = new ReadableStream<string>({
268+
start(c) {
269+
controller = c;
270+
},
271+
});
272+
273+
const mockSink = new WritableStream<string>();
274+
const state = createFlushableState();
275+
276+
// Start piping in background
277+
flushablePipe(source, mockSink, state).catch(() => {});
278+
279+
// Start polling multiple times
280+
pollReadableLock(source, state);
281+
pollReadableLock(source, state);
282+
pollReadableLock(source, state);
283+
284+
// Should only have one interval active
285+
expect(state.readablePollingInterval).toBeDefined();
286+
287+
// Close to clean up
288+
controller?.close();
289+
290+
// Wait for cleanup
291+
await new Promise((r) => setTimeout(r, 100));
292+
});
293+
294+
it('should handle stream ending while pending operations are in flight', async () => {
295+
const chunks: string[] = [];
296+
let writeDelay = 0;
297+
298+
const mockSink = new WritableStream<string>({
299+
async write(chunk) {
300+
// Simulate slow write
301+
await new Promise((r) => setTimeout(r, writeDelay));
302+
chunks.push(chunk);
303+
},
304+
});
305+
306+
const { readable, writable } = new TransformStream<string, string>();
307+
const state = createFlushableState();
308+
309+
// Start piping in background
310+
flushablePipe(readable, mockSink, state).catch(() => {});
311+
312+
pollWritableLock(writable, state);
313+
314+
const userWriter = writable.getWriter();
315+
316+
// Write first chunk normally
317+
await userWriter.write('fast');
318+
319+
// Set delay for next write
320+
writeDelay = 100;
321+
322+
// Start slow write and immediately close
323+
const slowWrite = userWriter.write('slow');
324+
await userWriter.close();
325+
326+
// Wait for everything to complete
327+
await slowWrite;
328+
await new Promise((r) => setTimeout(r, 150));
329+
330+
// Promise should resolve
331+
await expect(state.promise).resolves.toBeUndefined();
332+
333+
// Both chunks should have been written
334+
expect(chunks).toContain('fast');
335+
expect(chunks).toContain('slow');
336+
});
337+
});

0 commit comments

Comments
 (0)