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

Offen
#215 0 Kommentare 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen

Dieses Issue hat noch niemand übernommen.

Bewertung

Schwierigkeit
4/5
Geschätzter Aufwand
3-5 Tage
Anfängerfreundlichkeit
45/100
Issue-Typ
Bug
Klarheit
Klar beschrieben
Aktivitätsstatus
Aktiv
Tech-Stack
typescript, wasm
Bereich
backend, testing

Rechercherichtung

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.

Vom Indexierungsmodell aus dem Issue-Text verfasst.

Beschreibung

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

Vorherrschende Sprache
TypeScript
Sterne
3k
Forks
197
Ø Merge
1 T. 40 Min.
Gemergte PRs (30 T.)
1

Entwicklungsumgebung

Dieses Projekt bietet weder Dev-Container noch Dockerfile noch Beitragsleitfaden – die Einrichtung liegt bei Ihnen. Beginnen Sie mit der README; die allgemeinen Schritte stehen in unserem Leitfaden für den ersten Beitrag.

Erste Schritte

  1. Lesen Sie das ganze Issue und danach den Beitragsleitfaden des Projekts.
  2. Schreiben Sie ins Issue, dass Sie es übernehmen — das erspart doppelte Arbeit.
  3. Forken Sie das Repository und arbeiten Sie in einem Branch.
  4. Öffnen Sie einen Pull Request, der die Issue-Nummer nennt.

Mehr aus coder/ghostty-web

Alle Issues in coder/ghostty-web

Ähnliche Issues

Weitere Issues zu TypeScript

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.