|
1 | | -import { describe, it, expect } from 'vitest'; |
| 1 | +import { describe, it, expect, vi } from 'vitest'; |
2 | 2 | import { EventEmitter } from 'node:events'; |
3 | | -import { guardConsoleStreams } from '../stream-guards'; |
| 3 | +import { guardConsoleStreams, guardProxyStreams } from '../stream-guards'; |
4 | 4 |
|
5 | 5 | // Fails-before / passes-after: a Node EventEmitter throws on emit('error') when there is NO |
6 | 6 | // 'error' listener. That is exactly why an EPIPE on stdout crashed the main process. The guard |
@@ -28,3 +28,54 @@ describe('guardConsoleStreams', () => { |
28 | 28 | expect(() => b.emit('error', new Error('y'))).not.toThrow(); |
29 | 29 | }); |
30 | 30 | }); |
| 31 | + |
| 32 | +// Regression for the proxyToLlama mid-stream crash: proxyRes.pipe(res) wired the pipe but neither |
| 33 | +// stream had an 'error' listener, so an upstream reset (llama-server aborting mid-stream) or a |
| 34 | +// client disconnect turned the unhandled 'error' event into an uncaught exception that took the |
| 35 | +// whole main process down. guardProxyStreams installs listeners on both ends. |
| 36 | +describe('guardProxyStreams', () => { |
| 37 | + it('makes an upstream reset non-fatal and tears down the client response (would throw without the guard)', () => { |
| 38 | + const upstream = new EventEmitter(); |
| 39 | + const client = Object.assign(new EventEmitter(), { destroy: vi.fn() }); |
| 40 | + const reset = Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }); |
| 41 | + |
| 42 | + // Sanity: before guarding, an upstream 'error' with no listener throws (the crash we saw). |
| 43 | + expect(() => upstream.emit('error', reset)).toThrow(/ECONNRESET/); |
| 44 | + |
| 45 | + // After guarding, the same emit is swallowed and the client response is destroyed to free it. |
| 46 | + const guarded = guardProxyStreams(upstream, client); |
| 47 | + expect(guarded).toBe(2); |
| 48 | + expect(() => upstream.emit('error', reset)).not.toThrow(); |
| 49 | + expect(client.destroy).toHaveBeenCalledTimes(1); |
| 50 | + }); |
| 51 | + |
| 52 | + it('makes a client disconnect non-fatal AND tears down the upstream (stops reading llama-server)', () => { |
| 53 | + const upstream = Object.assign(new EventEmitter(), { destroy: vi.fn() }); |
| 54 | + const client = new EventEmitter(); |
| 55 | + const guarded = guardProxyStreams(upstream, client); |
| 56 | + expect(guarded).toBe(2); |
| 57 | + // A client disconnect must not throw... |
| 58 | + expect(() => client.emit('error', new Error('EPIPE'))).not.toThrow(); |
| 59 | + // ...and must destroy the upstream, else proxyRes.pipe(res) keeps draining llama-server |
| 60 | + // after the client is gone (wasted local inference + a held socket). |
| 61 | + expect(upstream.destroy).toHaveBeenCalledTimes(1); |
| 62 | + }); |
| 63 | + |
| 64 | + it('a client disconnect with no upstream is still non-fatal', () => { |
| 65 | + const client = new EventEmitter(); |
| 66 | + guardProxyStreams(undefined, client); |
| 67 | + expect(() => client.emit('error', new Error('EPIPE'))).not.toThrow(); |
| 68 | + }); |
| 69 | + |
| 70 | + it('tolerates a client with no destroy method (does not throw when tearing down)', () => { |
| 71 | + const upstream = new EventEmitter(); |
| 72 | + const client = new EventEmitter(); // no destroy() |
| 73 | + expect(guardProxyStreams(upstream, client)).toBe(2); |
| 74 | + expect(() => upstream.emit('error', new Error('reset'))).not.toThrow(); |
| 75 | + }); |
| 76 | + |
| 77 | + it('skips undefined streams and counts only the guarded ones', () => { |
| 78 | + expect(guardProxyStreams(undefined, undefined)).toBe(0); |
| 79 | + expect(guardProxyStreams(new EventEmitter(), undefined)).toBe(1); |
| 80 | + }); |
| 81 | +}); |
0 commit comments