After RIS, `?47h`/`?1047h` shows old primary-screen cells on the alternate screen, and a grapheme cluster there makes the next write over it trap
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 45/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- typescript, wasm
Hướng nghiên cứu
Start by reading src/terminal/bitmap_allocator.zig and the existing regression coverage for RIS and alternate-screen behavior; the report links the allocator to the trap and gives a minimal reproducer. Run the 12-byte sequence a + U+0301 + RIS + ?47h + o against the terminal and inspect the alternate screen after reset. Done means the alternate screen is blank after RIS and the sequence no longer traps, with a regression test covering it.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
Summary
In ghostty-web 0.4.0 and in 0.4.0-next.21.g640db46 (the newest published build), a full reset (RIS, ESC c) followed by a switch to the alternate screen with CSI ? 47 h or CSI ? 1047 h leaves the alternate screen holding the cells that the primary screen had before the reset.
- With plain text, the alternate screen shows that old text. It is blank in libghostty-vt (see "Expected").
- When one of the stale cells holds a grapheme cluster (for example
a+ U+0301), the next print, ICH or DCH on that cell makes the WASM module throwRuntimeError: memory access out of bounds. - In Chromium headless shell on Linux, the same fault crashes the page instead of throwing a catchable error. Playwright reports
page.evaluate: Target crashed.
CSI ? 1049 h is not affected. Neither is ?47h without a RIS before it.
Minimal repro (12 bytes)
"a\u0301\x1bc\x1b[?47ho" // 'a', U+0301, RIS, ?47h, 'o'
That is 11 code points and 12 bytes in UTF-8 (61 cc 81 1b 63 1b 5b 3f 34 37 68 6f). Removing any one code point stops the trap. Node, no browser:
import { Ghostty } from 'ghostty-web';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
const wasm = createRequire(import.meta.url).resolve('ghostty-web/ghostty-vt.wasm');
const mod = await WebAssembly.compile(readFileSync(wasm));
async function run(label, data) {
const g = new Ghostty(await WebAssembly.instantiate(mod, { env: { log: () => {} } }));
const t = g.createTerminal(80, 24);
try {
t.write(data);
t.update();
const cells = t.getViewport();
const row0 = [0, 1, 2, 3, 4].map((i) => (cells[i].codepoint ? String.fromCodePoint(cells[i].codepoint) : '.')).join('');
console.log(`${label}: no trap, alt=${t.isAlternateScreen()}, row 0 = "${row0}"`);
} catch (e) {
console.log(`${label}: ${e.constructor.name}: ${e.message}`);
}
}
await run('plain text, RIS, ?47h ', 'abcde\x1bc\x1b[?47h');
await run('cluster, RIS, ?47h, "o" ', 'a\u0301\x1bc\x1b[?47ho');
await run('cluster, RIS, ?1049h, "o" ', 'a\u0301\x1bc\x1b[?1049ho');
await run('cluster, ?47h, "o" (no RIS) ', 'a\u0301\x1b[?47ho');
Output with ghostty-web 0.4.0 and with 0.4.0-next.21.g640db46:
plain text, RIS, ?47h : no trap, alt=true, row 0 = "abcde"
cluster, RIS, ?47h, "o" : RuntimeError: memory access out of bounds
cluster, RIS, ?1049h, "o" : no trap, alt=true, row 0 = "o...."
cluster, ?47h, "o" (no RIS) : no trap, alt=true, row 0 = ".o..."
Expected: row 0 of the alternate screen is blank in the first case, and the second case does not trap. The Node binding of libghostty-vt (@coder/libghostty-vt-node 0.1.0-beta.7, Ghostty commit 83fb956) shows a blank alternate screen in the first case. It does not throw on any of the four inputs.
What decides it
I ran 3,000 seeded random input streams for each setting (Node, no browser, one fresh WASM instance per stream, scrollbackLimit: 10000). The generator is in the collapsed block below.
| Generator setting | Streams that trap |
|---|---|
| all on (RIS, alt-screen switches, combining marks, RI and other controls) | 389 |
| RIS removed | 0 |
alt-screen switches removed (?47, ?1047, ?1049) |
0 |
| combining marks removed | 0 |
RI (ESC M) removed |
470 |
In these streams the trap needs all three of RIS, an alternate-screen switch, and a combining mark. RI is not needed. The 12-byte string has no RI.
Variants of the 12-byte string:
- These clusters trap:
a+ U+0301,a+ ZWJ,❤+ VS16, and a flag (two regional indicators). A single wide emoji (😀) does not trap. - The trap happens only when a write lands on the cell that held the cluster. With a cluster in the fifth column (
xxxx+a+ U+0301), the fifth printed character traps and the fourth does not. A cursor move (CUP,LF) or ED 2 alone does not trap. - ED 2, EL 2 and ECH on the alternate screen before the write avoid the trap. ICH and DCH do not avoid it: they trap even with no print after them.
- A
?1049h/?1049lpair after the RIS and before?47havoids it. - A cluster written on the alternate screen itself (
?47h, cluster, RIS,?47h,o) does not trap. A RIS sent while on the alternate screen does not avoid the trap (cluster on the primary screen,?47h, RIS,?47h,ostill traps).
Generator and driver for the table (Node, run in a folder where ghostty-web is installed)
import { Ghostty } from 'ghostty-web';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
const wasm = createRequire(import.meta.url).resolve('ghostty-web/ghostty-vt.wasm');
const mod = await WebAssembly.compile(readFileSync(wasm));
// mulberry32
function rng(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const COMB = ['\u0301', '\u0300', '\u0308', '\u0323', '\u20dd', '\u0489', '\u200d', '\ufe0f'];
function stream(seed, off) {
const r = rng(seed);
const pick = (a) => a[Math.floor(r() * a.length)];
const n = 20 + Math.floor(r() * 200);
let out = '';
for (let i = 0; i < n; i++) {
const k = r();
if (k < 0.3) out += pick(['a', 'b', 'e', 'x', 'hello', ' ', 'W', '#', '日', '😀']);
else if (k < 0.42 && !off.comb) out += pick(['a', 'e', 'o', 'x']) + pick(COMB) + (r() < 0.3 ? pick(COMB) : '');
else if (k < 0.5) out += pick(['\r\n', '\n', '\r', '\t', '\b']);
else if (k < 0.6 && !off.ri) out += '\x1bM'; // RI
else if (k < 0.68 && !off.ris) out += '\x1bc'; // RIS
else if (k < 0.78 && !off.alt) out += pick(['\x1b[?1049h', '\x1b[?1049l', '\x1b[?47h', '\x1b[?47l', '\x1b[?1047h', '\x1b[?1047l']);
else if (k < 0.84) out += `\x1b[${1 + Math.floor(r() * 24)};${1 + Math.floor(r() * 80)}H`;
else if (k < 0.88) out += pick(['\x1b[2J', '\x1b[K', '\x1b[1J', '\x1b[3J', '\x1b[H']);
else if (k < 0.92) out += `\x1b[${1 + Math.floor(r() * 5)};${5 + Math.floor(r() * 19)}r`;
else if (k < 0.96) out += pick(['\x1b[1m', '\x1b[31m', '\x1b[0m', '\x1b[7m', '\x1b[48;5;100m']);
else out += pick(['\x1b[2L', '\x1b[3M', '\x1b[2@', '\x1b[2P', '\x1b[S', '\x1b[T']);
}
if (r() < 0.5) { // split into random 1-12 character chunks
const chunks = [];
for (let i = 0; i < out.length; ) { const len = 1 + Math.floor(r() * 12); chunks.push(out.slice(i, i + len)); i += len; }
return chunks;
}
return [out];
}
async function traps(chunks) { // one fresh WASM instance per stream
const g = new Ghostty(await WebAssembly.instantiate(mod, { env: { log: () => {} } }));
const t = g.createTerminal(80, 24, { scrollbackLimit: 10000 });
try { for (const c of chunks) t.write(c); t.update(); t.getViewport(); return false; } catch { return true; }
}
const settings = { 'all on': {}, 'RIS removed': { ris: 1 }, 'alt-screen switches removed': { alt: 1 }, 'combining marks removed': { comb: 1 }, 'RI removed': { ri: 1 } };
for (const [name, off] of Object.entries(settings)) {
let n = 0;
for (let seed = 1000; seed < 4000; seed++) if (await traps(stream(seed, off))) n++;
console.log(`${name.padEnd(30)} ${n} / 3000`);
}
Where it faults
The trap is in wasm-function[162] of ghostty-vt.wasm 0.4.0 (177-byte body, offset 0xf837). The V8 stack is ghostty_terminal_write (wasm-function[446]) -> 292 -> 281 -> 234 -> 162. In 0.4.0-next.21.g640db46 the stack is ghostty_terminal_write (wasm-function[447]) -> 295 -> 284 -> 234 -> 162, and wasm-function[162] (also 177 bytes) traps at offset 0xf891.
A comment on #141 identifies wasm-function[162] (177 bytes, in the 0.4.0 artifact whose SHA-256 starts with d6f0326f1874ad2c) as BitmapAllocator.free (src/terminal/bitmap_allocator.zig), by comparing it with a rebuild from source. The module has no name section, so I did not check the name myself.
PR #180 (merge commit bec9e16, fixes #138) fixed stale cells in rows that cursorDownScroll grows. It was first published as 0.4.0-next.18.gbec9e16. It does not cover this case: next.18, next.20 and next.21 still trap.
Chromium
Playwright 1.64.1 with Chromium headless shell 1248 (Chrome for Testing 156.0.8078.4, Linux x64), with a page that calls terminal.write(...) in page.evaluate:
- Default flags: the page crashes. The browser's stderr shows
Received signal 11 SEGV_ACCERR(the address differs per run). Playwright firescrash, andpage.evaluaterejects withTarget crashed. There is nopageerrorevent and no console message, so nothing names the cause. - With
--disable-features=WebAssemblyTrapHandler:page.evaluaterejects withRuntimeError: memory access out of bounds, and the page stays alive. Every later write to that terminal that I tried (two, including a plainx) throws the same error.
Environment
- ghostty-web 0.4.0,
0.4.0-next.8.g77e29d9,-next.10.g3525675,-next.17.g24b3c80,-next.18.gbec9e16,-next.20.g1858a59and-next.21.g640db46: all throw on the 12-byte string in Node. - Not tested: unreleased
main. The release PR for 0.5.0-rc.0 (#182) is open and 0.5.0-rc.0 is not on npm. - Node v26.8.1 (Linux x64) for the Node runs.
- Playwright 1.64.1 and Chromium headless shell 1248 for the browser runs.
Asks
- Make RIS leave the alternate screen blank, as libghostty-vt does.
- Add a regression test with the 12-byte string above.
Related: #141 (multi-codepoint grapheme clusters, then free), #138 and PR #180 (stale cells in rows grown by cursorDownScroll, fixed in bec9e16), #210 (write after free at another width).
Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high
- Ngôn ngữ chính
- TypeScript
- Star
- 3k
- Fork
- 197
- Merge trung bình
- 1 ngày 40 phút
- Pull request đã merge (30 ngày)
- 1
Chuẩn bị môi trường
Dự án này không cung cấp dev container, Dockerfile hay hướng dẫn đóng góp, nên bạn cần tự thiết lập môi trường: hãy bắt đầu từ README và xem hướng dẫn đóng góp lần đầu của chúng tôi để biết các bước chung.
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của coder/ghostty-web
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
coder/ghostty-web#207 ·
-
buffer translateToString()/getChars() return '' for empty cells, collapsing cursor-positioned textĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 75/100
coder/ghostty-web#205 ·
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
coder/ghostty-web#204 ·
-
Alt+<printable key> sends the bare key with no ESC prefix (readline/emacs/tmux meta bindings lost)Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
coder/ghostty-web#203 ·
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
coder/ghostty-web#199 ·
Tất cả issue của coder/ghostty-web
Issue tương tự
-
area: build-and-ci S3: minor
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
manaflow-ai/cmux#39902 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
bug
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
refined-github/refined-github#10387 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
documentation
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
CopilotKit/CopilotKit#8283 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
Maintainer thường phản hồi trong vòng 1 ngày
-
complexity:simple priority:P3 ready-to-build
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
junruxiong/IncarnaMind#2919 ·