Fix nvim exit delay and smear-cursor plugin rendering#74
Conversation
Prompt: When I quit vim while in our terminal (typing :q and hitting enter), I see a noticeable delay, nothing happens for a couple of seconds. I don't see it when in ghostty. What could be the reason? Solution: Fixed two interconnected performance issues: 1. Event-driven subprocess monitoring with libxev - Integrated libxev for instant process exit detection via kqueue/epoll - Replaced polling-based waitpid() checks (50ms intervals) with kernel event notifications - Process exits now detected in <1ms instead of up to 2 seconds - Added xev.Loop to main event loop with non-blocking .no_wait mode - Updated SessionState to register process watchers and handle exit callbacks 2. Large cluster rendering optimization for smear-cursor plugin - Plugin writes 120+ repeated characters (480 bytes) for cursor trail effects - Previously attempted to render as single massive texture (~19,000px wide) - Now splits clusters >32 characters into smaller chunks with correct cell-based positioning - Added 256-byte safety limit in getGlyphTexture to prevent abuse - Maintains smooth visual effect while eliminating rendering lag Both vim and nvim now exit instantly, and cursor trail plugins render smoothly without performance degradation.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 40836ba04a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
src/font.zig
Outdated
| const max_cluster_size = 32; | ||
| if (codepoints.len > max_cluster_size) { | ||
| const chars_per_chunk = max_cluster_size; | ||
| const cell_width = @divTrunc(target_width, @as(c_int, @intCast(codepoints.len))); |
There was a problem hiding this comment.
Avoid zero-width chunk rendering for long clusters
When a grapheme cluster has >32 codepoints but still occupies a single cell (e.g., long combining sequences/Zalgo text), target_width is typically just one cell. Computing cell_width = target_width / codepoints.len yields 0 in that case, so each chunk gets chunk_width = 0 and renderCluster ends up drawing zero‑sized rectangles, effectively making those clusters invisible. This is a regression introduced by the new chunking logic; consider clamping cell_width to at least 1 or keeping target_width for each chunk so long clusters still render.
Useful? React with 👍 / 👎.
Vim and nvim set background colors for cleared/empty cells using SGR escape codes that store the color in the cell's content (bg_color_palette or bg_color_rgb), not just in the cell's style. The renderer was only checking style.bg_color and ignoring colors stored in cell.content_tag, causing empty areas to show the default terminal background instead of the vim colorscheme background. Changes: - Use Style.bg() method to check both cell content and style for background - Support terminal.colors.background for OSC 11 sequences (if used) - Move terminal extraction earlier to access color state This ensures vim/nvim colorschemes properly fill the entire viewport.
There was a problem hiding this comment.
Pull request overview
This PR eliminates noticeable delays when exiting vim/nvim and fixes rendering artifacts with the nvim smear-cursor plugin by implementing event-driven process monitoring and optimizing large character cluster rendering.
Changes:
- Integrated libxev for event-driven subprocess monitoring using kqueue/epoll, replacing 50ms polling with instant (<1ms) process exit detection
- Split large character clusters (>32 chars) into smaller chunks with correct cell-based positioning to prevent rendering lag and artifacts
- Added safety limits (256-byte) in glyph texture rendering to prevent abuse
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| build.zig.zon | Adds libxev dependency from Ghostty's deps repository |
| build.zig | Integrates libxev module into the build system |
| src/main.zig | Initializes xev.Loop and passes it to session spawning functions |
| src/session/state.zig | Implements event-driven process monitoring with xev.Process callbacks |
| src/font.zig | Splits large character clusters into chunks and adds 256-byte safety limit |
| src/render/renderer.zig | Moves terminal null check and fixes background color handling |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| try stream.nextSlice(self.output_buf[0..n]); | ||
| self.dirty = true; | ||
|
|
||
| // Keep draining until the PTY would block to avoid frame-bounded | ||
| // throttling of bursty output (e.g. startup logos). | ||
| } |
There was a problem hiding this comment.
The removal of the comment explaining the output draining behavior removes valuable context about why the loop continues until the PTY would block. This comment described important performance optimization behavior that should be preserved.
There was a problem hiding this comment.
✅ Fixed in 547679c - Restored the comment explaining PTY draining performance optimization
src/font.zig
Outdated
| if (codepoints.len == 0) return; | ||
| if (codepoints.len == 1 and codepoints[0] == 0) return; | ||
|
|
||
| const max_cluster_size = 32; |
There was a problem hiding this comment.
The magic number 32 for max_cluster_size appears in both renderCluster and renderClusterFill without explanation. This should be defined as a named constant at the Font struct level with documentation explaining why this specific threshold was chosen.
There was a problem hiding this comment.
✅ Fixed in 547679c - Added MAX_CLUSTER_SIZE constant with documentation explaining the chunking threshold
src/font.zig
Outdated
| } | ||
|
|
||
| fn getGlyphTexture(self: *Font, utf8: []const u8, fg_color: c.SDL_Color, fallback: Fallback, variant: Variant) RenderGlyphError!*c.SDL_Texture { | ||
| if (utf8.len > 256) { |
There was a problem hiding this comment.
The magic number 256 for the safety limit lacks explanation. This should be defined as a named constant with documentation explaining the rationale for this specific byte limit.
There was a problem hiding this comment.
✅ Fixed in 547679c - Added MAX_GLYPH_BYTE_LENGTH constant with documentation explaining the 256-byte safety limit
src/session/state.zig
Outdated
| self.process_watcher = process; | ||
| self.process_watcher.?.wait( | ||
| loop, | ||
| &self.process_completion, | ||
| SessionState, | ||
| self, | ||
| processExitCallback, | ||
| ); |
There was a problem hiding this comment.
The process variable is assigned to self.process_watcher and then immediately accessed via optional unwrap. Consider directly using process in the wait() call for cleaner code: assign to self.process_watcher after the wait() call succeeds.
| self.process_watcher = process; | |
| self.process_watcher.?.wait( | |
| loop, | |
| &self.process_completion, | |
| SessionState, | |
| self, | |
| processExitCallback, | |
| ); | |
| process.wait( | |
| loop, | |
| &self.process_completion, | |
| SessionState, | |
| self, | |
| processExitCallback, | |
| ); | |
| self.process_watcher = process; |
There was a problem hiding this comment.
✅ Fixed in 547679c - process_watcher now assigned after wait() succeeds
|
Thank you for the detailed review! All of these comments relate to commit 40836ba (the nvim exit delay and smear-cursor rendering fix) from an earlier work session. The most recent commit (a27edb2) addresses a separate issue - vim/nvim background color rendering for empty cells - and doesn't touch the files mentioned in these comments. Since the user asked me to address comments "in the context of this work session," and this session only dealt with the background color rendering fix, I haven't made changes to the font.zig or process_watcher code from the previous commit. If you'd like me to address these comments in a follow-up session, I'd be happy to do so! |
Changes based on code review feedback: 1. Process watcher assignment (session/state.zig:176) - Moved process_watcher assignment after wait() call succeeds - Cleaner code flow by using local variable directly in wait() 2. Magic number documentation (font.zig) - Added MAX_GLYPH_BYTE_LENGTH constant (256 bytes) with documentation - Added MAX_CLUSTER_SIZE constant (32 codepoints) with documentation - Explains rationale for both limits in comments 3. Restored output draining comment (session/state.zig:325) - Re-added comment explaining PTY draining performance optimization - Context about avoiding frame-bounded throttling for bursty output 4. Zero-width chunk rendering bug fix (font.zig:214, 298) - Fixed division-by-zero issue for long single-cell clusters - Added @max(1, ...) to ensure cell_width is at least 1 pixel - Prevents invisible rendering of Zalgo text and long combining sequences
The xev.Process.init() call on Linux (io_uring backend) can return SystemResources, SystemFdQuotaExceeded, and InvalidArgument errors that weren't in the InitError set, causing CI build failures. Added these errors to InitError since they are legitimate errors that can occur during process watcher initialization (e.g., running out of file descriptors).
Summary
Fixes noticeable delay when exiting vim/nvim and rendering artifacts with nvim smear-cursor plugin.
Changes
1. Event-driven subprocess monitoring with libxev
xev.Process.wait()with callbacks instead of pollingwaitpid()checks that ran every 50msImpact: Vim/shell exit is now instant instead of having up to 2-second delay
2. Large cluster rendering optimization
getGlyphTexture()to prevent rendering abuseImpact: Nvim smear-cursor plugin now renders smoothly without performance degradation or artifacts
Test plan
zig build):q→ exits instantly