Skip to content

Commit 560d04e

Browse files
committed
fix(StreamHandler): decode output across chunk boundaries
Without line buffering, each stdout/stderr chunk was decoded separately, so a UTF-8 character split between chunks reached the task as U+FFFD: ExifTool printed "Каналов" and tasks saw "Кана��ов". Decoding once in StreamHandler means tasks and taskData/noTaskData listeners only ever receive strings.
1 parent a93c7d5 commit 560d04e

5 files changed

Lines changed: 111 additions & 19 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ See [Semver](http://semver.org/).
2626
call rescans the task's accumulated output, so a task that printed 100,000
2727
lines took 30 seconds instead of 55 ms. `taskData` events now carry these
2828
batches, as they carry chunks without these options.
29+
- 🐞 A multi-byte UTF-8 character split across two stdout or stderr chunks no
30+
longer reaches the task as U+FFFD replacement characters. Without
31+
`isRetirementRequest` (or, for stderr, `shouldIgnoreStderrLine`), each chunk
32+
was decoded separately: ExifTool's `-listx` output contained `Кана��ов` where
33+
it printed `Каналов`. Tasks, `taskData`, and `noTaskData` now always receive
34+
decoded strings, never `Buffer`s.
2935

3036
## [v19.4.1](https://github.com/photostructure/batch-cluster.js/releases/tag/v19.4.1)
3137

‎src/BatchCluster.spec.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,31 @@ describe("BatchCluster", function () {
879879
});
880880
});
881881

882+
describe("UTF-8 split across chunks", function () {
883+
let bc: BatchCluster;
884+
afterEach(() => shutdown(bc));
885+
886+
for (const stream of ["stdout", "stderr"] as const) {
887+
it(`decodes a ${stream} code point split across two chunks`, async function () {
888+
setFailRatePct(0);
889+
bc = listen(
890+
new BatchCluster({
891+
...DefaultTestOptions,
892+
processFactory,
893+
maxProcs: 1,
894+
}),
895+
);
896+
const output = await bc.enqueueTask(
897+
new Task("split-utf8 " + stream, (stdout, stderr) =>
898+
(stream === "stdout" ? stdout : (stderr ?? "")).trim(),
899+
),
900+
);
901+
expect(output).to.eql("🌻");
902+
postAssertions();
903+
});
904+
}
905+
});
906+
882907
describe("maxProcs", function () {
883908
const iters = 50;
884909
const maxProcs = 10;

‎src/StreamHandler.spec.ts‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -967,7 +967,7 @@ describe("StreamHandler", function () {
967967
streamHandler.processStdout(bufferData, mockContext);
968968

969969
expect(taskDataEvents).to.have.length(1);
970-
expect(taskDataEvents[0]?.data).to.eql(bufferData);
970+
expect(taskDataEvents[0]?.data).to.eql("test buffer data");
971971
});
972972

973973
it("should handle Buffer data in stderr", function () {
@@ -979,6 +979,50 @@ describe("StreamHandler", function () {
979979
streamHandler.processStderr(bufferData, mockContext);
980980
}).to.not.throw();
981981
});
982+
983+
for (const stream of ["stdout", "stderr"] as const) {
984+
describe(`${stream} decoding`, function () {
985+
const write = (data: Buffer) =>
986+
stream === "stdout"
987+
? streamHandler.processStdout(data, mockContext)
988+
: streamHandler.processStderr(data, mockContext);
989+
const end = () =>
990+
stream === "stdout"
991+
? streamHandler.endStdout(mockContext)
992+
: streamHandler.endStderr(mockContext);
993+
let received: string[];
994+
995+
beforeEach(function () {
996+
received = [];
997+
mockContext.getCurrentTask = () =>
998+
({
999+
pending: true,
1000+
onStdout: (data: string | Buffer) => {
1001+
received.push(String(data));
1002+
},
1003+
onStderr: (data: string | Buffer) => {
1004+
received.push(String(data));
1005+
},
1006+
}) as unknown as Task<unknown>;
1007+
});
1008+
1009+
it(`decodes a code point split across ${stream} chunks`, function () {
1010+
const bytes = Buffer.from("🌻\n");
1011+
write(bytes.subarray(0, 2));
1012+
expect(received).to.eql([]);
1013+
write(bytes.subarray(2));
1014+
expect(received).to.eql(["🌻\n"]);
1015+
});
1016+
1017+
it(`delivers a ${stream} code point truncated at EOF as U+FFFD`, function () {
1018+
write(Buffer.from("🌻").subarray(0, 2));
1019+
expect(received).to.eql([]);
1020+
end();
1021+
end();
1022+
expect(received).to.eql(["�"]);
1023+
});
1024+
});
1025+
}
9821026
});
9831027

9841028
describe("integration scenarios", function () {

‎src/StreamHandler.ts‎

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,10 @@ export class StreamHandler {
133133
#onStdout(data: string | Buffer, context: StreamContext): void {
134134
if (data == null) return;
135135
if (this.#isRetirementRequest == null || this.#stdoutEnded) {
136-
this.#routeStdout(data, context);
136+
// The decoder retains a code point split across chunks, which decoding
137+
// each chunk separately would turn into U+FFFD replacement characters.
138+
const output = this.#stdoutDecoder.write(data);
139+
if (output.length > 0) this.#routeStdout(output, context);
137140
return;
138141
}
139142

@@ -232,7 +235,12 @@ export class StreamHandler {
232235
}
233236

234237
#endStdout(context: StreamContext): void {
235-
if (this.#isRetirementRequest == null || this.#stdoutEnded) return;
238+
if (this.#isRetirementRequest == null) {
239+
const output = this.#stdoutDecoder.end();
240+
if (output.length > 0) this.#routeStdout(output, context);
241+
return;
242+
}
243+
if (this.#stdoutEnded) return;
236244
const hadIncompleteLine = this.hasIncompleteOutputLine;
237245
this.#stdoutEnded = true;
238246
this.#consumeStdout(this.#stdoutDecoder.end(), context);
@@ -252,11 +260,7 @@ export class StreamHandler {
252260
if (fragment.length > 0) this.#routeStdout(fragment, context, owner);
253261
}
254262

255-
#routeStdout(
256-
data: string | Buffer,
257-
context: StreamContext,
258-
owner?: LineOwner,
259-
): void {
263+
#routeStdout(data: string, context: StreamContext, owner?: LineOwner): void {
260264
const task = owner == null ? context.getCurrentTask() : owner.task;
261265
if (task != null && task.pending) {
262266
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
@@ -279,12 +283,13 @@ export class StreamHandler {
279283
this.#shouldIgnoreStderrLine == null &&
280284
this.#isRetirementRequest == null
281285
) {
282-
this.#routeStderr(data, context);
286+
// See #onStdout:
287+
this.#routeStderr(this.#stderrDecoder.write(data), context);
283288
return;
284289
}
285290

286291
if (this.#stderrEnded) {
287-
this.#routeStderr(data, context);
292+
this.#routeStderr(this.#stderrDecoder.write(data), context);
288293
return;
289294
}
290295

@@ -434,11 +439,13 @@ export class StreamHandler {
434439

435440
#endStderr(context: StreamContext): void {
436441
if (
437-
(this.#shouldIgnoreStderrLine == null &&
438-
this.#isRetirementRequest == null) ||
439-
this.#stderrEnded
440-
)
442+
this.#shouldIgnoreStderrLine == null &&
443+
this.#isRetirementRequest == null
444+
) {
445+
this.#routeStderr(this.#stderrDecoder.end(), context);
441446
return;
447+
}
448+
if (this.#stderrEnded) return;
442449

443450
const hadIncompleteLine = this.hasIncompleteOutputLine;
444451
this.#stderrEnded = true;
@@ -461,11 +468,7 @@ export class StreamHandler {
461468
}
462469
}
463470

464-
#routeStderr(
465-
data: string | Buffer,
466-
context: StreamContext,
467-
owner?: LineOwner,
468-
): void {
471+
#routeStderr(data: string, context: StreamContext, owner?: LineOwner): void {
469472
if (blank(data)) return;
470473

471474
const task = owner == null ? context.getCurrentTask() : owner.task;

‎src/test.js‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,20 @@ async function onLine(line) {
142142
}
143143
break;
144144
}
145+
case "split-utf8": {
146+
// Writes a 4-byte code point in two parts, so the parent reads them in
147+
// separate chunks. Like "lines", the stderr variant ends with FAIL on
148+
// stderr.
149+
const stderr = tokens[0] === "stderr";
150+
const bytes = Buffer.from(
151+
"🌻" + newline + (stderr ? "FAIL" : "PASS") + newline,
152+
);
153+
const stream = stderr ? process.stderr : process.stdout;
154+
stream.write(bytes.subarray(0, 2));
155+
await delay(50);
156+
stream.write(bytes.subarray(2));
157+
break;
158+
}
145159
case "sleep": {
146160
const millis = parseInt(tokens[0] ?? "100");
147161
if (millis > 0) await delay(millis);

0 commit comments

Comments
 (0)