authorgravatar for lucenz@proton.meSimone <lucenz@proton.me> 2026-08-21 23:02:04+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-21 17:02:04-04:00
logc3e37094e04de71accf497c6110c5305223e0090
tree7cd85e3afb158622e932bb5446501d713b1c3ec3
parent9b5d416872591d215c4a16d6f87798c8486522d8
signature Signed by PGP key B5690EEEBB952194

fix(web): render oversized terminal graphemes without crashing (#7809)

Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com>

2 files changed, 58 insertions(+), 5 deletions(-)

apps/web/src/terminal/ghostty/core.test.ts created+36
...@@ -0,0 +1,36 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { ghosttyCellText } from "./core";
4
5function codepointView(codepoints: ReadonlyArray<number>): DataView {
6 const view = new DataView(new ArrayBuffer(codepoints.length * 4));
7 codepoints.forEach((codepoint, index) => view.setUint32(index * 4, codepoint, true));
8 return view;
9}
10
11describe("ghosttyCellText", () => {
12 it("converts oversized grapheme clusters without hitting engine spread limits", () => {
13 // A program printing one base character followed by a huge run of
14 // combining marks packs the whole cluster into one cell; spreading that
15 // many arguments into String.fromCodePoint once overflows the call stack.
16 const graphemeLength = 130_000;
17 const view = new DataView(new ArrayBuffer(graphemeLength * 4));
18 for (let index = 0; index < graphemeLength; index += 1) {
19 view.setUint32(index * 4, index === 0 ? "a".codePointAt(0)! : 0x301, true);
20 }
21 const text = ghosttyCellText(view, graphemeLength);
22 expect(text.length).toBe(graphemeLength);
23 expect(text.codePointAt(0)).toBe("a".codePointAt(0));
24 expect(text.codePointAt(1)).toBe(0x301);
25 expect(text.codePointAt(graphemeLength - 1)).toBe(0x301);
26 });
27
28 it("converts small clusters including astral codepoints", () => {
29 const text = ghosttyCellText(codepointView([0x1f642, 0x20e3]), 2);
30 expect([...text]).toEqual(["\u{1F642}", "\u{20E3}"]);
31 });
32
33 it("returns an empty string for empty cells", () => {
34 expect(ghosttyCellText(codepointView([]), 0)).toBe("");
35 });
36});
apps/web/src/terminal/ghostty/core.ts+22-5
...@@ -166,6 +166,27 @@ function sameColor(left: GhosttyColor, right: GhosttyColor): boolean {...@@ -166,6 +166,27 @@ function sameColor(left: GhosttyColor, right: GhosttyColor): boolean {
166 return left.r === right.r && left.g === right.g && left.b === right.b;166 return left.r === right.r && left.g === right.g && left.b === right.b;
167}167}
168168
169/**
170 * A terminal program can print one base character followed by a huge run of
171 * combining marks, packing hundreds of thousands of codepoints into a single
172 * cell that still fits the scrollback buffer. Engines cap spread-call
173 * arguments far below that, so convert in bounded chunks instead of spreading
174 * every codepoint into String.fromCodePoint at once.
175 */
176export function ghosttyCellText(codepointView: DataView, graphemeLength: number): string {
177 const CHUNK_SIZE = 4_096;
178 let text = "";
179 for (let start = 0; start < graphemeLength; start += CHUNK_SIZE) {
180 const count = Math.min(CHUNK_SIZE, graphemeLength - start);
181 const codes = new Array<number>(count);
182 for (let index = 0; index < count; index += 1) {
183 codes[index] = codepointView.getUint32((start + index) * 4, true);
184 }
185 text += String.fromCodePoint(...codes);
186 }
187 return text;
188}
189
169export class GhosttyTerminalCore {190export class GhosttyTerminalCore {
170 private readonly runtime: GhosttyRuntime;191 private readonly runtime: GhosttyRuntime;
171 private terminalSlot = 0;192 private terminalSlot = 0;
...@@ -960,11 +981,7 @@ export class GhosttyTerminalCore {...@@ -960,11 +981,7 @@ export class GhosttyTerminalCore {
960 // Read through a DataView: the byte-array allocator guarantees no981 // Read through a DataView: the byte-array allocator guarantees no
961 // 4-byte alignment, which a Uint32Array view would require.982 // 4-byte alignment, which a Uint32Array view would require.
962 const codepointView = this.runtime.view(codepoints, bufferSize);983 const codepointView = this.runtime.view(codepoints, bufferSize);
963 const codes: number[] = [];984 text = ghosttyCellText(codepointView, graphemeLength);
964 for (let index = 0; index < graphemeLength; index += 1) {
965 codes.push(codepointView.getUint32(index * 4, true));
966 }
967 text = String.fromCodePoint(...codes);
968 }985 }
969 this.runtime.free(codepoints, bufferSize);986 this.runtime.free(codepoints, bufferSize);
970 }987 }