Files
open-design/apps/daemon/tests/vela-proxy-pipe-error.test.ts
cfc6ae0890 fix(daemon): harden the AMR proxy against unhandled pipe errors (#5512)
* fix(daemon): guard AMR proxy stream bodies against error crashes

The AMR/vela API proxy pipes the upstream response body to the client
(`upstreamRes.pipe(res)`) and the client upload body to the upstream
(`req.pipe(upstream)`). Only the outbound ClientRequest had an `'error'`
handler; neither piped stream body did. `.pipe()` does not forward a
source `'error'`, and a stream that emits `'error'` with no listener
throws — an unhandled exception that crashes the privileged daemon.

Both legs have real error paths: the upstream response body can
ECONNRESET mid-stream (a routine network drop), and the inbound request
body errors when a client aborts an upload. Either one takes the whole
daemon down today.

Route both pipes through `pipeProxyStreamWithGuard`, which attaches the
missing source-error handler: an upstream response error 502s (or
destroys the response once headers are sent), and a client-upload error
destroys the upstream request. Same class as #5126 (unhandled read-stream
error in the library asset route), different site.

Regression test pins the crash vector — a bare pipe throws on a source
error — against the guard routing it to the handler instead.

* chore: re-trigger fork-pr approval workflow (transient blip)

---------

Co-authored-by: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com>
2026-07-13 03:43:09 +00:00

59 lines
2.6 KiB
TypeScript

// Regression: the AMR/vela API proxy pipes the upstream response body to the
// client (`upstreamRes.pipe(res)`) and the client upload body to the upstream
// (`req.pipe(upstream)`). `.pipe()` does not forward a source `'error'`, and a
// stream that emits `'error'` with no listener throws — an unhandled exception
// that crashes the privileged daemon. A mid-stream upstream ECONNRESET, or a
// client aborting an upload, would therefore take the whole daemon down. Only
// the outbound ClientRequest had a handler; the two piped stream bodies did not.
//
// `pipeProxyStreamWithGuard` attaches the missing source-error handler. This
// spec pins the crash vector (a bare pipe throws on a source error) against the
// fix (the guard routes the error to the handler and nothing throws).
import { PassThrough } from 'node:stream';
import { describe, expect, it, vi } from 'vitest';
import { pipeProxyStreamWithGuard } from '../src/routes/vela.js';
const flush = () => new Promise((resolve) => setTimeout(resolve, 20));
describe('vela proxy pipe error guard', () => {
it('CONTROL: a bare pipe (the pre-fix shape) throws on a source error — the daemon-crash vector', () => {
const source = new PassThrough();
const dest = new PassThrough();
source.pipe(dest); // exactly what upstreamRes.pipe(res) / req.pipe(upstream) did
expect(source.listenerCount('error')).toBe(0);
// An 'error' with no listener throws (Node would surface this as an
// uncaughtException and kill the daemon).
expect(() => source.emit('error', new Error('ECONNRESET'))).toThrow('ECONNRESET');
});
it('routes a mid-stream source error to the handler instead of crashing', async () => {
const source = new PassThrough();
const dest = new PassThrough();
const onError = vi.fn();
pipeProxyStreamWithGuard(source, dest, onError);
expect(source.listenerCount('error')).toBe(1);
// The same error that threw above must now be delivered to the handler and
// must NOT throw (test completing is itself the no-crash proof).
expect(() => source.emit('error', new Error('ECONNRESET'))).not.toThrow();
await flush();
expect(onError).toHaveBeenCalledOnce();
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
});
it('still pipes the payload through on the happy path', async () => {
const source = new PassThrough();
const dest = new PassThrough();
const chunks: Buffer[] = [];
dest.on('data', (c: Buffer) => chunks.push(c));
pipeProxyStreamWithGuard(source, dest, () => {});
source.end(Buffer.from('hello'));
await flush();
expect(Buffer.concat(chunks).toString()).toBe('hello');
});
});