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

Đang mở
#215 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

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
Lĩnh vực
backend, testing

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 throw RuntimeError: 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/?1049l pair after the RIS and before ?47h avoids 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, o still 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 fires crash, and page.evaluate rejects with Target crashed. There is no pageerror event and no console message, so nothing names the cause.
  • With --disable-features=WebAssemblyTrapHandler: page.evaluate rejects with RuntimeError: memory access out of bounds, and the page stays alive. Every later write to that terminal that I tried (two, including a plain x) 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.g1858a59 and -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

  1. Make RIS leave the alternate screen blank, as libghostty-vt does.
  2. 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

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. 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.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của coder/ghostty-web

Tất cả issue của coder/ghostty-web

Issue tương tự

Thêm issue về TypeScript

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.