mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* 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>
59 lines
2.6 KiB
TypeScript
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');
|
|
});
|
|
});
|