Skip to content

Commit 88114c2

Browse files
authored
Merge pull request #4647 from Textualize/deadlock-fix
Deadlock fix
2 parents ff918b4 + 604b04d commit 88114c2

File tree

13 files changed

+109
-23
lines changed

13 files changed

+109
-23
lines changed

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8-
## Unreleased
8+
## [0.68.0] - 2024-06-13
99

1010
### Added
1111

1212
- Added `ContentSwitcher.add_content`
1313

14+
### Fixed
15+
16+
- Improved handling of non-tty input https://github.com/Textualize/textual/pull/4647
17+
1418
## [0.67.1] - 2024-06-12
1519

1620
### Changed
@@ -2128,6 +2132,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
21282132
- New handler system for messages that doesn't require inheritance
21292133
- Improved traceback handling
21302134

2135+
[0.68.0]: https://github.com/Textualize/textual/compare/v0.67.1...v0.68.0
21312136
[0.67.1]: https://github.com/Textualize/textual/compare/v0.67.0...v0.67.1
21322137
[0.67.0]: https://github.com/Textualize/textual/compare/v0.66.0...v0.67.0
21332138
[0.66.0]: https://github.com/Textualize/textual/compare/v0.65.2...v0.66.0

examples/dictionary.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ async def lookup_word(self, word: str) -> None:
4646
results = response.json()
4747
except Exception:
4848
self.query_one("#results", Markdown).update(response.text)
49+
return
4950

5051
if word == self.query_one(Input).value:
5152
markdown = self.make_word_markdown(results)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "textual"
3-
version = "0.67.1"
3+
version = "0.68.0"
44
homepage = "https://github.com/Textualize/textual"
55
repository = "https://github.com/Textualize/textual"
66
documentation = "https://textual.textualize.io/"

src/textual/app.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2564,6 +2564,7 @@ async def invoke_ready_callback() -> None:
25642564
console = Console()
25652565
console.print(self.screen._compositor)
25662566
console.print()
2567+
25672568
driver.stop_application_mode()
25682569
except Exception as error:
25692570
self._handle_exception(error)
@@ -2616,9 +2617,14 @@ async def recompose(self) -> None:
26162617
26172618
Recomposing will remove children and call `self.compose` again to remount.
26182619
"""
2619-
async with self.screen.batch():
2620-
await self.screen.query("*").exclude(".-textual-system").remove()
2621-
await self.screen.mount_all(compose(self))
2620+
if self._exit:
2621+
return
2622+
try:
2623+
async with self.screen.batch():
2624+
await self.screen.query("*").exclude(".-textual-system").remove()
2625+
await self.screen.mount_all(compose(self))
2626+
except ScreenStackError:
2627+
pass
26222628

26232629
def _register_child(
26242630
self, parent: DOMNode, child: Widget, before: int | None, after: int | None
@@ -3409,14 +3415,15 @@ async def _prune_node(self, root: Widget) -> None:
34093415
child._close_messages(wait=True) for child in close_children
34103416
]
34113417
try:
3418+
# Close all the children
34123419
await asyncio.wait_for(
34133420
asyncio.gather(*close_messages), self.CLOSE_TIMEOUT
34143421
)
34153422
except asyncio.TimeoutError:
34163423
# Likely a deadlock if we get here
34173424
# If not a deadlock, increase CLOSE_TIMEOUT, or set it to None
34183425
raise asyncio.TimeoutError(
3419-
f"Timeout waiting for {close_children!r} to close; possible deadlock\n"
3426+
f"Timeout waiting for {close_children!r} to close; possible deadlock (consider changing App.CLOSE_TIMEOUT)\n"
34203427
) from None
34213428
finally:
34223429
for child in children:

src/textual/driver.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,6 @@ def process_event(self, event: events.Event) -> None:
9999
and not event.button
100100
and self._last_move_event is not None
101101
):
102-
103102
# Deduplicate self._down_buttons while preserving order.
104103
buttons = list(dict.fromkeys(self._down_buttons).keys())
105104
self._down_buttons.clear()

src/textual/drivers/linux_driver.py

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import rich.repr
1515

1616
from .. import events
17+
from .._parser import ParseError
1718
from .._xterm_parser import XTermParser
1819
from ..driver import Driver
1920
from ..geometry import Size
@@ -46,6 +47,7 @@ def __init__(
4647
super().__init__(app, debug=debug, mouse=mouse, size=size)
4748
self._file = sys.__stderr__
4849
self.fileno = sys.__stdin__.fileno()
50+
self.input_tty = sys.__stdin__.isatty()
4951
self.attrs_before: list[Any] | None = None
5052
self.exit_event = Event()
5153
self._key_thread: Thread | None = None
@@ -235,7 +237,10 @@ def on_terminal_resize(signum, stack) -> None:
235237
# defaults to ASCII EOT = Ctrl-D = 4.)
236238
newattr[tty.CC][termios.VMIN] = 1
237239

238-
termios.tcsetattr(self.fileno, termios.TCSANOW, newattr)
240+
try:
241+
termios.tcsetattr(self.fileno, termios.TCSANOW, newattr)
242+
except termios.error:
243+
pass
239244

240245
self.write("\x1b[?25l") # Hide cursor
241246
self.write("\x1b[?1004h") # Enable FocusIn/FocusOut.
@@ -264,6 +269,8 @@ def _request_terminal_sync_mode_support(self) -> None:
264269
# Terminals should ignore this sequence if not supported.
265270
# Apple terminal doesn't, and writes a single 'p' in to the terminal,
266271
# so we will make a special case for Apple terminal (which doesn't support sync anyway).
272+
if not self.input_tty:
273+
return
267274
if os.environ.get("TERM_PROGRAM", "") != "Apple_Terminal":
268275
self.write("\033[?2026$p")
269276
self.flush()
@@ -309,8 +316,11 @@ def disable_input(self) -> None:
309316
if self._key_thread is not None:
310317
self._key_thread.join()
311318
self.exit_event.clear()
312-
termios.tcflush(self.fileno, termios.TCIFLUSH)
313-
except Exception as error:
319+
try:
320+
termios.tcflush(self.fileno, termios.TCIFLUSH)
321+
except termios.error:
322+
pass
323+
except Exception:
314324
# TODO: log this
315325
pass
316326

@@ -325,13 +335,14 @@ def stop_application_mode(self) -> None:
325335
except termios.error:
326336
pass
327337

328-
# Alt screen false, show cursor
329-
self.write("\x1b[?1049l" + "\x1b[?25h")
330-
self.write("\033[?1004l") # Disable FocusIn/FocusOut.
331-
self.write(
332-
"\x1b[<u"
333-
) # Disable https://sw.kovidgoyal.net/kitty/keyboard-protocol/
334-
self.flush()
338+
# Alt screen false, show cursor
339+
self.write("\x1b[?1049l")
340+
self.write("\x1b[?25h")
341+
self.write("\x1b[?1004l") # Disable FocusIn/FocusOut.
342+
self.write(
343+
"\x1b[<u"
344+
) # Disable https://sw.kovidgoyal.net/kitty/keyboard-protocol/
345+
self.flush()
335346

336347
def close(self) -> None:
337348
"""Perform cleanup."""
@@ -375,18 +386,26 @@ def more_data() -> bool:
375386
utf8_decoder = getincrementaldecoder("utf-8")().decode
376387
decode = utf8_decoder
377388
read = os.read
389+
eof = False
378390

379391
try:
380-
while not self.exit_event.is_set():
392+
while not eof and not self.exit_event.is_set():
381393
selector_events = selector.select(0.1)
382394
for _selector_key, mask in selector_events:
383395
if mask & EVENT_READ:
384396
unicode_data = decode(
385397
read(fileno, 1024), final=self.exit_event.is_set()
386398
)
399+
if not unicode_data:
400+
# This can occur if the stdin is piped
401+
eof = True
402+
break
387403
for event in feed(unicode_data):
388404
self.process_event(event)
389405
finally:
390406
selector.close()
391-
for event in feed(""):
407+
try:
408+
for event in feed(""):
409+
pass
410+
except ParseError:
392411
pass

src/textual/message_pump.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@ def app(self) -> "App[object]":
247247
@property
248248
def is_attached(self) -> bool:
249249
"""Is this node linked to the app through the DOM?"""
250+
if self.app._exit:
251+
return False
250252
node: MessagePump | None = self
251253
while (node := node._parent) is not None:
252254
if node.is_dom_root:

src/textual/signal.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,13 @@ def subscribe(
7979

8080
if immediate:
8181

82-
def signal_callback(data: object):
82+
def signal_callback(data: object) -> None:
8383
"""Invoke the callback immediately."""
8484
callback(data)
8585

8686
else:
8787

88-
def signal_callback(data: object):
88+
def signal_callback(data: object) -> None:
8989
"""Post the callback to the node, to call at the next opertunity."""
9090
node.call_next(callback, data)
9191

src/textual/widget.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,8 @@ def mount_all(
10051005
Only one of ``before`` or ``after`` can be provided. If both are
10061006
provided a ``MountError`` will be raised.
10071007
"""
1008+
if self.app._exit:
1009+
return AwaitMount(self, [])
10081010
await_mount = self.mount(*widgets, before=before, after=after)
10091011
return await_mount
10101012

src/textual/widgets/_footer.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,5 +172,8 @@ async def bindings_changed(screen: Screen) -> None:
172172

173173
self.screen.bindings_updated_signal.subscribe(self, bindings_changed)
174174

175+
def on_unmount(self) -> None:
176+
self.screen.bindings_updated_signal.unsubscribe(self)
177+
175178
def watch_compact(self, compact: bool) -> None:
176179
self.set_class(compact, "-compact")

0 commit comments

Comments
 (0)