From 6b906de476b4874ebb70bfd265df343cbbba78be Mon Sep 17 00:00:00 2001 From: Donald Date: Sat, 3 Oct 2026 02:58:45 +0000 Subject: [PATCH] Fix retry crash for unregistered remotes in the Sessions popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a window whose target was removed from the registry (its tab stays open by design) and failing landed in the action menu with a "重试连接" row that indexed self.entries directly and raised KeyError, killing the popup with a traceback. Only offer the retry action for registered targets, degrade a stale retry row to a message, and keep the popup alive when a handler raises anything unexpected instead of dying with a traceback. Co-Authored-By: Claude Fable 5.1 --- herdr_hub/selector.py | 32 +++++++++++++++++++++++------- tests/test_selector.py | 45 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/herdr_hub/selector.py b/herdr_hub/selector.py index ec1d6de..25afc7b 100644 --- a/herdr_hub/selector.py +++ b/herdr_hub/selector.py @@ -616,7 +616,13 @@ class SessionSelector: except curses.error: continue - outcome = self._handle_key(key, filtered) + try: + outcome = self._handle_key(key, filtered) + except Exception as error: # noqa: BLE001 - keep the popup alive + # A handler bug must not replace the popup with a traceback. + self._show_main() + self.message = f"内部错误:{type(error).__name__}: {error}" + continue if isinstance(outcome, SelectorResult) or outcome is False: return outcome if isinstance(outcome, SelectorResult) else None @@ -797,7 +803,12 @@ class SessionSelector: elif source.kind == "retry": rows.append(_action_row(source, "retry", "action-retry", "重试连接")) - if source.kind != "retry" and source.error is not None and source.target is not None: + if ( + source.kind != "retry" + and source.error is not None + and source.target is not None + and source.target in self.entries + ): rows.append(_action_row(source, "retry", "action-retry", "重试连接")) if source.kind == "window" and source.endpoint_key is not None: @@ -1008,8 +1019,15 @@ class SessionSelector: self._show_actions(row) elif row.kind in {"retry", "action-retry"}: assert row.target is not None + entry = self.entries.get(row.target) + if entry is None: + # The window's target was removed from the registry while its + # tab stayed open; there is nothing to re-query. + self._show_main() + self.message = f"{row.target} 未登记为远端,无法重试;可通过“添加远端”重新登记" + return None self.results.pop(row.target, None) - self.pool.enqueue(self.entries[row.target]) + self.pool.enqueue(entry) self._show_main() self.message = f"正在重试 {row.target}" elif row.kind == "action-new": @@ -1122,7 +1140,7 @@ class SessionSelector: return result try: self.activate_handler(result) - except (OSError, RuntimeError, ValueError) as error: + except Exception as error: # noqa: BLE001 - shown inside the popup detail = str(error).strip() or "未知错误" self.message = f"打开失败:{detail}" if row is not None: @@ -1144,7 +1162,7 @@ class SessionSelector: if self.rename_handler is not None: try: self.rename_handler(self.input_key, normalized) - except (OSError, RuntimeError, ValueError) as error: + except Exception as error: # noqa: BLE001 - shown inside the popup self.message = str(error) return None @@ -1280,7 +1298,7 @@ class SessionSelector: self.pending_action = None try: outcome = future.result() - except (OSError, RuntimeError, ValueError) as error: + except Exception as error: # noqa: BLE001 - shown inside the popup self._show_main() entry = self.entries.get(action.target) if entry is not None: @@ -1368,7 +1386,7 @@ class SessionSelector: ) try: self.windows = list(self.close_window_handler(window_id)) - except (OSError, RuntimeError, ValueError) as error: + except Exception as error: # noqa: BLE001 - shown inside the popup self.message = f"关闭 Tab 失败:{error}" return self._show_main() diff --git a/tests/test_selector.py b/tests/test_selector.py index f536c45..0a69567 100644 --- a/tests/test_selector.py +++ b/tests/test_selector.py @@ -1000,5 +1000,50 @@ class SelectorActionTests(unittest.TestCase): os.waitpid(pid, 0) +class SelectorRobustnessTests(unittest.TestCase): + def test_retry_is_not_offered_for_unregistered_remote_and_never_crashes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + + def activate(result: SelectorResult) -> None: + raise RuntimeError("ssh: connect to host orangepi port 22: No route to host") + + # The registry is empty: this window's target was removed via + # "移除远端记录" while its tab stayed open. + window = CatalogWindow( + "@7", + "remote:orangepi:default", + "R orangepi/default", + True, + False, + kind="remote", + target="orangepi", + session="default", + default_label="R orangepi/default", + ) + selector = SessionSelector( + [window], + RemoteRegistry(Path(directory) / "remotes.json"), + "/missing/ssh", + activate_handler=activate, + ) + self.addCleanup(selector.pool.close) + row = next( + item + for item in selector._rows() # type: ignore[attr-defined] + if item.key == "window:@7" + ) + self.assertIsNone(selector._activate(row)) # type: ignore[attr-defined] + self.assertEqual(selector.view, "actions") + kinds = [item.kind for item in selector._rows()] # type: ignore[attr-defined] + self.assertIn("action-window", kinds) + self.assertNotIn("action-retry", kinds) + + # A stale retry row must degrade to a message instead of a KeyError. + stale = PickerRow("retry:orangepi", "retry", "! orangepi", target="orangepi") + self.assertIsNone(selector._activate(stale)) # type: ignore[attr-defined] + self.assertEqual(selector.view, "main") + self.assertIn("未登记", selector.message) + + if __name__ == "__main__": unittest.main()