Skip to content

Commit 7c9b211

Browse files
Merge pull request #41 from off-grid-ai/fix/review-followup-proxyres-dims
fix: review follow-up — proxy stream guard, all-branch dim clamp, safer scripts
2 parents b367675 + 9e102f8 commit 7c9b211

7 files changed

Lines changed: 143 additions & 17 deletions

File tree

scripts/smoke-api.sh

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,21 @@
1010
# (slower). Exits non-zero on the first failure so it works as a CI/pre-release gate.
1111
set -uo pipefail
1212
GW="${OFFGRID_GATEWAY_URL:-http://127.0.0.1:7878}"
13+
14+
# Per-run temp files via mktemp (not fixed /tmp paths) - avoids a TOCTOU/symlink hijack on a
15+
# predictable name and lets concurrent runs coexist. Cleaned up on any exit.
16+
MODELS_JSON="$(mktemp -t smoke_models.XXXXXX)"
17+
STREAM_TXT="$(mktemp -t smoke_stream.XXXXXX)"
18+
trap 'rm -f "$MODELS_JSON" "$STREAM_TXT"' EXIT
19+
1320
fail() { echo " FAIL: $1"; exit 1; }
1421
ok() { echo " ok: $1"; }
1522

1623
echo "[smoke] gateway = $GW"
1724

1825
# 0. Gateway reachable + model catalog (models-manager path)
19-
curl -s -m 5 "$GW/v1/models" >/tmp/smoke_models.json 2>&1 || fail "gateway not reachable - start the app first (npm run dev)"
20-
node -e 'const j=require("/tmp/smoke_models.json");if(!j.data||!j.data.length)process.exit(1);console.log(" models:",j.data.map(m=>m.id).join(", "))' \
26+
curl -s -m 5 "$GW/v1/models" >"$MODELS_JSON" 2>&1 || fail "gateway not reachable - start the app first (npm run dev)"
27+
MODELS_JSON="$MODELS_JSON" node -e 'const j=require(process.env.MODELS_JSON);if(!j.data||!j.data.length)process.exit(1);console.log(" models:",j.data.map(m=>m.id).join(", "))' \
2128
< /dev/null 2>/dev/null || fail "/v1/models returned no models"
2229
ok "/v1/models"
2330

@@ -29,8 +36,8 @@ ok "chat non-stream"
2936

3037
# 2. Chat stream (SSE proxy + retry refactor) - count content OR reasoning deltas
3138
curl -sN -m 120 "$GW/v1/chat/completions" -H 'Content-Type: application/json' \
32-
-d '{"messages":[{"role":"user","content":"Count one to five."}],"max_tokens":48,"stream":true}' > /tmp/smoke_stream.txt 2>&1
33-
node -e 'const fs=require("fs");let n=0;for(const l of fs.readFileSync("/tmp/smoke_stream.txt","utf8").split("\n")){if(!l.startsWith("data:"))continue;const p=l.slice(5).trim();if(p==="[DONE]")continue;try{const d=JSON.parse(p).choices?.[0]?.delta||{};if(d.content||d.reasoning_content)n++;}catch(e){}}if(n<1)process.exit(1);console.log(" stream deltas:",n)' || fail "chat stream produced no deltas"
39+
-d '{"messages":[{"role":"user","content":"Count one to five."}],"max_tokens":48,"stream":true}' > "$STREAM_TXT" 2>&1
40+
STREAM_TXT="$STREAM_TXT" node -e 'const fs=require("fs");let n=0;for(const l of fs.readFileSync(process.env.STREAM_TXT,"utf8").split("\n")){if(!l.startsWith("data:"))continue;const p=l.slice(5).trim();if(p==="[DONE]")continue;try{const d=JSON.parse(p).choices?.[0]?.delta||{};if(d.content||d.reasoning_content)n++;}catch(e){}}if(n<1)process.exit(1);console.log(" stream deltas:",n)' || fail "chat stream produced no deltas"
3441
ok "chat stream"
3542

3643
# 3. Embeddings

scripts/test-db.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
# Kept OUT of the default `npm test` (those files are *.dbtest.ts, not *.test.ts) precisely
99
# because of this ABI swap. Run it explicitly: `npm run test:db`. A CI job that runs it must
1010
# do so in an isolated step (the rebuild mutates node_modules).
11-
set -uo pipefail
11+
# -e: abort on any failure (e.g. `npm rebuild` failing) so we never run the tests against a stale
12+
# ABI. The EXIT trap below still fires on abort, so Electron's build is restored either way.
13+
set -euo pipefail
1214

1315
restore() {
1416
echo "[test:db] restoring the Electron ABI build of better-sqlite3-multiple-ciphers..."

src/main/__tests__/stream-guards.test.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, vi } from 'vitest';
22
import { EventEmitter } from 'node:events';
3-
import { guardConsoleStreams } from '../stream-guards';
3+
import { guardConsoleStreams, guardProxyStreams } from '../stream-guards';
44

55
// Fails-before / passes-after: a Node EventEmitter throws on emit('error') when there is NO
66
// 'error' listener. That is exactly why an EPIPE on stdout crashed the main process. The guard
@@ -28,3 +28,54 @@ describe('guardConsoleStreams', () => {
2828
expect(() => b.emit('error', new Error('y'))).not.toThrow();
2929
});
3030
});
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+
});

src/main/model-server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { llm, type LlmSettings } from './llm';
3737
import { LLAMA_SERVER_PORT, GATEWAY_PORT } from '../shared/ports';
3838
import { retryWithDeadline } from './lib/retry';
3939
import { resolveDims } from './model-server/dimensions';
40+
import { guardProxyStreams } from './stream-guards';
4041
import { classifyRef, decodeDataUrl, stripFileScheme, mimeFromExt, extForMime, toDataUrl } from './model-server/data-url';
4142
import { errBody, errMeta } from './model-server/errors';
4243
import { isAsync, matchPollRoute } from './model-server/async-request';
@@ -178,6 +179,11 @@ function proxyToLlama(
178179
{ hostname: UPSTREAM_HOST, port: UPSTREAM_PORT, path: req.url, method: req.method, headers },
179180
(proxyRes) => {
180181
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
182+
// Guard both ends BEFORE piping: a mid-stream reset from llama-server (or a client
183+
// disconnect) emits 'error' on these streams, and with no listener that becomes an
184+
// uncaught exception that crashes the main process. Does not re-settle this promise —
185+
// it has already resolved once piping begins.
186+
guardProxyStreams(proxyRes, res);
181187
proxyRes.pipe(res);
182188
resolve();
183189
}

src/main/model-server/__tests__/dimensions.test.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ describe('round64', () => {
2626
expect(round64(1024)).toBe(1024);
2727
expect(round64(512)).toBe(512);
2828
});
29+
30+
it('rejects non-finite input (NaN/Infinity clamp to the 256 floor, never leak NaN)', () => {
31+
expect(round64(NaN)).toBe(256);
32+
expect(round64(Infinity)).toBe(256);
33+
expect(round64(-Infinity)).toBe(256);
34+
});
2935
});
3036

3137
describe('parseSize', () => {
@@ -55,19 +61,35 @@ describe('parseSize', () => {
5561
});
5662

5763
describe('resolveDims', () => {
58-
it('uses explicit numeric width/height as-is (no rounding)', () => {
59-
expect(resolveDims({ width: 500, height: 700 })).toEqual({ width: 500, height: 700 });
64+
it('rounds explicit numeric width/height to /64 (every branch is rounded)', () => {
65+
// 500 -> 512, 700 -> 704 (700/64 = 10.9 -> 11 -> 704)
66+
expect(resolveDims({ width: 500, height: 700 })).toEqual({ width: 512, height: 704 });
67+
});
68+
69+
it('clamps out-of-range explicit width/height instead of passing raw values through', () => {
70+
// Regression: the explicit width/height branch used to return raw values, so a caller could
71+
// slip 10000 / 8 straight into image generation. Now clamped to 256-2048.
72+
expect(resolveDims({ width: 10000, height: 8 })).toEqual({ width: 2048, height: 256 });
73+
});
74+
75+
it('rejects NaN explicit width/height (clamps to the 256 floor, never leaks NaN)', () => {
76+
expect(resolveDims({ width: NaN, height: NaN })).toEqual({ width: 256, height: 256 });
6077
});
6178

6279
it('ignores explicit width/height when either is not a number', () => {
6380
// falls through to size
6481
expect(resolveDims({ width: 500, height: '700', size: '256x256' })).toEqual({ width: 256, height: 256 });
6582
});
6683

67-
it('parses an OpenAI size string when width/height absent', () => {
84+
it('parses an OpenAI size string when width/height absent (and rounds it)', () => {
6885
expect(resolveDims({ size: '768x512' })).toEqual({ width: 768, height: 512 });
6986
});
7087

88+
it('rounds and clamps an out-of-range size string (the size branch is rounded too)', () => {
89+
// Regression: the size-string branch used to return raw parsed values. 500 -> 512, 9000 -> 2048.
90+
expect(resolveDims({ size: '500x9000' })).toEqual({ width: 512, height: 2048 });
91+
});
92+
7193
it('derives square dims from a 1:1 aspect ratio at the default 1K resolution', () => {
7294
expect(resolveDims({ aspect_ratio: '1:1' })).toEqual({ width: 1024, height: 1024 });
7395
});
@@ -100,10 +122,11 @@ describe('resolveDims', () => {
100122
expect(resolveDims({ size: 'nope', aspect_ratio: 'nope' })).toEqual({});
101123
});
102124

103-
it('prefers explicit dims over size over aspect_ratio', () => {
125+
it('prefers explicit dims over size over aspect_ratio (still rounded/clamped)', () => {
126+
// Explicit wins; 100 -> 256 (floor), 200 -> round64(200)=192 -> 256 (floor).
104127
expect(resolveDims({ width: 100, height: 200, size: '512x512', aspect_ratio: '1:1' })).toEqual({
105-
width: 100,
106-
height: 200,
128+
width: 256,
129+
height: 256,
107130
});
108131
expect(resolveDims({ size: '512x512', aspect_ratio: '1:1' })).toEqual({ width: 512, height: 512 });
109132
});

src/main/model-server/dimensions.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
// Extracted from model-server.ts so the branch logic (OpenAI size, OpenRouter
33
// aspect_ratio + resolution, explicit width/height) is unit-testable.
44

5-
// Round to a multiple of 64 (diffusion models require it), clamped sane.
5+
// Round to a multiple of 64 (diffusion models require it), clamped sane. A non-finite input
6+
// (NaN/Infinity - e.g. a bad size string or a 0-denominator aspect ratio) clamps to the 256 floor
7+
// rather than leaking NaN downstream into image generation.
68
export function round64(n: number): number {
9+
if (!Number.isFinite(n)) return 256;
710
return Math.max(256, Math.min(2048, Math.round(n / 64) * 64));
811
}
912

@@ -16,17 +19,21 @@ export function parseSize(size: unknown): { width?: number; height?: number } {
1619
}
1720

1821
// Resolve output dimensions from any of: explicit width/height, an OpenAI
19-
// "WIDTHxHEIGHT" size, or an OpenRouter aspect_ratio + resolution pair.
22+
// "WIDTHxHEIGHT" size, or an OpenRouter aspect_ratio + resolution pair. EVERY branch runs its
23+
// result through round64 (round to /64, clamp 256-2048, reject NaN) so no path can leak an
24+
// out-of-range or non-numeric dimension into image generation.
2025
export function resolveDims(p: {
2126
width?: unknown;
2227
height?: unknown;
2328
size?: unknown;
2429
aspect_ratio?: unknown;
2530
resolution?: unknown;
2631
}): { width?: number; height?: number } {
27-
if (typeof p.width === 'number' && typeof p.height === 'number') return { width: p.width, height: p.height };
32+
if (typeof p.width === 'number' && typeof p.height === 'number') {
33+
return { width: round64(p.width), height: round64(p.height) };
34+
}
2835
const fromSize = parseSize(p.size);
29-
if (fromSize.width && fromSize.height) return fromSize;
36+
if (fromSize.width && fromSize.height) return { width: round64(fromSize.width), height: round64(fromSize.height) };
3037
if (typeof p.aspect_ratio === 'string') {
3138
const m = /^(\d+)\s*[:x×]\s*(\d+)$/.exec(p.aspect_ratio.trim());
3239
if (m) {

src/main/stream-guards.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,33 @@ export function guardConsoleStreams(streams: Array<NodeJS.EventEmitter | undefin
2424
}
2525
return n;
2626
}
27+
28+
/** Guard both ends of a proxied stream (upstream response + client response) against a mid-stream
29+
* reset. Piping `proxyRes` into `res` only wires the pipe; neither stream gets an 'error' listener,
30+
* so if llama-server aborts the connection or the client disconnects mid-stream, the unhandled
31+
* 'error' event becomes an uncaught exception and crashes the whole main process (an EventEmitter
32+
* throws on 'error' only when there is NO listener). We swallow the error and destroy the client
33+
* response so its socket is released. This does NOT settle any outer promise — by the time piping
34+
* starts the proxy attempt has already resolved, and re-settling here would be a double-settle.
35+
* Returns the count guarded (for tests). */
36+
export function guardProxyStreams(
37+
upstream: (NodeJS.EventEmitter & { destroy?: (err?: Error) => void }) | undefined,
38+
client: (NodeJS.EventEmitter & { destroy?: (err?: Error) => void }) | undefined
39+
): number {
40+
let n = 0;
41+
const destroy = (s?: { destroy?: (err?: Error) => void }): void => {
42+
try { s?.destroy?.(); } catch { /* already destroyed */ }
43+
};
44+
if (upstream && typeof upstream.on === 'function') {
45+
// Upstream (llama-server) reset mid-stream: tear down the client response so its socket frees.
46+
upstream.on('error', () => destroy(client));
47+
n++;
48+
}
49+
if (client && typeof client.on === 'function') {
50+
// Client disconnected mid-stream: stop reading from llama-server too, else proxyRes.pipe(res)
51+
// keeps draining the upstream (wasted local inference + a held socket) after the client is gone.
52+
client.on('error', () => destroy(upstream));
53+
n++;
54+
}
55+
return n;
56+
}

0 commit comments

Comments
 (0)