Skip to content

Commit e223790

Browse files
authored
Merge pull request #11 from ttak0422/fix/snapshot-output-duplicate-rendering
fix: prevent duplicate rendering when attaching via :Pterm
2 parents 492117f + 7269b99 commit e223790

File tree

4 files changed

+28
-19
lines changed

4 files changed

+28
-19
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ require("pterm").setup({
9090
binary = nil,
9191
-- Default shell command
9292
shell = vim.env.SHELL or "/bin/sh",
93-
-- Fallback terminal size (Neovim window size takes priority)
93+
-- Default terminal size for `pterm new` when created outside Neovim
94+
-- (the bridge reads the actual PTY size via TIOCGWINSZ at attach time)
9495
cols = 80,
9596
rows = 24,
9697
-- Socket directory (nil = let daemon decide)

docs/DESIGN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Notable behavior:
7878
- session socket path: `<socket_root>/<session>/socket`
7979
- if socket file is removed externally, daemon treats session as deleted and exits
8080
- output delivery uses per-client send queues and writable polling to avoid disconnecting on backpressure (`WouldBlock`)
81-
- **snapshot delivery**: no timer-based deferral; snapshot is sent either when the client sends RESIZE (correct dimensions) or when the first PTY OUTPUT arrives (current dimensions as fallback)
81+
- **snapshot delivery**: no timer-based deferral; snapshot is sent either when the client sends RESIZE (correct dimensions) or when the first PTY OUTPUT arrives (current dimensions as fallback). Clients that receive a snapshot are excluded from the same flush cycle's OUTPUT broadcast to prevent duplicate rendering (the snapshot already reflects the effect of those bytes)
8282
- **drain-and-flush**: PTY output uses non-blocking drain (reads until `WouldBlock`) followed by immediate flush — no timer-based micro-batching, minimizing latency while naturally coalescing bytes available at each poll cycle
8383
- EXIT message is queued into `send_buf` (not written directly) to preserve OUTPUT→EXIT ordering under backpressure, and is sent exactly once via an `exit_sent` guard
8484

lua/pterm/init.lua

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -174,17 +174,17 @@ function M.open(session_name, args)
174174
end
175175
end
176176

177-
local win_cols = vim.api.nvim_win_get_width(0)
178-
local win_rows = vim.api.nvim_win_get_height(0)
179-
177+
-- Do NOT pass --cols/--rows to `pterm new` here. The bridge
178+
-- (started by M.attach) will send an accurate RESIZE based on
179+
-- the actual PTY size after terminal-friendly window options
180+
-- (number=false, signcolumn=no, …) have been applied. Passing
181+
-- dimensions at this point would use the pre-option window size,
182+
-- which may differ and trigger an unnecessary resize + snapshot
183+
-- race on the daemon side.
180184
local create_cmd = {
181185
bin,
182186
"new",
183187
session_name,
184-
"--cols",
185-
tostring(win_cols),
186-
"--rows",
187-
tostring(win_rows),
188188
}
189189

190190
if #cmd_parts > 0 then
@@ -249,15 +249,13 @@ function M.attach(session_name)
249249
vim.api.nvim_set_option_value("foldcolumn", "0", { win = win })
250250
vim.api.nvim_set_option_value("statuscolumn", "", { win = win })
251251

252-
-- Pass the current window size so the bridge sends an accurate initial
253-
-- RESIZE before the daemon generates the snapshot.
254-
local win_cols = vim.api.nvim_win_get_width(win)
255-
local win_rows = vim.api.nvim_win_get_height(win)
256-
252+
-- Let the bridge read the actual PTY size via TIOCGWINSZ instead of
253+
-- passing --cols/--rows from Lua. jobstart({term=true}) creates a PTY
254+
-- sized to the current window, and the bridge's get_winsize(stdout)
255+
-- will return exactly that size. This avoids any mismatch between the
256+
-- Lua-reported dimensions and the real PTY geometry.
257257
local job_id = vim.fn.jobstart({
258258
bin, "attach", session_name,
259-
"--cols", tostring(win_cols),
260-
"--rows", tostring(win_rows),
261259
}, {
262260
term = true,
263261
on_exit = function(_, exit_code, _)

src/server.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,17 +253,22 @@ impl Server {
253253

254254
// Clients awaiting snapshot: the arrival of OUTPUT means the VT state
255255
// is populated, so send their snapshot now (no timer needed).
256+
// These clients must NOT also receive the raw OUTPUT bytes below,
257+
// because the snapshot already reflects the effect of those bytes
258+
// (read_pty feeds data to the VT parser before this method runs).
259+
// Sending both would cause Neovim's libvterm to process the same
260+
// content twice, resulting in duplicated rendering.
256261
let snapshot_ids: Vec<usize> = self
257262
.clients
258263
.iter()
259264
.filter_map(|(&id, c)| if c.pending_snapshot { Some(id) } else { None })
260265
.collect();
261-
for id in snapshot_ids {
266+
for id in &snapshot_ids {
262267
log::info!(
263268
"Client {} snapshot triggered by PTY output arrival",
264-
id
269+
*id
265270
);
266-
self.send_snapshot_to_client(id);
271+
self.send_snapshot_to_client(*id);
267272
}
268273

269274
let msg = proto::encode(proto::server::OUTPUT, &self.pending_pty_output);
@@ -272,6 +277,11 @@ impl Server {
272277
let mut disconnected = Vec::new();
273278
let mut flush_ids = Vec::new();
274279
for (&id, client) in self.clients.iter_mut() {
280+
// Skip clients that just received a snapshot — they already have
281+
// the up-to-date screen state and must not get the raw bytes again.
282+
if snapshot_ids.contains(&id) {
283+
continue;
284+
}
275285
client.send_buf.extend_from_slice(&msg);
276286
flush_ids.push(id);
277287
}

0 commit comments

Comments
 (0)