Skip to content

Commit 3d1ad00

Browse files
committed
Handle client aborts and child signals
Detect and handle child process termination by signal in runQoderRequest (return -1 code and append a signal message to stderr). Update chat and completions routes to track real client aborts with req.on('aborted') and a clientAborted flag, guard onDone/onError callbacks to avoid writing after the client disconnected or response ended, and only kill the child process when appropriate. This prevents premature child termination from req "close" events and avoids sending responses after disconnects.
1 parent eefec1b commit 3d1ad00

File tree

3 files changed

+34
-10
lines changed

3 files changed

+34
-10
lines changed

src/helpers/spawn.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,12 @@ const runQoderRequest = ({
156156
addSystem(text, "error", "qodercli-stderr");
157157
});
158158

159-
child.on("close", (code) => {
160-
settle(() => onDone(code, stderrOutput.trim()));
159+
child.on("close", (code, signal) => {
160+
const finalCode = code == null && signal ? -1 : code;
161+
const finalStderr = signal
162+
? `${stderrOutput.trim()}${stderrOutput.trim() ? "\n" : ""}Process terminated by signal: ${signal}`
163+
: stderrOutput.trim();
164+
settle(() => onDone(finalCode, finalStderr));
161165
});
162166

163167
child.on("error", (err) => {

src/routes/chat.js

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ router.post("/", (req, res) => {
138138
// Accumulate full text so we can detect tool calls at the end
139139
let fullStreamText = "";
140140

141+
let clientAborted = false;
141142
const child = runQoderRequest({
142143
prompt,
143144
model,
@@ -173,6 +174,7 @@ router.post("/", (req, res) => {
173174
}
174175
},
175176
onDone: (code, stderr) => {
177+
if (clientAborted || res.writableEnded) return;
176178
if (code !== 0) {
177179
console.error(
178180
"[chat/completions] qodercli exit code:",
@@ -213,6 +215,7 @@ router.post("/", (req, res) => {
213215
res.end();
214216
},
215217
onError: (err) => {
218+
if (clientAborted || res.writableEnded) return;
216219
console.error("[chat/completions] error:", err.message);
217220
res.write(
218221
`data: ${JSON.stringify({ error: { message: err.message, type: err.code === "TIMEOUT" ? "timeout_error" : "api_error" } })}\n\n`,
@@ -221,19 +224,21 @@ router.post("/", (req, res) => {
221224
},
222225
});
223226

224-
req.on("close", () => {
227+
req.on("aborted", () => {
228+
clientAborted = true;
225229
console.log(
226230
"[Stream] Client disconnected at",
227231
Date.now() - streamStartTime,
228232
"ms",
229233
);
230-
child.kill();
234+
if (!res.writableEnded) child.kill();
231235
});
232236
} else {
233237
// Non-streaming path
234238
let fullContent = "";
235239
let finishReason = "stop";
236240
let allToolCalls = [];
241+
let clientAborted = false;
237242

238243
const child = runQoderRequest({
239244
prompt,
@@ -251,6 +256,7 @@ router.post("/", (req, res) => {
251256
if (data.message?.stop_reason) finishReason = data.message.stop_reason;
252257
},
253258
onDone: (code, stderr) => {
259+
if (clientAborted || res.writableEnded) return;
254260
if (code !== 0) {
255261
return res.status(500).json({
256262
error: {
@@ -287,6 +293,7 @@ router.post("/", (req, res) => {
287293
}
288294
},
289295
onError: (err) => {
296+
if (clientAborted || res.writableEnded) return;
290297
res.status(err.code === "TIMEOUT" ? 504 : 500).json({
291298
error: {
292299
message: err.message,
@@ -296,9 +303,11 @@ router.post("/", (req, res) => {
296303
},
297304
});
298305

299-
// Clean up if client disconnects during non-streaming response
300-
req.on("close", () => {
301-
if (!res.headersSent) child.kill();
306+
// Clean up only on real client aborts during non-streaming response.
307+
// req "close" can fire after normal request completion and would kill too early.
308+
req.on("aborted", () => {
309+
clientAborted = true;
310+
if (!res.writableEnded) child.kill();
302311
});
303312
}
304313
});

src/routes/completions.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ router.post('/', (req, res) => {
5252
};
5353
res.write(`data: ${JSON.stringify(initialChunk)}\n\n`);
5454

55+
let clientAborted = false;
5556
const child = runQoderRequest({
5657
prompt,
5758
model,
@@ -64,20 +65,26 @@ router.post('/', (req, res) => {
6465
}
6566
},
6667
onDone: (_code, _stderr) => {
68+
if (clientAborted || res.writableEnded) return;
6769
res.write('data: [DONE]\n\n');
6870
res.end();
6971
},
7072
onError: (err) => {
73+
if (clientAborted || res.writableEnded) return;
7174
console.error('[completions]', err.message);
7275
res.write(`data: ${JSON.stringify({ error: { message: err.message } })}\n\n`);
7376
res.end();
7477
},
7578
});
7679

77-
req.on('close', () => child.kill());
80+
req.on('aborted', () => {
81+
clientAborted = true;
82+
if (!res.writableEnded) child.kill();
83+
});
7884
} else {
7985
let fullText = '';
8086
let finishReason = 'stop';
87+
let clientAborted = false;
8188

8289
const child = runQoderRequest({
8390
prompt,
@@ -89,6 +96,7 @@ router.post('/', (req, res) => {
8996
if (data.message?.stop_reason) finishReason = data.message.stop_reason;
9097
},
9198
onDone: (code, stderr) => {
99+
if (clientAborted || res.writableEnded) return;
92100
if (code !== 0) {
93101
return res.status(500).json({
94102
error: { message: `qodercli exited with code ${code}`, type: 'api_error', details: stderr },
@@ -97,14 +105,17 @@ router.post('/', (req, res) => {
97105
res.json(buildFullCompletionResponse(fullText, model, finishReason, id));
98106
},
99107
onError: (err) => {
108+
if (clientAborted || res.writableEnded) return;
100109
res.status(err.code === 'TIMEOUT' ? 504 : 500).json({
101110
error: { message: err.message, type: err.code === 'TIMEOUT' ? 'timeout_error' : 'api_error' },
102111
});
103112
},
104113
});
105114

106-
// For non-streaming, don't kill on client disconnect - let it complete
107-
// req.on('close', () => child.kill());
115+
req.on('aborted', () => {
116+
clientAborted = true;
117+
if (!res.writableEnded) child.kill();
118+
});
108119
}
109120
});
110121

0 commit comments

Comments
 (0)