mirror of
https://github.com/nmasur/dotfiles
synced 2026-09-14 12:58:11 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a72e81fcce | ||
|
|
fe2843ead6 |
@@ -1,5 +1,23 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-08-29 (root cause found and fixed)
|
||||||
|
|
||||||
|
- **Root-caused and fixed the recurring post-TUI typing lag** (fish + Zellij + Ghostty) using a `lag-triage` capture from a live lagging shell plus a deterministic PTY reproduction (`presets/programs/lag-triage/upstream_repro.py`):
|
||||||
|
- **Root cause chain**: (1) fish latches feature flags from its **startup environment**, before `config.fish` runs — so the existing `set -gx fish_features no-query-term` in `shellInit` never applied to the shell that set it, only to its children. (2) Zellij spawns pane shells via `default_shell` with no `fish_features` in the environment, so every pane's fish latched `query-term` **on** (the fish 4.8.1 default; the triage log from the lagging shell confirmed `query-term on` while `$fish_features` was correctly set to `no-query-term`). (3) With query-term on, fish sends OSC 11 + CPR (`\e[6n`) + DA1 (`\e[0c`) after **every** command and waits for replies relayed by Zellij. (4) Reproduced on fish 4.8.1: if the terminal fails to reply during just **one** such cycle — answering everything before and after — that fish process's interactive reader is **permanently degraded** (keystroke echo >3s, never recovers; ~35ms before). In production Zellij drops/mangles a relay during TUI teardown or heavy output (cf. zellij-org/zellij#5158), e.g. after `nh home switch`, nvim, jjui, yazi.
|
||||||
|
- **Why every previous observation finally makes sense**: subshells and `exec fish` were never "resetting" anything — they *inherited* the exported `fish_features=no-query-term` from config.fish, latched query-term off at startup, and were therefore **immune**. The parent zellij-spawned shell never had the variable at startup and stayed vulnerable. Raw keystroke capture in the lagging pane showed instant plain bytes (input path fine) and no stuck terminal modes — the damage was inside the fish process, exactly as the repro shows.
|
||||||
|
- **Fix**: `zellij.nix` now spawns panes through a `fish-no-query-term` wrapper (`export fish_features=no-query-term; exec fish`), so the feature is latched off in every pane shell. Verified: interactive fish through the built wrapper with the real config reports `query-term off`; the PTY repro with `no-query-term` in the environment shows ~35ms echo through all failure phases.
|
||||||
|
- **Correction** to the earlier 2026-08-29 entry: `query-term` does **not** default to off in fish 4.8.1 — it defaults on; it only *appeared* off in non-interactive checks because the user config's `set -gx` takes effect for `fish -c` (no reader latch) but not for interactive shells.
|
||||||
|
- Upstream: fish-shell should bound the reader's wait for query replies instead of degrading permanently (repro script kept at `presets/programs/lag-triage/upstream_repro.py` for filing); Zellij's reply relaying is the trigger (zellij-org/zellij#5158).
|
||||||
|
- `lag-triage` now checks `status features` and calls out `query-term on` as the known root cause, and warns that its `read`-prompt typing tests may not exhibit main-commandline lag (which produced a false "fixed by stage A" in the first capture).
|
||||||
|
|
||||||
|
## 2026-08-29 (later)
|
||||||
|
|
||||||
|
- Added a diagnostic toolkit (`lag-triage` / `unlag` fish functions + `term-probe` binary, `presets/programs/lag-triage/`) for the still-recurring post-TUI typing lag in fish + Zellij + Ghostty, instead of another blind fix. Findings that motivated it:
|
||||||
|
- All three prior fixes were either no-ops or insufficient: `fish_features = no-query-term` is a **no-op** because `query-term` already defaults to *off* in fish 4.8.1 (verified with `status features`); disabling Ghostty's fish integration inside Zellij and setting `support_kitty_keyboard_protocol = false` did not stop recurrence.
|
||||||
|
- PTY captures of fish 4.8.1 (`TERM=xterm-256color`, with and without `$ZELLIJ`) show fish never writes Kitty keyboard sequences to the wire — it uses modifyOtherKeys (`\e[>4;1m`), application keypad (`\e=`), bracketed paste (`?2004`), and color-theme reporting (`?2031`), enabling them at every prompt and disabling them before every external command. Crucially, a fresh subshell's startup bytes are identical to the parent's post-command re-enable bytes, so "a subshell fixes the lag" cannot be explained by a simple terminal-state reset — leaving two competing hypotheses that only live capture can separate: (1) fish-internal reader state poisoned by stray/partial escape bytes (e.g. leaked from a closing floating pane), cleared only by a new fish process; (2) Zellij/Ghostty-level stuck state (Zellij 0.45's `StdinAnsiParser` is already a proven source of input delays — see the Alt-Shift-P fix below).
|
||||||
|
- Also note: the floating-pane TUIs (jjui via Alt-Shift-J, yazi via Alt-Shift-Y, scrollback editor) run in their own panes and never pass through the shell's fish process at all, while `nvim` runs inside the shell pane — the triage log records which path preceded the lag.
|
||||||
|
- **Next occurrence: run `lag-triage` in the lagging shell BEFORE starting a new shell.** It snapshots the environment, queries pane terminal state (kitty flags, modifyOtherKeys, DEC modes, DA1 round-trip latency), captures raw keystroke bytes+timing bypassing fish, then applies staged resets (kitty pop/clear, modifyOtherKeys off, keypad/cursor, mouse/focus/sync, altscreen, stty, DECSTR) — the stage that cures it names the stuck layer. Logs to `~/.local/state/lag-triage/` for an upstream issue. `unlag` is the one-shot convenience version (if `unlag` never helps but `exec fish` does, the bug is fish-internal).
|
||||||
|
|
||||||
## 2026-08-29
|
## 2026-08-29
|
||||||
|
|
||||||
- Fixed 1.5-second latency when pressing `Alt-Shift-P` to trigger `zellij-session` in Zellij 0.45.0 + Ghostty:
|
- Fixed 1.5-second latency when pressing `Alt-Shift-P` to trigger `zellij-session` in Zellij 0.45.0 + Ghostty:
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
pkgs,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.nmasur.presets.programs.lag-triage;
|
||||||
|
|
||||||
|
term-probe = pkgs.writeScriptBin "term-probe" ''
|
||||||
|
#!${lib.getExe pkgs.python3}
|
||||||
|
${builtins.readFile ./term_probe.py}
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
options.nmasur.presets.programs.lag-triage.enable =
|
||||||
|
lib.mkEnableOption "Terminal input-lag triage tools";
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
|
||||||
|
home.packages = [ term-probe ];
|
||||||
|
|
||||||
|
programs.fish.functions = {
|
||||||
|
lag-triage = {
|
||||||
|
description = "Diagnose post-TUI typing lag in the current shell";
|
||||||
|
body = builtins.readFile ./lag-triage.fish;
|
||||||
|
};
|
||||||
|
unlag = {
|
||||||
|
description = "Reset terminal state left behind by a TUI";
|
||||||
|
body = builtins.readFile ./unlag.fish;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Guided diagnosis for the post-TUI typing-lag problem (Ghostty + Zellij + fish).
|
||||||
|
# Run this IN THE LAGGING SHELL the moment you notice the lag, BEFORE starting
|
||||||
|
# a new shell. It captures evidence, then applies targeted resets one at a time
|
||||||
|
# so the stage that cures the lag identifies the layer holding stuck state.
|
||||||
|
# Everything is logged for filing an upstream issue.
|
||||||
|
|
||||||
|
set -l logdir ~/.local/state/lag-triage
|
||||||
|
mkdir -p $logdir
|
||||||
|
set -l logfile $logdir/(date +%Y%m%d-%H%M%S).log
|
||||||
|
|
||||||
|
function _lt --inherit-variable logfile
|
||||||
|
echo $argv | tee -a $logfile
|
||||||
|
end
|
||||||
|
|
||||||
|
function _lt_ask --inherit-variable logfile
|
||||||
|
# usage: _lt_ask VARNAME prompt... -> sets global $VARNAME (default: skip)
|
||||||
|
set -l __name $argv[1]
|
||||||
|
read -g -P "$argv[2..] " $__name
|
||||||
|
or set -g $__name skip
|
||||||
|
test -z "$$__name"; and set -g $__name skip
|
||||||
|
echo "ANSWER $__name: $$__name" >>$logfile
|
||||||
|
end
|
||||||
|
|
||||||
|
_lt "== lag-triage "(date)" =="
|
||||||
|
_lt "Log: $logfile"
|
||||||
|
_lt "Answer y / n, or press Enter to skip a question."
|
||||||
|
_lt ""
|
||||||
|
|
||||||
|
# ---- 1. Context -------------------------------------------------------------
|
||||||
|
_lt_ask ans_tui "Which TUI did you just exit (nvim/jjui/yazi/other)?"
|
||||||
|
_lt_ask ans_launch "Launched via (f)loating-pane keybind or (c)ommand typed in this shell?"
|
||||||
|
|
||||||
|
# ---- 2. Snapshot ------------------------------------------------------------
|
||||||
|
begin
|
||||||
|
echo "-- snapshot --"
|
||||||
|
fish --version
|
||||||
|
echo "fish pid: $fish_pid, started: "(ps -o lstart= -p $fish_pid 2>/dev/null)
|
||||||
|
zellij --version 2>/dev/null
|
||||||
|
echo "escape delay: '$fish_escape_delay_ms' sequence delay: '$fish_sequence_key_delay_ms'"
|
||||||
|
env | grep -iE '^(TERM|ZELLIJ|GHOSTTY|COLORTERM)' | sort
|
||||||
|
echo "-- status features --"
|
||||||
|
status features
|
||||||
|
echo "-- stty -a --"
|
||||||
|
stty -a
|
||||||
|
end >>$logfile 2>&1
|
||||||
|
_lt "Captured shell + environment snapshot."
|
||||||
|
|
||||||
|
# Proven root cause of the 2026-08 lag (see docs/CHANGELOG.md 2026-08-29):
|
||||||
|
# fish latches feature flags from its startup env before config.fish runs, so
|
||||||
|
# a shell with query-term ON sends terminal queries after every command; one
|
||||||
|
# reply zellij fails to relay permanently degrades this process's reader.
|
||||||
|
if status features | string match -qr '^query-term\s+on'
|
||||||
|
_lt ""
|
||||||
|
_lt "!! query-term is ON in this shell: fish did NOT get fish_features="
|
||||||
|
_lt "!! no-query-term in its STARTUP environment (config.fish is too late)."
|
||||||
|
_lt "!! This is the proven root cause of the post-TUI lag — a query reply"
|
||||||
|
_lt "!! lost by zellij permanently degrades this fish process's reader."
|
||||||
|
_lt "!! Fix: spawn fish with the variable exported (zellij default_shell"
|
||||||
|
_lt "!! wrapper fish-no-query-term). Subshells are immune because they"
|
||||||
|
_lt "!! inherit the exported variable — that's why a new shell 'fixes' it."
|
||||||
|
else
|
||||||
|
_lt "query-term is off in this shell (good — the known root cause is ruled out)."
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---- 3. Terminal state below the shell --------------------------------------
|
||||||
|
_lt ""
|
||||||
|
_lt "Querying terminal state (takes a few seconds)..."
|
||||||
|
term-probe report 2>&1 | tee -a $logfile
|
||||||
|
_lt ""
|
||||||
|
_lt " ^ Things to look for: kitty flags with a reply > 0, modifyOtherKeys > 1,"
|
||||||
|
_lt " any mouse/alternate-screen mode SET while at a shell prompt, or a slow"
|
||||||
|
_lt " DA1 round-trip (> 100 ms means the input path itself is delayed)."
|
||||||
|
|
||||||
|
# ---- 4. Raw keystroke capture (bypasses fish entirely) ----------------------
|
||||||
|
_lt ""
|
||||||
|
_lt "Raw input capture: type ~10 characters at a steady pace, including one"
|
||||||
|
_lt "ESC press and one arrow key. This shows the exact bytes this pane delivers"
|
||||||
|
_lt "and their timing, with fish's input handling out of the picture."
|
||||||
|
term-probe keylog 2>&1 | tee -a $logfile
|
||||||
|
_lt_ask ans_keylog_instant "Did each keypress appear INSTANTLY in the capture? (y/n)"
|
||||||
|
_lt_ask ans_keylog_plain "Were plain letters single plain bytes like b'a' (not escape sequences)? (y/n)"
|
||||||
|
|
||||||
|
# ---- 5. Scope ---------------------------------------------------------------
|
||||||
|
_lt ""
|
||||||
|
_lt_ask ans_scope_pane "Optional: open a NEW zellij pane/tab and type — laggy there too? (y/n)"
|
||||||
|
_lt_ask ans_scope_window "Optional: type in a separate Ghostty window (outside this zellij session) — laggy? (y/n)"
|
||||||
|
|
||||||
|
# ---- 6. Staged resets -------------------------------------------------------
|
||||||
|
# Each stage resets one category of state a TUI could have left behind.
|
||||||
|
# The first stage that cures the lag names the culprit.
|
||||||
|
set -l fixed none
|
||||||
|
|
||||||
|
_lt ""
|
||||||
|
_lt "Now applying resets one at a time. After each, type into the test prompt"
|
||||||
|
_lt "to judge whether the lag is gone."
|
||||||
|
_lt "CAVEAT: fish's read prompt may NOT exhibit lag even when the main"
|
||||||
|
_lt "commandline does. If typing at these test prompts never feels laggy at"
|
||||||
|
_lt "all, answer 'u' (unsure) instead of 'y' — a 'y' here is only meaningful"
|
||||||
|
_lt "if you could feel the lag at the test prompts before the reset."
|
||||||
|
|
||||||
|
if test $fixed = none
|
||||||
|
_lt ""
|
||||||
|
_lt "Stage A - kitty keyboard protocol: pop stack + clear all flags"
|
||||||
|
printf '\e[<9u\e[=0;1u'
|
||||||
|
_lt_ask ans_stage_a " Test typing here, then Enter — lag gone? (y/n)"
|
||||||
|
test "$ans_stage_a" = y; and set fixed "A (kitty keyboard state)"
|
||||||
|
end
|
||||||
|
|
||||||
|
if test $fixed = none
|
||||||
|
_lt "Stage B - modifyOtherKeys off"
|
||||||
|
printf '\e[>4;0m'
|
||||||
|
_lt_ask ans_stage_b " Test typing here, then Enter — lag gone? (y/n)"
|
||||||
|
test "$ans_stage_b" = y; and set fixed "B (modifyOtherKeys)"
|
||||||
|
end
|
||||||
|
|
||||||
|
if test $fixed = none
|
||||||
|
_lt "Stage C - normal keypad + normal cursor keys"
|
||||||
|
printf '\e>\e[?1l'
|
||||||
|
_lt_ask ans_stage_c " Test typing here, then Enter — lag gone? (y/n)"
|
||||||
|
test "$ans_stage_c" = y; and set fixed "C (application keypad/cursor mode)"
|
||||||
|
end
|
||||||
|
|
||||||
|
if test $fixed = none
|
||||||
|
_lt "Stage D - disable mouse, focus reporting, synchronized output"
|
||||||
|
printf '\e[?1000l\e[?1001l\e[?1002l\e[?1003l\e[?1005l\e[?1006l\e[?1015l\e[?1016l\e[?1004l\e[?2026l'
|
||||||
|
_lt_ask ans_stage_d " Test typing here, then Enter — lag gone? (y/n)"
|
||||||
|
test "$ans_stage_d" = y; and set fixed "D (mouse/focus/sync modes)"
|
||||||
|
end
|
||||||
|
|
||||||
|
if test $fixed = none
|
||||||
|
_lt "Stage E - leave alternate screen"
|
||||||
|
printf '\e[?1049l'
|
||||||
|
_lt_ask ans_stage_e " Test typing here, then Enter — lag gone? (y/n)"
|
||||||
|
test "$ans_stage_e" = y; and set fixed "E (alternate screen)"
|
||||||
|
end
|
||||||
|
|
||||||
|
if test $fixed = none
|
||||||
|
_lt "Stage F - stty sane (line-discipline reset)"
|
||||||
|
stty sane
|
||||||
|
_lt_ask ans_stage_f " Test typing here, then Enter — lag gone? (y/n)"
|
||||||
|
test "$ans_stage_f" = y; and set fixed "F (termios/line discipline)"
|
||||||
|
end
|
||||||
|
|
||||||
|
if test $fixed = none
|
||||||
|
_lt "Stage G - DECSTR soft terminal reset"
|
||||||
|
printf '\e[!p'
|
||||||
|
_lt_ask ans_stage_g " Test typing here, then Enter — lag gone? (y/n)"
|
||||||
|
test "$ans_stage_g" = y; and set fixed "G (DECSTR-resettable mode)"
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---- 7. Verdict --------------------------------------------------------------
|
||||||
|
_lt ""
|
||||||
|
_lt "== Verdict =="
|
||||||
|
if test $fixed != none
|
||||||
|
_lt "Lag cleared by stage $fixed."
|
||||||
|
_lt "That state was stuck BELOW fish — in the Zellij pane or relayed to"
|
||||||
|
_lt "Ghostty — and the TUI you exited ($ans_tui) failed to restore it, or"
|
||||||
|
_lt "Zellij failed to restore it when the pane closed."
|
||||||
|
_lt "Re-probing terminal state after the fix for comparison:"
|
||||||
|
term-probe report 2>&1 | tee -a $logfile
|
||||||
|
_lt ""
|
||||||
|
_lt "-> File this log against zellij (or ghostty, if a separate window also"
|
||||||
|
_lt " lagged). The before/after probe diff pinpoints the exact stuck mode."
|
||||||
|
else if test "$ans_keylog_instant" = y; and test "$ans_keylog_plain" = y
|
||||||
|
_lt "Raw input reaches this pane instantly as plain bytes, and no terminal"
|
||||||
|
_lt "state reset helps: the lag lives INSIDE this fish process (reader state)."
|
||||||
|
_lt "Confirm now: run 'exec fish' — if that cures it, it is fish-internal."
|
||||||
|
_lt ""
|
||||||
|
_lt "-> To catch it in the act, run your next long-lived shell as:"
|
||||||
|
_lt " FISH_DEBUG='reader,term-support' FISH_DEBUG_OUTPUT=$logdir/fish-debug.log fish"
|
||||||
|
_lt " then re-run lag-triage when it recurs and file both logs to fish-shell."
|
||||||
|
else
|
||||||
|
_lt "Keystrokes were delayed or arrived as escape sequences BEFORE fish saw"
|
||||||
|
_lt "them: the problem is in Zellij (client stdin parser / server) or Ghostty."
|
||||||
|
_lt " new pane also laggy: $ans_scope_pane (y -> session-wide, not this pane)"
|
||||||
|
_lt " separate window laggy: $ans_scope_window (y -> Ghostty itself)"
|
||||||
|
_lt "-> File this log against zellij; include the keylog byte capture."
|
||||||
|
end
|
||||||
|
_lt ""
|
||||||
|
_lt "Full log: $logfile"
|
||||||
|
|
||||||
|
functions -e _lt _lt_ask
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""Probe the terminal state of the current pane, below the shell.
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
report - query kitty-keyboard flags, modifyOtherKeys, and DEC private
|
||||||
|
modes directly on /dev/tty, reporting each reply (or lack of
|
||||||
|
one) and its round-trip latency. Answers may come from Zellij
|
||||||
|
(pane state) or be relayed from Ghostty (window state).
|
||||||
|
keylog - raw-mode keystroke capture: prints the exact bytes and
|
||||||
|
inter-key latency for every keypress, bypassing the shell's
|
||||||
|
input machinery entirely. Press q to finish.
|
||||||
|
|
||||||
|
Used by the `lag-triage` fish function to pin down which layer
|
||||||
|
(fish / zellij / ghostty) is holding stuck state when typing lags
|
||||||
|
after a TUI exits.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import select
|
||||||
|
import sys
|
||||||
|
import termios
|
||||||
|
import time
|
||||||
|
import tty
|
||||||
|
|
||||||
|
# DECRQM reply values
|
||||||
|
DECRQM_VALUES = {
|
||||||
|
"0": "not recognized",
|
||||||
|
"1": "SET",
|
||||||
|
"2": "reset",
|
||||||
|
"3": "permanently set",
|
||||||
|
"4": "permanently reset",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEC_MODES = [
|
||||||
|
(1, "application cursor keys (DECCKM)"),
|
||||||
|
(25, "cursor visible"),
|
||||||
|
(1000, "mouse click reporting"),
|
||||||
|
(1002, "mouse drag reporting"),
|
||||||
|
(1003, "mouse all-motion reporting"),
|
||||||
|
(1004, "focus reporting"),
|
||||||
|
(1006, "SGR mouse encoding"),
|
||||||
|
(1049, "alternate screen"),
|
||||||
|
(2004, "bracketed paste"),
|
||||||
|
(2026, "synchronized output"),
|
||||||
|
(2031, "color theme reporting"),
|
||||||
|
]
|
||||||
|
|
||||||
|
QUERIES = [
|
||||||
|
("kitty keyboard flags (\\e[?u)", b"\x1b[?u", rb"\x1b\[\?(\d+)u", None),
|
||||||
|
("modifyOtherKeys (XTQMODKEYS)", b"\x1b[?4m", rb"\x1b\[>4;(\d+)m", None),
|
||||||
|
("background color (OSC 11)", b"\x1b]11;?\x1b\\", rb"\x1b\]11;([^\x07\x1b]+)", None),
|
||||||
|
] + [
|
||||||
|
(
|
||||||
|
f"DEC mode {num} — {desc}",
|
||||||
|
b"\x1b[?%d$p" % num,
|
||||||
|
rb"\x1b\[\?%d;(\d+)\$y" % num,
|
||||||
|
DECRQM_VALUES,
|
||||||
|
)
|
||||||
|
for num, desc in DEC_MODES
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def read_for(fd, seconds):
|
||||||
|
buf = b""
|
||||||
|
end = time.monotonic() + seconds
|
||||||
|
while True:
|
||||||
|
remaining = end - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
r, _, _ = select.select([fd], [], [], remaining)
|
||||||
|
if not r:
|
||||||
|
break
|
||||||
|
buf += os.read(fd, 4096)
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def report(fd):
|
||||||
|
lines = []
|
||||||
|
raw_dump = b""
|
||||||
|
for label, query, pattern, value_names in QUERIES:
|
||||||
|
raw_dump += read_for(fd, 0.02) # drain stragglers
|
||||||
|
start = time.monotonic()
|
||||||
|
os.write(fd, query)
|
||||||
|
buf = b""
|
||||||
|
match = None
|
||||||
|
deadline = time.monotonic() + 0.35
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
buf += read_for(fd, 0.05)
|
||||||
|
match = re.search(pattern, buf)
|
||||||
|
if match:
|
||||||
|
break
|
||||||
|
raw_dump += buf
|
||||||
|
if match:
|
||||||
|
latency = (time.monotonic() - start) * 1000
|
||||||
|
value = match.group(1).decode("ascii", "replace")
|
||||||
|
if value_names:
|
||||||
|
value = f"{value} ({value_names.get(value, '?')})"
|
||||||
|
lines.append(f" {label:45s} = {value:24s} [{latency:6.1f} ms]")
|
||||||
|
else:
|
||||||
|
lines.append(f" {label:45s} = (no reply)")
|
||||||
|
|
||||||
|
# DA1 as a fence: every terminal answers it, so its round-trip time
|
||||||
|
# measures the whole input path (ghostty -> zellij -> pane -> here).
|
||||||
|
start = time.monotonic()
|
||||||
|
os.write(fd, b"\x1b[c")
|
||||||
|
buf = b""
|
||||||
|
match = None
|
||||||
|
deadline = time.monotonic() + 2.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
buf += read_for(fd, 0.05)
|
||||||
|
match = re.search(rb"\x1b\[\?([0-9;]*)c", buf)
|
||||||
|
if match:
|
||||||
|
break
|
||||||
|
raw_dump += buf
|
||||||
|
if match:
|
||||||
|
latency = (time.monotonic() - start) * 1000
|
||||||
|
lines.append(
|
||||||
|
f" {'device attributes (DA1) round-trip':45s} = "
|
||||||
|
f"{match.group(1).decode():24s} [{latency:6.1f} ms]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(f" {'device attributes (DA1) round-trip':45s} = (NO REPLY in 2s!)")
|
||||||
|
lines.append(f" raw bytes received: {raw_dump!r}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def keylog(fd):
|
||||||
|
sys.stdout.write("keylog: capturing raw bytes from the tty. Press q to finish.\r\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
last = time.monotonic()
|
||||||
|
while True:
|
||||||
|
select.select([fd], [], [], None)
|
||||||
|
data = os.read(fd, 4096)
|
||||||
|
now = time.monotonic()
|
||||||
|
delta_ms = (now - last) * 1000
|
||||||
|
last = now
|
||||||
|
sys.stdout.write(f" +{delta_ms:8.1f} ms {data!r} hex={data.hex(' ')}\r\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
if data in (b"q", b"\x03", b"\x04"):
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
mode = sys.argv[1] if len(sys.argv) > 1 else "report"
|
||||||
|
fd = os.open("/dev/tty", os.O_RDWR)
|
||||||
|
old = termios.tcgetattr(fd)
|
||||||
|
lines = None
|
||||||
|
try:
|
||||||
|
tty.setraw(fd)
|
||||||
|
if mode == "report":
|
||||||
|
lines = report(fd)
|
||||||
|
elif mode == "keylog":
|
||||||
|
keylog(fd)
|
||||||
|
else:
|
||||||
|
raise SystemExit(f"unknown mode: {mode}")
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
||||||
|
os.close(fd)
|
||||||
|
if lines:
|
||||||
|
print("terminal state as seen from this pane:")
|
||||||
|
print("\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# One-shot reset of terminal state a TUI may have left behind (kitty keyboard
|
||||||
|
# flags, modifyOtherKeys, application keypad/cursor, mouse/focus reporting,
|
||||||
|
# alternate screen, termios). fish re-enables the modes it wants at the next
|
||||||
|
# prompt, so this is safe to run any time.
|
||||||
|
#
|
||||||
|
# Diagnostic value: if this cures the lag, the stuck state was below fish
|
||||||
|
# (run lag-triage next time to find which mode). If only `exec fish` cures
|
||||||
|
# it, the lag is inside the fish process itself.
|
||||||
|
printf '\e[<9u\e[=0;1u'
|
||||||
|
printf '\e[>4;0m'
|
||||||
|
printf '\e>\e[?1l'
|
||||||
|
printf '\e[?1000l\e[?1001l\e[?1002l\e[?1003l\e[?1005l\e[?1006l\e[?1015l\e[?1016l\e[?1004l\e[?2026l'
|
||||||
|
printf '\e[?1049l'
|
||||||
|
stty sane
|
||||||
|
echo "terminal state reset — if typing still lags, run lag-triage (before exec fish!)"
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Deterministic repro of permanent fish reader degradation (fish 4.8.1).
|
||||||
|
|
||||||
|
This is evidence for an upstream fish-shell report, and the proof behind the
|
||||||
|
fish-no-query-term wrapper in presets/programs/zellij.nix. Not installed by
|
||||||
|
the nix module; run directly: python3 upstream_repro.py [queryterm|noqueryterm]
|
||||||
|
|
||||||
|
Finding: with the query-term feature enabled (latched from the startup env,
|
||||||
|
which is fish's default), fish sends OSC 11 + CPR (\e[6n) + DA1 (\e[0c)
|
||||||
|
after every external command and waits for the replies. If the terminal
|
||||||
|
fails to reply during ONE such cycle -- even though it answered every query
|
||||||
|
before and answers every query after -- that fish process's interactive
|
||||||
|
reader is PERMANENTLY degraded: keystrokes are no longer echoed (>3s each,
|
||||||
|
never recovers). In production this happens when zellij drops/mis-relays a
|
||||||
|
reply during TUI teardown or heavy output (cf. zellij-org/zellij#5158), and
|
||||||
|
it presents as permanent typing lag cured only by replacing the process.
|
||||||
|
With fish_features=no-query-term in the startup environment, the same
|
||||||
|
sequence has zero effect (~35ms echo throughout).
|
||||||
|
|
||||||
|
Phases:
|
||||||
|
A. terminal answers all queries -> echo ~35ms (both variants)
|
||||||
|
B. replies dropped for one command -> queryterm: echo dead, permanently
|
||||||
|
C. replies restored, another command -> queryterm: still dead
|
||||||
|
"""
|
||||||
|
import os, pty, re, select, subprocess, sys, time, fcntl, termios
|
||||||
|
|
||||||
|
VARIANT = sys.argv[1] if len(sys.argv) > 1 else "queryterm"
|
||||||
|
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["TERM"] = "xterm-ghostty"
|
||||||
|
env["ZELLIJ"] = "0"
|
||||||
|
env["ZELLIJ_SESSION_NAME"] = "repro"
|
||||||
|
env["FISH_DEBUG"] = "term-support"
|
||||||
|
env["FISH_DEBUG_OUTPUT"] = f"/tmp/fish-lagrepro-{VARIANT}.log"
|
||||||
|
env.pop("fish_features", None)
|
||||||
|
if VARIANT == "noqueryterm":
|
||||||
|
env["fish_features"] = "no-query-term"
|
||||||
|
|
||||||
|
master, slave = pty.openpty()
|
||||||
|
# give it a size
|
||||||
|
fcntl.ioctl(master, termios.TIOCSWINSZ, b"\x00\x28\x00\x78\x00\x00\x00\x00")
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["fish", "-i", "--no-config"],
|
||||||
|
stdin=slave, stdout=slave, stderr=slave, env=env,
|
||||||
|
preexec_fn=lambda: (os.setsid(), fcntl.ioctl(0, termios.TIOCSCTTY, 0)),
|
||||||
|
close_fds=True,
|
||||||
|
)
|
||||||
|
os.close(slave)
|
||||||
|
|
||||||
|
RESPOND = True
|
||||||
|
transcript = []
|
||||||
|
|
||||||
|
def respond(data):
|
||||||
|
"""Answer terminal queries the way a well-behaved terminal would."""
|
||||||
|
out = b""
|
||||||
|
for m in re.finditer(rb"\x1b\[6n", data):
|
||||||
|
out += b"\x1b[40;1R" # CPR
|
||||||
|
for m in re.finditer(rb"\x1b\[0?c", data):
|
||||||
|
out += b"\x1b[?62;22c" # DA1
|
||||||
|
for m in re.finditer(rb"\x1b\[\?u", data):
|
||||||
|
out += b"\x1b[?0u" # kitty flags
|
||||||
|
for m in re.finditer(rb"\x1b\]11;\?", data):
|
||||||
|
out += b"\x1b]11;rgb:2828/2828/2828\x1b\\" # OSC 11
|
||||||
|
for m in re.finditer(rb"\x1b\[>0?q", data):
|
||||||
|
out += b"\x1bP>|ghostty 1.3.1\x1b\\" # XTVERSION
|
||||||
|
for m in re.finditer(rb"\x1bP\+q[0-9a-fA-F;]+\x1b\\", data):
|
||||||
|
out += b"\x1bP0+r\x1b\\" # XTGETTCAP: not found
|
||||||
|
for m in re.finditer(rb"\x1b\[\?(\d+)\$p", data):
|
||||||
|
out += b"\x1b[?%s;2$y" % m.group(1) # DECRQM: reset
|
||||||
|
return out
|
||||||
|
|
||||||
|
def pump(timeout):
|
||||||
|
"""Read fish output for `timeout` seconds, answering queries if RESPOND."""
|
||||||
|
buf = b""
|
||||||
|
end = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < end:
|
||||||
|
r, _, _ = select.select([master], [], [], 0.03)
|
||||||
|
if r:
|
||||||
|
try:
|
||||||
|
data = os.read(master, 65536)
|
||||||
|
except OSError:
|
||||||
|
return buf
|
||||||
|
buf += data
|
||||||
|
transcript.append(data)
|
||||||
|
if RESPOND:
|
||||||
|
reply = respond(data)
|
||||||
|
if reply:
|
||||||
|
os.write(master, reply)
|
||||||
|
return buf
|
||||||
|
|
||||||
|
def send(s):
|
||||||
|
os.write(master, s if isinstance(s, bytes) else s.encode())
|
||||||
|
|
||||||
|
def measure_echo(chars, settle=0.1):
|
||||||
|
"""Send chars one at a time; measure time until each is echoed."""
|
||||||
|
results = []
|
||||||
|
for ch in chars:
|
||||||
|
pump(settle)
|
||||||
|
t0 = time.monotonic()
|
||||||
|
send(ch)
|
||||||
|
deadline = time.monotonic() + 3.0
|
||||||
|
latency = None
|
||||||
|
buf = b""
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
buf += pump(0.02)
|
||||||
|
if ch.encode() in buf:
|
||||||
|
latency = (time.monotonic() - t0) * 1000
|
||||||
|
break
|
||||||
|
results.append((ch, latency))
|
||||||
|
return results
|
||||||
|
|
||||||
|
print(f"=== variant: {VARIANT} ===")
|
||||||
|
pump(1.2) # startup, queries answered
|
||||||
|
|
||||||
|
send("echo warmup\r")
|
||||||
|
pump(0.8)
|
||||||
|
|
||||||
|
print("phase A: terminal responsive, echo latency per key:")
|
||||||
|
for ch, ms in measure_echo("abcde"):
|
||||||
|
print(f" {ch}: {ms:.0f} ms" if ms else f" {ch}: NO ECHO in 3s")
|
||||||
|
send("\x15") # ctrl-u clear line
|
||||||
|
pump(0.3)
|
||||||
|
|
||||||
|
# Run external command, then STOP answering queries (simulate lost relay)
|
||||||
|
send("sh -c true\r")
|
||||||
|
time.sleep(0.05)
|
||||||
|
RESPOND = False
|
||||||
|
pump(1.0)
|
||||||
|
|
||||||
|
print("phase B: after external command with query replies DROPPED:")
|
||||||
|
for ch, ms in measure_echo("fghij"):
|
||||||
|
print(f" {ch}: {ms:.0f} ms" if ms else f" {ch}: NO ECHO in 3s")
|
||||||
|
send("\x15")
|
||||||
|
pump(0.3)
|
||||||
|
|
||||||
|
# Does it persist across further commands, with responses restored?
|
||||||
|
RESPOND = True
|
||||||
|
send("sh -c true\r")
|
||||||
|
pump(1.0)
|
||||||
|
print("phase C: responses restored, after another external command:")
|
||||||
|
for ch, ms in measure_echo("klmno"):
|
||||||
|
print(f" {ch}: {ms:.0f} ms" if ms else f" {ch}: NO ECHO in 3s")
|
||||||
|
|
||||||
|
send("\x15")
|
||||||
|
pump(0.2)
|
||||||
|
send("exit\r")
|
||||||
|
pump(0.5)
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=3)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
@@ -9,6 +9,22 @@ let
|
|||||||
inherit (config.nmasur.settings) username;
|
inherit (config.nmasur.settings) username;
|
||||||
cfg = config.nmasur.presets.programs.zellij;
|
cfg = config.nmasur.presets.programs.zellij;
|
||||||
|
|
||||||
|
# fish latches feature flags from its startup ENVIRONMENT before config.fish
|
||||||
|
# runs, so the `set -gx fish_features no-query-term` in config.fish only
|
||||||
|
# protects CHILD fish processes — which is why subshells/exec fish were always
|
||||||
|
# immune to the post-TUI typing lag while zellij-spawned pane shells were not.
|
||||||
|
# With query-term latched on, fish sends OSC 11 + CPR + DA1 queries after
|
||||||
|
# every command and waits for replies; if zellij fails to relay even one
|
||||||
|
# reply (a race during TUI teardown or heavy output), that fish process's
|
||||||
|
# reader is PERMANENTLY degraded — reproduced deterministically in a PTY
|
||||||
|
# harness on fish 4.8.1 (see docs/CHANGELOG.md 2026-08-29 and
|
||||||
|
# presets/programs/lag-triage/upstream_repro.py). Spawning fish with the
|
||||||
|
# variable already exported makes every pane shell immune.
|
||||||
|
fish-no-query-term = pkgs.writeShellScriptBin "fish-no-query-term" ''
|
||||||
|
export fish_features=no-query-term
|
||||||
|
exec ${lib.getExe pkgs.fish} "$@"
|
||||||
|
'';
|
||||||
|
|
||||||
zellij-switch-to-last = pkgs.writeShellScriptBin "zellij-switch-to-last" ''
|
zellij-switch-to-last = pkgs.writeShellScriptBin "zellij-switch-to-last" ''
|
||||||
TARGET_SESSION=$(cat ~/.local/state/zellij-last-session)
|
TARGET_SESSION=$(cat ~/.local/state/zellij-last-session)
|
||||||
if [ -z "$TARGET_SESSION" ]; then
|
if [ -z "$TARGET_SESSION" ]; then
|
||||||
@@ -130,7 +146,10 @@ in
|
|||||||
# Spawn fish directly instead of trusting $SHELL, which inherits the
|
# Spawn fish directly instead of trusting $SHELL, which inherits the
|
||||||
# macOS login shell. On darwin that login shell is no longer managed by
|
# macOS login shell. On darwin that login shell is no longer managed by
|
||||||
# nix-darwin, so $SHELL can point at a stale /run/current-system path.
|
# nix-darwin, so $SHELL can point at a stale /run/current-system path.
|
||||||
default_shell = lib.getExe pkgs.fish;
|
# Wrapped to export fish_features=no-query-term BEFORE fish starts —
|
||||||
|
# see the fish-no-query-term comment above for why this must happen in
|
||||||
|
# the environment rather than in config.fish.
|
||||||
|
default_shell = lib.getExe fish-no-query-term;
|
||||||
# default_layout = "compact-top";
|
# default_layout = "compact-top";
|
||||||
# Remove border
|
# Remove border
|
||||||
pane_frames = false;
|
pane_frames = false;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ in
|
|||||||
git.enable = lib.mkDefault true;
|
git.enable = lib.mkDefault true;
|
||||||
helix.enable = lib.mkDefault true;
|
helix.enable = lib.mkDefault true;
|
||||||
jujutsu.enable = lib.mkDefault true;
|
jujutsu.enable = lib.mkDefault true;
|
||||||
|
lag-triage.enable = lib.mkDefault true;
|
||||||
lazygit.enable = lib.mkDefault true;
|
lazygit.enable = lib.mkDefault true;
|
||||||
# neovim.enable = lib.mkDefault true;
|
# neovim.enable = lib.mkDefault true;
|
||||||
nix-index.enable = lib.mkDefault true;
|
nix-index.enable = lib.mkDefault true;
|
||||||
|
|||||||
Reference in New Issue
Block a user