Skip to content

flutter_pty: the 1 KB read buffer dominates cost under heavy output #24

Description

@lordspace

Summary

read_loop reads with a 1024-byte buffer and posts one Dart port message per
read (src/flutter_pty_unix.c:49, and the same in src/flutter_pty_win.c:168).

Each port message costs the Dart side a typed-data allocation, a stream event,
Future propagation and the GC that follows — so under heavy output the number of
reads
, not the number of bytes, is what dominates. 15 MB of program output
becomes ~15,000 messages, and the UI isolate spends its time on message machinery
instead of on the terminal.

Impact

In a Flutter terminal app (Linux, release build), running find /usr (15.2 MB)
and measuring how long the app stays busy after the command has finished
i.e. how long the UI is unresponsive:

Terminal window time to become idle again
small (~848 cells) 52.2 s
maximized (~6478 cells) 58.2 s

During that window the app does not repaint, does not accept input, and does not
answer a termination signal. To a user it is indistinguishable from a hang.

It is the read size, not rendering or parsing

Worth stating because both are the intuitive suspects and both are wrong here:

  • Rendering was ruled out first — 7.6x the terminal cells cost only 12% more
    time, so the cost is per-byte, not per-cell.
  • A CPU profile agreed — the hot leaves were typed-data allocation, GC
    (__munmap), and _propagateToListeners / _scheduleMicrotask: the per-message
    path, not the escape parser and not painting.
  • Raising ONLY the buffer size (1 KB → 64 KB, no other change) takes the same
    flood to 8.0 s / 10.1 s — roughly a 6x improvement, with nothing else touched.

Reproducing

Any Flutter app embedding flutter_pty will show it: run a command with a lot of
output (find /, a big cat, a noisy build) and watch how long the app stays
pegged AFTER the shell prompt returns.

⚠️ Measure time-to-idle, not the time the command takes. The reader thread
empties the pipe with no backpressure, so the shell finishes at the same moment
regardless of how slowly the consumer processes — a stopwatch around the command
reports ~6 s either way while the app is actually busy for ~55 s.

Suggested fix

Raise the read buffer and give it a name. Happy to send a PR — it is two lines in
each of the two platform readers, no API change, and read() still returns as soon
as data is available so interactive latency is unaffected.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions