Skip to content

ターミナル描画の作り直し (v1.2.0) - #2

Merged
gomanizm merged 29 commits into
mainfrom
feature/terminal-emulation
Aug 27, 2026
Merged

gomanizm merged 29 commits into
mainfrom
feature/terminal-emulation

Conversation

@gomanizm

Copy link
Copy Markdown
Owner

概要

ターミナル描画の作り直し(v1.2.0)。受信文字を書き足すだけだった実装を、
行×桁の「画面」を持つ形へ置き換えた。あわせて UI の追加が 4 件。

仕様から自作している。既存ターミナル実装のソースは読んでいない
(派生物とみなされる余地を残さないため)。参照したのは Paul Williams の
DEC 互換パーサ状態遷移図、ECMA-48(= ISO/IEC 6429)、ISO/IEC 2022、
RFC 4254、標準に無い部分は XTerm Control Sequences。

主な変更

  • src/core/terminal/ を新設(parser / screen / attrs)。UI に依存しない
  • terminal_widget.py の解釈処理(正規表現・約 300 行)を削除して差し替え
  • ウィンドウサイズを機器へ伝達(RFC 4254 §6.2 / §6.7、SSH のみ)
  • 色・装飾(SGR)の描画、DECCKM とブラケットペーストへの追従
  • ステータスバーの端末サイズ表示、ターミナルタブの並べ替え、
    接続先リストの非表示、更新通知に変更内容を出す

検証

  • 全テスト 725 passed / 2 skipped / exit 0
  • 実機(ラボの Ubuntu と Catalyst 8000v)から採取したデータで検証。
    機器の 7058 バイトにはエスケープが 1 つも含まれず、この経路を
    壊さないことを最優先に据えた
  • リリース前監査(4 観点 12 エージェント+敵対的検証)と codex

監査で見つけて直したもの

タグを打つ前に止められた 3 件:

  1. クラッシュ — 窓を広げたあと ESC[1K / ESC[P で IndexError。
    折り返し行を埋めない変更の副作用。8316 通りの総当たりで 0 件に
  2. 採取データが git 上で壊れていた — cisco_ios_vt100.bin だけ
    テキスト判定され CR を 147 個剥がされていた。Linux clone と
    リリース添付のソースアーカイブでテストが落ちる。.gitattributes で解決
  3. CHANGELOG の誤り — 「修正」に並べた項目が v1.1.1 に存在しない
    不具合だった(v1.1.1 に core/terminal 自体が無い)。実際に v1.1.1 で
    壊れていた 2 項目に絞った。代替画面の記述も誤りだったので訂正

既知の制限

  • 狭いウィンドウで出力された行は、その折り返しのまま画面に残る
    (実端末と同じ。記録へ流れた行は 1 本の行として保存され、
    ウィンドウ幅に追従する)
  • 全角文字は 1 桁として数える
  • 端末種別は vt100 固定のため、nano / vi の画面は記録に残る

🤖 Generated with Claude Code

gomanizm and others added 29 commits August 27, 2026 03:38
Every terminal fixture so far came from a Linux host, so what network
gear sends was guesswork - and the second-opinion review twice asked
whether the full-screen notice could fire on a pager or a terminal
reset. Captured 7058 bytes from the lab Catalyst 8000v over the same
vt100 shell the app opens: show version, show ip route, three screens of
show interfaces through the pager, and a typo rubbed out with backspace.

It contains no escape sequences at all. Not one. The pager erases
--More-- with backspace-space-backspace and line editing does the same,
so the whole device-facing path rests on \r \n \b. That answers the
false-positive question by measurement, and it says what the rewrite
must not break: this is the path a network engineer is on all day.

Two artefacts of real capture are worth keeping. The pager ate the first
character of the next command, so the device answered with % Invalid
input and a ^ under the offending column - a column-alignment invariant
that comes free. And exit arrived as xit for the same reason.

Addresses, MAC addresses and the hostname are replaced with documentation
values; the control bytes are untouched, verified by comparing their
positions before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First piece of the v1.2.0 terminal rewrite. The old widget interprets
control codes with regexes over a text buffer and patches receive
boundaries by hand (_pending_escape); that seam produced most of the
terminal bugs this release cycle. Replace the approach at the root:
implement Paul Williams' DEC-compatible parser state machine
(vt100.net/emu/dec_ansi_parser), which carries its state across feeds,
so a sequence split anywhere on the wire parses identically.

This commit covers ground, escape, escape intermediate, OSC and
SOS/PM/APC; the CSI and DCS states follow in the next commits, so for
now ESC [ falls through to esc_dispatch and ESC P is consumed like
SOS. Nothing imports the module yet. It knows nothing about Qt.
Deliberate deviations from the diagram are listed in the module
docstring (BEL-terminated OSC as xterm does, decoded-str input instead
of bytes, aborted strings are discarded).

The tests pin the property that motivated the rewrite: every split
point of a stream yields the same command list - including the 7058
byte Catalyst 8000v capture, which must come out as Print/Ctrl only
and rebuild byte-for-byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the four CSI states from the Williams diagram (entry, param,
intermediate, ignore). The parser stays deliberately dumb: params come
out as numbers with omitted positions kept as None, private markers
(< = > ?) ride in their own field, and unknown finals still come
through - deciding what to honour is the screen's job, not the
parser's.

Two poison paths follow the diagram rather than modern practice: a
colon (ECMA-48 sub-parameters, e.g. SGR 38:5:1) sends the whole
sequence to csi_ignore, and a param string over MAX_PARAMS does the
same instead of growing without bound. Neither form appears in any
capture; revisit the colon if 256-colour SGR ever shows up.

The Linux capture tests assert measured values, not guesses: nano
under TERM=vt100 builds its screen with DECSTBM (ESC[1;24r) and CUP,
bash toggles bracketed paste (?2004), clear is ESC[H ESC[J, and no
capture contains a single OSC. Every split point of all three streams
parses identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the five DCS states (entry, param, intermediate, passthrough,
ignore) from the Williams diagram. The hook head - private markers,
params, intermediates, final - is captured when passthrough starts,
and the whole thing is delivered as one Dcs command on ST. A CAN/SUB
discards head and body together, matching how the parser already
treats aborted OSC strings.

No capture contains a DCS and the screen will ignore them, but
consuming them by the diagram instead of a shortcut means a device
that does send one (DECRQSS replies, XTGETTCAP) can never desync the
stream or leak its payload into the visible text.

With this the parser implements every state in the diagram. The
module is still imported by nothing but its tests; the screen model
comes next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Value object for what ECMA-48 calls select graphic rendition: colour,
bold, underline, reverse. Cells will hold these as-is. Colours stay
numbers (0-7 basic, 8-15 bright, 16-255 palette, an (r,g,b) tuple for
direct colour) - mapping a number to an actual pixel colour is the
renderer's decision, because it depends on the light/dark theme, and
core code knows nothing about themes.

SGR is the most frequent sequence in the captures (72 times), all of
it the basic codes, but the extended 38/48 forms cost little and vim
or ls --color will send them the moment someone opens a Linux shell.
Unknown codes are skipped silently; a malformed 38/48 drops the rest
of the parameter list rather than misreading colour components as
attribute codes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A grid of rows x cols cells, each holding (char, Attr). This is the
concept the old widget never had: nano says "row 23 column 1" and now
there is somewhere for that to land. This commit covers printing with
deferred wrap (the VT100 right-edge quirk - a CR after an exactly
80-column line must not mint a phantom blank line), C0 controls,
scrolling with history capture, cursor movement and SGR. Erasing,
escape dispatch, margins, the alternate screen and modes follow in
the next commits.

Lines scrolled off the top of a full-screen region go to history,
which is how the session record survives the grid being only 24 rows.

The Catalyst capture already renders correctly on the model alone, no
Qt: --More-- fully erased, the backspace-corrected typo clean, the ^
error marker in column 10, 12 indented legend lines - all equal to
what the old widget shows today. Walking every 7th split point of the
7058 bytes takes ~2 s here versus ~4 min through the widget, which is
the testability the rewrite was after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ED/EL erasing, ECH, line and character insertion/deletion (IL DL ICH
DCH), explicit scrolls (SU SD) and DECSTBM margins. Erased cells drop
their attributes, edited lines keep their exact width, and margins
outside the screen are refused like a real VT does.

Two behaviours carry policy, not just spec: erasing the display never
touches history (clear keeps the session record - the v1.1.1
decision), and a partial scroll region never feeds history, because
what scrolls inside an application's pinned region (nano's editing
area between its title and shortcut bars) is repainting, not session
output.

DECSTBM also homes the cursor, which matters because nano's ESC[1;24r
is the very first thing it sends and the marker the full-screen
notice keys on today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rest of the screen model. ESC dispatch (save/restore cursor,
index, reverse index, full reset), ISO 2022 charset designation with
the DEC line-drawing set, the xterm private modes that actually occur
in captures (?1049/1047/47 alternate screen, ?7 autowrap, ?25 cursor,
?1 cursor keys, ?2004 bracketed paste - the last two only tracked and
exposed for the input side), OSC window titles, DSR/DA answered
through a response queue the connection can drain, and set_size for
window-change support.

Two findings from measuring the real clear capture: it is not ESC[2J
but ESC[H ESC[J - a mode 0 erase from home - followed by 216 NUL
padding bytes (the vt100 terminfo $<50> delay), so the keep-the-
session-record rule now triggers on any erase that covers the whole
screen, not just mode 2. And one Python trap fixed on the way: the
charset branch tested `intermediate in "()"`, which is a substring
check that is true for the empty string, so every plain ESC dispatch
was being eaten as a charset designation until the tests caught it.

The nano capture now assembles the full 24x80 layout on the model:
title bar reverse-video on row 0, shortcut bars on rows 22-23, cursor
parked in the editing area. The old widget flattened all of this into
one line; that difference is the whole point of v1.2.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The widget now feeds received text to the parser, applies the
commands to the Screen, and mirrors the result into the QTextEdit.
The document becomes [settled record] + [live screen]: lines pushed
off the top join the record and are never touched again, and the live
region is re-rendered per batch - but only the changed span (common
prefix/suffix trimmed), because wiping the whole region would destroy
a selection every time the device emits a log line. The cursor row
keeps its blanks up to the cursor column so a prompt's trailing space
still shows and the caret sits where the device put it. DSR and DA
queries are answered back through the key_pressed path.

nano now draws its real 24x80 layout in the widget - title bar,
shortcut bars, cursor in the editing area - where v1.1.1 could only
print an apology notice. Reconnection swaps in a fresh parser and
screen; the old screen's rendering stays in the record.

tests/test_terminal_fullscreen.py pinned that notice and the regex
fragment carry-over, both of which this change removes on purpose;
its surviving properties (split invariance, no title leaks, records
outliving clear and nano, reconnect isolation, selections surviving
output) move to tests/test_terminal_widget_render.py. The old escape
machinery in the widget is now dead code; the next commits remove it.
The widget-level Cisco split walk drops from ~4 min to ~5 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dead since the widget switched to the parser + screen model. This was
the 195-line loop that read escape sequences with regexes over the
QTextEdit document, carried split fragments in _pending_escape, and
edited text around a persistent render cursor - the seam most of the
v1.1.x terminal bugs lived in. Its helpers and constants go in the
next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The notice ("full-screen apps are not supported") existed because the
widget could not honour cursor addressing; now it can, and nano
actually renders. Gone with it: the DECSTBM/CUP heuristics that
guessed when a full-screen app was starting, the alt-screen mode
list, the incomplete-escape regex and its carry limit, the visible
line estimate, and the per-tab render cursor - all machinery of the
old interpreter. The device capture test keeps proving the heuristics
are not missed: a network device sends no escape sequences at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colour, bold, underline and reverse video now reach the glyphs.
Numbers map to pixels only here in the renderer (xterm's default 16,
the 6x6x6 cube, the grayscale ramp, direct RGB); the core model keeps
numbers, as designed. Reverse video swaps the terminal's configured
palette pair, so --More-- highlights and nano's title bar band come
out right on any colour scheme - and attribute-carrying blanks are no
longer trimmed at line ends, because that band IS trailing spaces.

Painting is a format-only pass over rows the screen marked dirty:
setCharFormat moves no characters, so selections survive it, and
default-attribute text gets an empty format on purpose - pinning an
explicit colour would freeze old output when the user changes the
palette in settings.

Under TERM=vt100 devices send no colour today; what this buys now is
reverse and bold, and the rest is ready the day the terminal type
setting learns xterm-256color.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The grid was pinned at 80x24 since the first release, so show output
wrapped at 80 columns no matter how wide the window was. Now the
widget measures how many rows and columns fit (font metrics x
viewport), resizes the screen model, and announces the change:
SSHConnection.set_terminal_size sends an RFC 4254 6.7 window-change
on a live channel and feeds the 6.2 pty-req when called before
connecting - which MainWindow now does, so a session starts at the
real window size instead of 80x24.

Resize storms while dragging are coalesced through a 200 ms timer,
an unchanged size is not re-announced, and a dead channel swallows
the notify without taking the session down. Telnet has its own
standard for this (RFC 1073 NAWS) but our client does not negotiate
options, so non-SSH connections are silently left at their fixed
size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Arrow keys now follow DECCKM: while an application holds ESC[?1h -
nano does - they go out as SS3 (ESC O A) instead of CSI, which is the
form such applications actually decode. And pasted or dropped text is
wrapped in xterm bracketed-paste markers, but only while the far end
has ESC[?2004h raised; bash then treats the paste as one edit instead
of executing each line on sight. Network devices raise neither mode,
so nothing changes on the primary path - both switches live in the
screen model the device itself controls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old widget implemented erase-to-start-of-line by deleting the
text and pulling the rest left, and this test pinned that. ECMA-48
says EL blanks the span - including the cursor position - and shifts
nothing; shifting is exactly what breaks column-aligned output. The
screen model does it by the book (test_terminal_screen already pins
"   def" for the same operation), so the widget-level expectation
moves to the standard behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five fixes from the review round and from testing the build on real
gear.

Shrinking the window destroyed text: set_size truncated every line at
the new width, so narrowing the window while reading a long line - an
authorized_keys entry, a wide show output - permanently ate its tail,
and widening again did not bring it back. It now reflows: overflow
wraps onto following lines, surplus rows go to history, and blank
bottom rows are dropped before any written line is. The buffer parked
behind an alternate screen is reflowed too, so opening vi and then
resizing no longer silently drops the shell's bottom rows.

Repeat counts are now clamped to the screen. CSI parameters may carry
256 digits, so ESC[999999999S spun _scroll_up for tens of minutes on
the GUI thread while _new_history grew past a million rows, with no
way out but killing the app. Line commands clamp to rows, character
commands to columns; cursor moves are left alone because _move
already clamps them.

ED 3 erases saved lines, not the display - it was clearing the
visible screen and, backwards, growing the history instead. NetBelt
does not discard the session record, so it now does nothing. And
clearing DECAWM now also drops a pending wrap, which otherwise let
exactly one more character spill onto the next line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Testing the build on real gear turned up three visible faults, all
from the same root: the live screen was drawn only as tall as its
last non-blank row.

clear looked like it did nothing - the region collapsed, so scrollback
rose to fill the viewport. nano drew half off-screen, because
ensureCursorVisible scrolled to the caret up in the editing area
rather than to the screen's bottom. The screen is now always drawn its
full height and the view is pinned to the bottom, which is what a
terminal does: the last N lines of the document are exactly what the
device has on screen.

Selections no longer die when the screen scrolls. A line scrolling off
shifted every row, so the diff found no common prefix and replaced the
whole region, taking any selection with it. The scrolled-off line is
already in the document, so instead of writing it again the boundary
between record and live screen just moves past it - nothing is edited,
so nothing is lost. Re-inserted text also carries an explicit empty
format now, and rows covered by the replacement are repainted, so
colours no longer bleed from whatever sat at the insertion point.

Notices the app writes itself (disconnect banner, errors, serial
messages) used bare LF. A terminal's LF moves down without returning
to column 0, so with a prompt on screen the disconnect banner came out
in a staircase. They go through show_notice, which converts to CRLF;
device output keeps its LF meaning untouched. Session logs also keep
tabs again - dropping them ran table columns together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The thread-safety check matched the failure notice as an exact source
literal, so switching it to CRLF for the terminal broke a test that
has nothing to say about line endings. It now matches the call shape
instead: three emits through the signal, no direct widget call. Both
mutations - routing one through the widget, and dropping one of the
three - are still caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resizing narrow and back left output permanently chopped up: ls /etc/
came back with its columns split mid-line, and because the chopped
rows had already scrolled into the record, later commands inherited
the mess. The screen split long lines on the way down but had no way
to tell a wrap from a line break the device actually sent, so widening
could not put them back together.

Each row now carries a flag saying whether it continues onto the next,
set only where autowrap actually carried a character over. Reflow
joins each run of continued rows into one logical line before
splitting it at the new width, so narrowing and widening is a round
trip. The flags ride along through scrolls, insertions, erases and the
alternate-screen swap, and a row cleared to the end of line stops
being a continuation.

The renderer uses the same flag: a continued row goes into the
document without a newline after it, so a line that scrolled off while
the window was narrow is one long line in the record and re-wraps
freely at whatever width the window has later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Testing on real gear showed output being eaten one piece per resize
cycle: shrink to the minimum and restore, and the tail of a wrapped
line was gone; do it again and more went, until only the first
20 characters - one minimum-width row - were left.

The cause was reflow itself. Recomputing every line break on each
resize means the document the renderer had already settled no longer
lines up with what the model hands over, and each pass shaved a bit
more off. Chasing that with a smarter renderer only moved the seam.

So the model no longer re-splits. A resize widens short rows to the
new width and never truncates or re-wraps anything already written -
which is what real terminals do, and what this app did before v1.2.0.
Rows may now be longer than the screen is wide; erasing uses each
row's own length, and the view wraps long lines for display. Lines
that must leave a shrinking screen still go to history rather than
being dropped, including the main screen parked behind vi.

Verified against the lab box: five shrink-to-minimum-and-back cycles
leave the document byte-identical, where before the first cycle
already lost the end of the key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Working out why output vanished on resize took several rounds partly
because nobody could say how wide the window had been at the time.
The size the app reports to the device is now visible while you work.

It sits in the status bar's permanent area, so it never competes with
the transient showMessage notices, and it follows the visible tab
rather than whichever terminal happened to resize last.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Requested while testing. The tool-area tabs already work this way;
terminal tabs did not.

Nothing had to change underneath: a tab is matched to its device by
name and widget, and every index in this file is read at the moment
of the event it belongs to, never stored. Tests cover what a reorder
could plausibly break - output reaching the right terminal, closing
the tab you actually clicked, the current-tab name, and reconnection
reusing the same tab from its new position. All three mutations
(disabling the move, closing by a fixed index, matching a tab without
checking its name) are caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Widening the window put a block of spaces in the middle of a key:
the two halves of the wrapped line rejoined with the gap between
them, because widening had padded the first row out to the new width
and the padding was carried into the join.

A wrapped row's length is the record of where it wrapped, so a resize
now leaves it alone and only pads rows that end on their own. Rows
can therefore be shorter than the screen is wide, and printing past
the end of one grows it on demand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Requested while testing. The tool area could already be collapsed by
its splitter handle, hidden from its tab bar's context menu, and
toggled from the View menu; the device list had none of that.

It now has the same three ways in and out. Every device-tree context
menu carries the entry - on a device, on a group, and on empty space
- so it does not matter where the click lands. The console group's
menu used to return early and show nothing at all; it now shows at
least this.

Bringing it back gives it a width again: a list restored after being
dragged to zero would otherwise come back invisible and read as
"it won't come back". The terminal stays non-collapsible, being the
part of the window there is no point in hiding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two release blockers found by the pre-release audit.

Erasing to the start of a line, or deleting characters, crashed the
app outright. Since b8409cf a wrapped row is left at its wrap width
instead of being padded, so a row can be narrower than the screen -
while the cursor may still sit anywhere up to the last column. EL 1,
ED 1 and DCH indexed the row by the cursor column and raised
IndexError, which takes the whole app down. Every place that touches
a row now bounds itself by that row's own length. A sweep of 8316
combinations of sequence, parameter, cursor column and row/width
mismatch now completes without an exception.

The Catalyst capture was also being stored wrong. With no
.gitattributes and core.autocrlf on, git decided the file was text -
it contains only CR, LF and BS - and stripped all 147 CRs on the way
into the index: 7058 bytes in the working tree, 6911 in the commit.
Windows checkouts put the CRs back, so nothing showed locally, but a
Linux clone and the Source code archive attached to each release -
the GPL corresponding source - carried the broken file, where
test_terminal_cisco fails on the column of the ^ marker. Verified by
cloning with core.autocrlf=input: 6911 bytes and a failing test
before, 7058 bytes and 7 passing after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The update notice showed the release body, which held only the
install guide and licence notice - nothing about what had changed.

Releases now put the CHANGELOG section for that version at the top of
the body, with the standing notice below it (moved to
.github/release-body.md so the workflow can compose the two). The
licence text and the GPL source offer stay exactly as they were.

The app asks for the release list rather than just the newest one, so
someone who skipped a few versions sees each of them in order, newest
first, capped at five with a line saying how many were left out. It
is the same single request as before, so the unauthenticated rate
limit is unaffected; drafts and prereleases are filtered out here
rather than by the endpoint, and the list is re-sorted by version
instead of trusting its order.

One trap found by running the workflow's PowerShell locally: -like
reads [ and ] as a character class, so "## [1.2.0]*" matches nothing
and the release would have failed at the tag. It uses StartsWith.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Splitter positions are remembered across restarts, so a list
collapsed to nothing and left that way came back the same on the next
launch - visible as far as Qt was concerned, invisible to the person
looking at it. The View menu then appeared dead, because the first
click hid a list that was already unseen and only the second brought
it back. A pane with no width now counts as hidden.

Also corrects the changelog. Everything previously listed under
"fixed" was a fault introduced during this rewrite and caught before
release, not something a v1.1.1 user ever met - v1.1.1 has no screen
model at all. Listing them tells people they were suffering from bugs
they never had, so the section now names only what was genuinely
broken for them: full-screen apps collapsing to one line, and output
wrapping at 80 columns however wide the window. Three claims that
overstated the work are corrected too, and the device queries the app
now answers on its own (cursor position, device attributes), the
pager's page size following the window height, and full-width
characters counting as one column are all written down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The notes box printed the release body verbatim, so a changelog
arrived as a wall of #, ### and - with the text buried in it. Now
that the body carries the changelog rather than an install guide,
that markup is most of what is on screen. It renders as markdown,
falling back to plain text if that ever fails.

Checked end to end for a v1.1.0 user finding v1.3.0: all three
skipped versions arrive in the dialog, in order, with the version
already installed left out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The step that assembles the release body only ran on a tag, so the
first time it executed was the release itself - and it had already
been caught failing once locally (PowerShell reads [ ] in -like as a
character class, so the changelog heading never matched). Publishing
is still tag-only, but the assembly now runs on a manual dispatch
too, prints the first lines it produced, and fails if a template
placeholder survived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gomanizm
gomanizm merged commit e4d99c4 into main Aug 27, 2026
1 check passed
gomanizm added a commit that referenced this pull request Sep 12, 2026
…oning it

Closing the update dialog mid-download went three different ways, and all
three were wrong. Esc reaches QDialog.reject(), which never runs
closeEvent, so the download was neither confirmed nor cancelled and the
thread kept running as a child of the dialog. The cancel button and the
window's close button called wait(10000) on the GUI thread, freezing the
window for ten seconds. And in every case the still-running QThread stayed
parented to the dialog, so destroying the main window at exit destroyed a
running QThread and Qt aborted the process.

Measured (inspector, release #2): after Esc, `dialog visible: False
result: 0 thread running: True _cancelled flag: False`; the cancel path
took `cancel path took 10.0s`; both ended in `QThread: Destroyed while
thread '' is still running` and `EXIT: -1073740791 (hex 0xC0000409)`.

Fix: Esc, the cancel button and closeEvent now share one path that asks
once, then hands the thread over: signals disconnected, parent cleared, a
cancel requested, and the object kept in a module-level set until its
finished signal arrives (with a bounded wait at aboutToQuit). The GUI
never waits. Cancelling also closes the HTTP response
(VersionManager.abort()), so a stalled read stops at once instead of
sitting until the 60 second timeout.

Tests: tests/test_update_dialog_cancel.py -- Esc and close release the
thread promptly, "No" keeps the download, a child process survives having
its parent widget destroyed (it exited 3221226505 before this change), and
abort() ends a stalled read.

Co-Authored-By: Claude Fable 5.1 <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.

1 participant