Skip to content

Support synchronized output (DEC private mode 2026) - #35

Merged
tomlm merged 7 commits into
tomlm:mainfrom
JohnCampionJr:sync-output
Aug 27, 2026
Merged

Support synchronized output (DEC private mode 2026)#35
tomlm merged 7 commits into
tomlm:mainfrom
JohnCampionJr:sync-output

Conversation

@JohnCampionJr

Copy link
Copy Markdown
Collaborator

Full-screen applications redraw in many writes. A renderer that paints between them shows a frame half old and half new — the tearing you see when a TUI repaints under load, or over a slow link. DEC private mode 2026 is how an application says it is mid-frame and the display should hold still.

This matters most for exactly the applications this library exists to host: anything that repaints a whole screen per frame gets to look atomic instead of assembling in bands.

What the emulator does

Reports the state, and nothing more:

  • Terminal.SynchronizedOutput — is an atomic update in progress
  • Terminal.SynchronizedOutputChanged — raised on transitions only

The event fires only on real edges because applications wrap every frame, so the mode gets set over and over. An event per frame saying nothing changed is noise a renderer would have to filter itself.

Holding the frame is the renderer's decision, and so is the timeout that must bound it — without one, an application that begins an update and then crashes, or stops at a breakpoint, would freeze the display for as long as it stays that way. A tear is a bad frame; a permanently frozen terminal looks like the application hung. I have the consumer side working in Iciclecreek and can open it once this ships in a package.

DECRQM comes with it

Without it the feature goes largely unused. A well-behaved application asks whether the terminal understands 2026 before relying on it, and silence means no — so implementing the mode without answering the query leaves it switched off for the applications most likely to want it.

It answers for 2026 alone, deliberately. The reply distinguishes "set" and "reset" from "not recognised", and this terminal keeps mode state as individual properties rather than a registry — so answering for everything means a switch mapping every mode back to its property, where one wrong entry tells an application a feature is missing when it is not. Staying silent for every other mode is exactly the behaviour before this change, so nothing regresses while the one mode that needs an answer gets a correct one.

Happy to widen it if you'd rather, it's just more surface to get right in one go.

Tests

Seven, covering both halves: the mode's edges, that repeating it raises nothing, that content written inside an update lands normally, and the DECRQM replies for set, reset, and the modes it stays quiet about.

Full suite: 856 passing. Independent of #32 and #34 — different files entirely.

A full-screen application redraws in many writes, and a renderer that paints
between them shows a frame half old and half new -- the tearing you see when a TUI
repaints under load. Mode 2026 is how an application says it is mid-frame and the
display should hold still.

The emulator's job is to report the state and nothing more: SynchronizedOutput plus
a SynchronizedOutputChanged event, raised only on real transitions. Applications
wrap every frame, so the mode is set over and over, and an event per frame saying
nothing changed is noise a renderer would have to filter itself. Holding the frame
is the renderer's decision, and so is the timeout that must bound it -- without one,
an application that sets the mode and then crashes would freeze the display for good.

DECRQM comes with it, because without it the feature goes largely unused: a
well-behaved application asks whether the terminal understands 2026 before relying
on it, and silence means no. It answers for 2026 alone. The reply distinguishes set
and reset from not-recognised, and this terminal keeps mode state as individual
properties rather than a registry -- so answering for everything means a switch
mapping every mode back to its property, where one wrong entry talks an application
out of a feature that actually works. Staying silent for the rest is exactly the
behaviour before this change, so nothing regresses while the one mode that needs an
answer gets a correct one.

856 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new SynchronizedOutputChanged event is declared as EventHandler<bool>, which cannot compile because EventHandler<T> requires T : EventArgs.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds support for DEC private mode 2026 (synchronized output) so host renderers can avoid mid-frame tearing by reacting to “atomic update begins/ends” signals, and implements the DECRQM query path so applications can detect support.

Changes:

  • Introduces Terminal.SynchronizedOutput plus a transition-only change notification event.
  • Implements DECRQM handling (CSI ? Ps $ p) specifically for mode 2026, returning DECRPM state replies.
  • Adds a dedicated test suite covering mode edges, repeated sets, normal content behavior, and DECRQM replies/silence.
File summaries
File Description
src/XTerm.NET/Terminal.cs Adds synchronized output state + change event plumbing.
src/XTerm.NET/InputHandler.cs Parses/handles DECRQM and toggles mode 2026 via DECSET/DECRST.
src/XTerm.NET/Common/TerminalMode.cs Defines TerminalMode.SynchronizedOutput = 2026.
src/XTerm.NET/Common/CsiCommand.cs Adds CsiCommand.RequestMode (DECRQM).
src/XTerm.NET/Common/CommandExtensions.cs Maps "$p" CSI identifier to RequestMode.
src/XTerm.NET.Tests/SynchronizedOutputTests.cs Adds tests for mode 2026 behavior and DECRQM replies.
Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/XTerm.NET/Terminal.cs
Comment thread src/XTerm.NET.Tests/SynchronizedOutputTests.cs Outdated
Comment thread src/XTerm.NET/Terminal.cs
Comment thread src/XTerm.NET/Terminal.cs Outdated
JohnCampionJr and others added 2 commits August 27, 2026 17:20
Both from review.

Terminal.Reset() cleared every other mode in its block and not this one, so after
RIS the terminal still reported an atomic update that nobody began. Three symptoms
from that one cause, and the third is the one that would bite in the field:

  the flag goes stale;
  the flag is also the event's dedupe key, so once stuck true the next
  application's begin is swallowed as "no change" and its end raises a lone false --
  the transitions-only contract inverted and staying inverted;
  DECRQM answers "set" to a client probing before it draws, telling it a frame is
  already open when none is.

It also lands on precisely the path this feature's timeout exists for: an
application sets the mode and dies, and the usual recovery is reset -- which was the
one thing that could not clear it. Cleared through RaiseSynchronizedOutputChanged
rather than by assignment, so a renderer holding a frame is told it can stop and
there stays one writer of the flag. Four tests, each of which fails without the fix.

The event now carries SynchronizedOutputEventArgs rather than a bare bool. The
reason given is a good one and outlives this feature: adding a property to an
EventArgs is a minor version, while changing a bool into one is a major -- so
anything this needs to carry later can arrive without breaking every subscriber.

Worth noting for the record that the compile error reported alongside it is not
one: EventHandler<TEventArgs> dropped its EventArgs constraint in .NET 4.5, so
EventHandler<bool> built and ran fine. The change is right for the versioning
reason, not that one.

860 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The XML documentation in TerminalEvents.cs currently contains duplicated/misattached <summary> tags that will generate incorrect/invalid API docs and should be corrected before merging.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread src/XTerm.NET/Events/TerminalEvents.cs Outdated
@tomlm
tomlm merged commit 9cd5b57 into tomlm:main Aug 27, 2026
1 check passed
@tomlm tomlm mentioned this pull request Aug 27, 2026
JohnCampionJr added a commit to JohnCampionJr/Iciclecreek.Avalonia.Terminal that referenced this pull request Aug 28, 2026
A full-screen application redraws in many writes, and painting between them shows a
frame half old and half new -- the tearing you see when a TUI repaints under load.
Mode 2026 is how an application says it is mid-frame.

Every paint request in the view now goes through one gate, which declines while an
update is in progress; the end of the update asks for exactly one frame. Gating in
one place rather than at the call sites is what keeps a future paint path from
quietly missing it -- there are twenty-six of them today.

The 150 ms timeout is not optional. An application that begins an update and then
crashes, or stops at a breakpoint, would otherwise freeze the display for as long as
it stays that way. That is the single failure mode of this feature and it is worse
than the tearing it prevents: a tear is a bad frame, while a frozen terminal looks
like the application hung.

Depends on the emulator reporting the state, which is tomlm/XTerm.NET#35. Until that
ships in a package this does not compile -- the only two errors are the two
references to SynchronizedOutputChanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
JohnCampionJr added a commit to JohnCampionJr/Iciclecreek.Avalonia.Terminal that referenced this pull request Aug 28, 2026
A full-screen application redraws in many writes, and painting between them shows a
frame half old and half new -- the tearing you see when a TUI repaints under load.
Mode 2026 is how an application says it is mid-frame.

Every paint request in the view now goes through one gate, which declines while an
update is in progress; the end of the update asks for exactly one frame. Gating in
one place rather than at the call sites is what keeps a future paint path from
quietly missing it -- there are twenty-six of them today.

The 150 ms timeout is not optional. An application that begins an update and then
crashes, or stops at a breakpoint, would otherwise freeze the display for as long as
it stays that way. That is the single failure mode of this feature and it is worse
than the tearing it prevents: a tear is a bad frame, while a frozen terminal looks
like the application hung.

Depends on the emulator reporting the state, which is tomlm/XTerm.NET#35. Until that
ships in a package this does not compile -- the only two errors are the two
references to SynchronizedOutputChanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tomlm pushed a commit to tomlm/Iciclecreek.Avalonia.Terminal that referenced this pull request Aug 28, 2026
* Hold the frame while an application is mid-update (DEC 2026)

A full-screen application redraws in many writes, and painting between them shows a
frame half old and half new -- the tearing you see when a TUI repaints under load.
Mode 2026 is how an application says it is mid-frame.

Every paint request in the view now goes through one gate, which declines while an
update is in progress; the end of the update asks for exactly one frame. Gating in
one place rather than at the call sites is what keeps a future paint path from
quietly missing it -- there are twenty-six of them today.

The 150 ms timeout is not optional. An application that begins an update and then
crashes, or stops at a breakpoint, would otherwise freeze the display for as long as
it stays that way. That is the single failure mode of this feature and it is worse
than the tearing it prevents: a tear is a bad frame, while a frozen terminal looks
like the application hung.

Depends on the emulator reporting the state, which is tomlm/XTerm.NET#35. Until that
ships in a package this does not compile -- the only two errors are the two
references to SynchronizedOutputChanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Add the tearing demo so this can be checked by hand

Two runs of the same program one escape sequence apart. Without mode 2026 a frame
assembles in visible bands; with it each frame appears whole.

It also exercises the part with no unit test. Interrupt a synchronized run partway
through a frame and the display catches up rather than staying frozen -- that is the
150 ms timeout, and whether a frame was ever shown half-drawn is a question about
when paints happened rather than about what the buffer holds, which no assertion on
the buffer can answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Give SynchronizedOutputChanged the same lifecycle as every other terminal event

Tom's review, confirmed: the event was subscribed in OnInitialized AND in the
attach block, and unsubscribed nowhere -- so the handler ran twice from the first
attach, and _terminal held a reference to every detached view's handler forever.
Harmless-looking, because duplicate delivery happens to be idempotent here, which
is exactly why it would only ever have surfaced under a profiler.

Rather than only adding the two removals, the OnInitialized subscription is gone:
the event now lives in the same three places as every other event on _terminal --
the attach block's defensive removal, its subscription, and the detach removal.
One event with a private lifecycle is how this happened.

Detach also clears the update state. A view detached mid-update kept the gate
closed and its timer running; the timeout self-heals in 150 ms, but the timer holds
the view for that window, and a view re-attached inside it would start out refusing
to paint for no reason.

322 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants