pycascadeui 3.3.0__tar.gz → 3.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pycascadeui-3.3.0/pycascadeui.egg-info → pycascadeui-3.3.2}/PKG-INFO +2 -1
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/README.md +1 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/__init__.py +1 -1
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/components/selects.py +14 -4
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/_interaction.py +36 -5
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/_navigation.py +23 -4
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/base.py +91 -8
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/layout.py +15 -3
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/persistent.py +9 -3
- {pycascadeui-3.3.0 → pycascadeui-3.3.2/pycascadeui.egg-info}/PKG-INFO +2 -1
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/pyproject.toml +1 -1
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_auto_defer.py +91 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_layout_view.py +155 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_selects.py +37 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_view_init.py +42 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/LICENSE +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/__main__.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/components/__init__.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/components/base.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/components/buttons.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/components/inputs.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/components/patterns/__init__.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/components/patterns/v1.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/components/patterns/v2.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/components/types.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/components/v1_composition.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/components/wrappers.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/devtools.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/exceptions.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/persistence/__init__.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/persistence/backends/__init__.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/persistence/backends/memory.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/persistence/backends/postgres.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/persistence/backends/sqlite.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/persistence/config.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/persistence/manager.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/persistence/migrations.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/persistence/protocols.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/persistence/schema.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/persistence/schema_postgres.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/py.typed +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/setup.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/state/__init__.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/state/actions.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/state/computed.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/state/middleware/__init__.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/state/middleware/logging.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/state/middleware/persistence.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/state/middleware/undo.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/state/reducers.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/state/singleton.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/state/slots.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/state/store.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/state/types.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/theming/__init__.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/theming/context.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/theming/core.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/theming/themes.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/tracing.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/utils/__init__.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/utils/coercion.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/utils/decorators.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/utils/errors.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/utils/fetch.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/utils/logging.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/utils/strings.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/utils/tasks.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/validation.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/__init__.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/_placement.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/patterns/__init__.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/patterns/form.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/patterns/leaderboard.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/patterns/menu.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/patterns/paginated.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/patterns/roles.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/patterns/tabs.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/patterns/types.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/patterns/wizard.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/cascadeui/views/view.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/pycascadeui.egg-info/SOURCES.txt +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/pycascadeui.egg-info/dependency_links.txt +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/pycascadeui.egg-info/requires.txt +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/pycascadeui.egg-info/top_level.txt +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/setup.cfg +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_backends.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_backends_postgres.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_backends_raw_sql.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_batching.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_button_grid.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_components.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_computed.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_devtools.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_dynamic_persistent_button.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_emoji_grid.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_ephemeral_refresh.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_fetch.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_five_pillars.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_form_patterns.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_hooks.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_input_coercion.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_inputs.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_instance_limit.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_layout_pagination.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_layout_patterns.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_layout_persistent.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_leaderboard_patterns.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_menu_patterns.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_message_cleanup.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_middleware.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_nav_version.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_navigation.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_navigation_artifact_restore.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_owner_only.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_paginated_patterns.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_pagination.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_persistence.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_persistence_middleware.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_persistent_views.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_pillar_isolation.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_placement.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_rebuild_callback.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_reducers.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_roles_patterns.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_scoping.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_state_slots.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_state_store.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_tab_patterns.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_theming.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_undo.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_update_coalescing.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_v2_helpers.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_validation.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_wizard_patterns.py +0 -0
- {pycascadeui-3.3.0 → pycascadeui-3.3.2}/tests/test_wrappers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycascadeui
|
|
3
|
-
Version: 3.3.
|
|
3
|
+
Version: 3.3.2
|
|
4
4
|
Summary: Redux-inspired UI framework for discord.py
|
|
5
5
|
Author-email: HollowTheSilver <hollow@users.noreply.github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -665,6 +665,7 @@ for row in rows:
|
|
|
665
665
|
- Auto-defer with `respond()`, `open_modal()`, and `_safe_defer()` helpers
|
|
666
666
|
- Interaction serialization so rapid clicks process sequentially
|
|
667
667
|
- Refresh throttling via `refresh_cooldown_ms` and reactive 429 backoff
|
|
668
|
+
- `edit_timeout` ceiling that cancels stalled Discord edits before they pin a view
|
|
668
669
|
- Silent snowflake coercion at every public boundary
|
|
669
670
|
- Class-attribute validation at subclass-definition time
|
|
670
671
|
|
|
@@ -622,6 +622,7 @@ for row in rows:
|
|
|
622
622
|
- Auto-defer with `respond()`, `open_modal()`, and `_safe_defer()` helpers
|
|
623
623
|
- Interaction serialization so rapid clicks process sequentially
|
|
624
624
|
- Refresh throttling via `refresh_cooldown_ms` and reactive 429 backoff
|
|
625
|
+
- `edit_timeout` ceiling that cancels stalled Discord edits before they pin a view
|
|
625
626
|
- Silent snowflake coercion at every public boundary
|
|
626
627
|
- Class-attribute validation at subclass-definition time
|
|
627
628
|
|
|
@@ -37,7 +37,12 @@ def _wrap_default_values(
|
|
|
37
37
|
out.append(value)
|
|
38
38
|
continue
|
|
39
39
|
snowflake_id = coerce_snowflake_id(value)
|
|
40
|
-
|
|
40
|
+
# SelectDefaultValue stores type verbatim; a bare string breaks to_dict().
|
|
41
|
+
out.append(
|
|
42
|
+
discord.SelectDefaultValue(
|
|
43
|
+
id=snowflake_id, type=discord.SelectDefaultValueType[default_type]
|
|
44
|
+
)
|
|
45
|
+
)
|
|
41
46
|
return out
|
|
42
47
|
|
|
43
48
|
|
|
@@ -63,15 +68,20 @@ def _wrap_mentionable_defaults(
|
|
|
63
68
|
if isinstance(value, discord.SelectDefaultValue):
|
|
64
69
|
out.append(value)
|
|
65
70
|
elif isinstance(value, discord.Role):
|
|
66
|
-
out.append(
|
|
71
|
+
out.append(
|
|
72
|
+
discord.SelectDefaultValue(id=value.id, type=discord.SelectDefaultValueType.role)
|
|
73
|
+
)
|
|
67
74
|
elif isinstance(value, (discord.Member, discord.User)):
|
|
68
|
-
out.append(
|
|
75
|
+
out.append(
|
|
76
|
+
discord.SelectDefaultValue(id=value.id, type=discord.SelectDefaultValueType.user)
|
|
77
|
+
)
|
|
69
78
|
else:
|
|
70
79
|
raise TypeError(
|
|
71
80
|
f"MentionableSelect default values must be Member, User, Role, "
|
|
72
81
|
f"or SelectDefaultValue (got {type(value).__name__}: {value!r}). "
|
|
73
82
|
f"Raw int IDs cannot be auto-typed; construct "
|
|
74
|
-
f"discord.SelectDefaultValue(id=...,
|
|
83
|
+
f"discord.SelectDefaultValue(id=..., "
|
|
84
|
+
f"type=discord.SelectDefaultValueType.user or .role) "
|
|
75
85
|
f"explicitly for those."
|
|
76
86
|
)
|
|
77
87
|
return out
|
|
@@ -88,6 +88,22 @@ class _InteractionMixin:
|
|
|
88
88
|
if self.auto_defer and not interaction.response.is_done():
|
|
89
89
|
try:
|
|
90
90
|
await interaction.response.defer()
|
|
91
|
+
except discord.HTTPException as e:
|
|
92
|
+
# 40060 means Discord acknowledged a request the
|
|
93
|
+
# acting-view fast path cancelled locally (cancellation
|
|
94
|
+
# race) -- the interaction is already acked, so this is
|
|
95
|
+
# benign and routine. Any other status is a genuine ack
|
|
96
|
+
# failure the user saw as an interaction-failed toast.
|
|
97
|
+
if e.code == 40060:
|
|
98
|
+
logger.debug(
|
|
99
|
+
f"Post-callback defer raced an existing ack in "
|
|
100
|
+
f"{self.__class__.__name__} (40060)"
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
logger.warning(
|
|
104
|
+
f"Post-callback defer failed in {self.__class__.__name__}: "
|
|
105
|
+
f"status={e.status} code={e.code}"
|
|
106
|
+
)
|
|
91
107
|
except Exception:
|
|
92
108
|
logger.debug(
|
|
93
109
|
f"Post-callback defer failed in {self.__class__.__name__} "
|
|
@@ -189,9 +205,24 @@ class _InteractionMixin:
|
|
|
189
205
|
send operations. Prevents double-defer when auto-defer or
|
|
190
206
|
``serialize_interactions`` has already acknowledged the
|
|
191
207
|
interaction before the callback runs.
|
|
208
|
+
|
|
209
|
+
The ack is bounded by ``auto_defer_delay``: this call runs inside
|
|
210
|
+
the interaction lock, and a Discord ack endpoint that stalls past
|
|
211
|
+
the ack window would pin the lock on a hung socket. A defer that
|
|
212
|
+
cannot land in time is useless anyway -- the auto-defer timer,
|
|
213
|
+
running outside the lock, is the backstop -- so the stall is
|
|
214
|
+
cancelled and swallowed rather than propagated.
|
|
192
215
|
"""
|
|
193
216
|
if not interaction.response.is_done():
|
|
194
|
-
|
|
217
|
+
try:
|
|
218
|
+
await asyncio.wait_for(
|
|
219
|
+
interaction.response.defer(), timeout=max(0.5, self.auto_defer_delay)
|
|
220
|
+
)
|
|
221
|
+
except asyncio.TimeoutError:
|
|
222
|
+
logger.debug(
|
|
223
|
+
f"Ack defer stalled past {self.auto_defer_delay}s in "
|
|
224
|
+
f"{type(self).__name__}; auto-defer timer backstops the ack."
|
|
225
|
+
)
|
|
195
226
|
|
|
196
227
|
# // ==================( Ephemeral Refresh )================== // #
|
|
197
228
|
|
|
@@ -341,11 +372,11 @@ class _InteractionMixin:
|
|
|
341
372
|
# panel becomes a harmless orphan the user can dismiss.
|
|
342
373
|
if self._message:
|
|
343
374
|
try:
|
|
344
|
-
await self._message.delete()
|
|
345
|
-
except (discord.NotFound, discord.HTTPException):
|
|
375
|
+
await self._bounded(self._message.delete())
|
|
376
|
+
except (discord.NotFound, discord.HTTPException, asyncio.TimeoutError):
|
|
346
377
|
try:
|
|
347
|
-
await self._message.edit(view=self)
|
|
348
|
-
except (discord.NotFound, discord.HTTPException):
|
|
378
|
+
await self._bounded(self._message.edit(view=self))
|
|
379
|
+
except (discord.NotFound, discord.HTTPException, asyncio.TimeoutError):
|
|
349
380
|
pass
|
|
350
381
|
|
|
351
382
|
await self.exit()
|
|
@@ -380,13 +380,24 @@ class _NavigationMixin:
|
|
|
380
380
|
new_view._check_placement()
|
|
381
381
|
|
|
382
382
|
try:
|
|
383
|
-
msg = await
|
|
383
|
+
msg = await self._bounded(
|
|
384
|
+
current_interaction.edit_original_response(view=new_view, **edit_kwargs)
|
|
385
|
+
)
|
|
384
386
|
# Preserve the parent's plain Message ref. The edit response
|
|
385
387
|
# is an InteractionMessage / WebhookMessage bound to the
|
|
386
388
|
# 15-minute interaction token; subsequent edits need the
|
|
387
389
|
# channel endpoint, which the plain ref provides.
|
|
388
390
|
if new_view._message is None:
|
|
389
391
|
new_view._message = msg
|
|
392
|
+
except asyncio.TimeoutError:
|
|
393
|
+
# Edit stalled past edit_timeout. The destination view is
|
|
394
|
+
# already registered with a fresh digest, so the next
|
|
395
|
+
# interaction re-renders it; re-attempting inline would stack
|
|
396
|
+
# a second wait on the same stalled endpoint.
|
|
397
|
+
logger.warning(
|
|
398
|
+
f"Navigation edit stalled past {self.edit_timeout}s in "
|
|
399
|
+
f"{type(self).__name__}; destination re-renders on next interaction."
|
|
400
|
+
)
|
|
390
401
|
except discord.HTTPException:
|
|
391
402
|
# Interaction token expired (15-min lifetime). Route the
|
|
392
403
|
# channel-endpoint fallback through refresh() so it picks
|
|
@@ -405,9 +416,17 @@ class _NavigationMixin:
|
|
|
405
416
|
# Stack was empty -- pop() already stopped/unsubscribed this view,
|
|
406
417
|
# so remove the dead components from the message to avoid a broken UI
|
|
407
418
|
try:
|
|
408
|
-
await interaction.edit_original_response(view=None)
|
|
409
|
-
except
|
|
410
|
-
|
|
419
|
+
await self._bounded(interaction.edit_original_response(view=None))
|
|
420
|
+
except asyncio.TimeoutError:
|
|
421
|
+
logger.debug(
|
|
422
|
+
f"Back-navigation clear stalled past {self.edit_timeout}s "
|
|
423
|
+
f"in {type(self).__name__}."
|
|
424
|
+
)
|
|
425
|
+
except discord.HTTPException as e:
|
|
426
|
+
logger.debug(
|
|
427
|
+
f"Back-navigation clear failed in {type(self).__name__}: "
|
|
428
|
+
f"status={e.status} code={e.code}"
|
|
429
|
+
)
|
|
411
430
|
# When prev_view is non-None, pop() routed through _apply_navigation_edit
|
|
412
431
|
# which already swapped the message to the restored view.
|
|
413
432
|
|
|
@@ -195,6 +195,20 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
195
195
|
# Discord returns an escalated rate-limit.
|
|
196
196
|
refresh_cooldown_ms: Optional[int] = None
|
|
197
197
|
|
|
198
|
+
# Subclass config: edit timeout ceiling
|
|
199
|
+
# discord.py issues HTTP edits with no total timeout (aiohttp defaults to
|
|
200
|
+
# total=None), so a connection that stalls without a response would pin
|
|
201
|
+
# the awaiting code -- and, on the interaction-locked refresh/navigation
|
|
202
|
+
# paths, the view itself -- until the socket drops. This ceiling bounds
|
|
203
|
+
# every live-view and teardown edit through ``_bounded``: a stall is
|
|
204
|
+
# cancelled after this many seconds and the view recovers on the next
|
|
205
|
+
# interaction. The default clears realistic attachment uploads while
|
|
206
|
+
# still capping a true hang. ``None`` disables the ceiling (unbounded
|
|
207
|
+
# awaits, matching discord.py's own default). The acting-view fast path
|
|
208
|
+
# keeps its own tighter bound, which protects the 3s ack deadline rather
|
|
209
|
+
# than guarding against a hang.
|
|
210
|
+
edit_timeout: Optional[float] = 60.0
|
|
211
|
+
|
|
198
212
|
# Subclass config: interaction serialization
|
|
199
213
|
# When True, rapid button clicks are processed one at a time to prevent
|
|
200
214
|
# racing message edits that cause "This interaction failed" errors.
|
|
@@ -283,6 +297,8 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
283
297
|
)
|
|
284
298
|
# Attributes that must be a positive float/int.
|
|
285
299
|
_POSITIVE_NUMBER_ATTRS: ClassVar[tuple] = ("auto_defer_delay",)
|
|
300
|
+
# Attributes that must be a positive float/int or None (None = disabled).
|
|
301
|
+
_OPTIONAL_POSITIVE_NUMBER_ATTRS: ClassVar[tuple] = ("edit_timeout",)
|
|
286
302
|
# Attributes that must be a bool.
|
|
287
303
|
_BOOL_ATTRS: ClassVar[tuple] = (
|
|
288
304
|
"owner_only",
|
|
@@ -338,6 +354,14 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
338
354
|
if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0:
|
|
339
355
|
raise ValueError(f"{cls.__name__}.{name} must be a positive number, got {value!r}")
|
|
340
356
|
return
|
|
357
|
+
if name in cls._OPTIONAL_POSITIVE_NUMBER_ATTRS:
|
|
358
|
+
if value is None:
|
|
359
|
+
return
|
|
360
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0:
|
|
361
|
+
raise ValueError(
|
|
362
|
+
f"{cls.__name__}.{name} must be a positive number or None, got {value!r}"
|
|
363
|
+
)
|
|
364
|
+
return
|
|
341
365
|
if name in cls._BOOL_ATTRS:
|
|
342
366
|
if not isinstance(value, bool):
|
|
343
367
|
raise ValueError(
|
|
@@ -426,6 +450,9 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
426
450
|
for attr in cls._POSITIVE_NUMBER_ATTRS:
|
|
427
451
|
if attr in own:
|
|
428
452
|
cls._validate_attribute_value(attr, own[attr])
|
|
453
|
+
for attr in cls._OPTIONAL_POSITIVE_NUMBER_ATTRS:
|
|
454
|
+
if attr in own:
|
|
455
|
+
cls._validate_attribute_value(attr, own[attr])
|
|
429
456
|
for attr in cls._BOOL_ATTRS:
|
|
430
457
|
if attr in own:
|
|
431
458
|
cls._validate_attribute_value(attr, own[attr])
|
|
@@ -1465,9 +1492,14 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
1465
1492
|
|
|
1466
1493
|
if self._message:
|
|
1467
1494
|
try:
|
|
1468
|
-
await self._message.edit(view=self)
|
|
1495
|
+
await self._bounded(self._message.edit(view=self))
|
|
1469
1496
|
except discord.NotFound:
|
|
1470
1497
|
pass # Message was already deleted
|
|
1498
|
+
except asyncio.TimeoutError:
|
|
1499
|
+
logger.warning(
|
|
1500
|
+
f"Timed out disabling components on timeout for "
|
|
1501
|
+
f"{type(self).__name__}; continuing teardown."
|
|
1502
|
+
)
|
|
1471
1503
|
except Exception as e:
|
|
1472
1504
|
hint = ""
|
|
1473
1505
|
if self._ephemeral:
|
|
@@ -1664,6 +1696,9 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
1664
1696
|
# value. Disqualified cases (modal interactions, cross-view
|
|
1665
1697
|
# message mismatch, already-deferred response, missing message
|
|
1666
1698
|
# ref) fall through to the existing webhook/channel paths.
|
|
1699
|
+
# Ephemeral acting views take the ack-first branch instead: a
|
|
1700
|
+
# webhook-only edit runs too slowly to double as the ack without
|
|
1701
|
+
# risking the 3s deadline.
|
|
1667
1702
|
#
|
|
1668
1703
|
# The fast path couples ack to edit in one HTTP call. A slow
|
|
1669
1704
|
# edit response from Discord (latency spike, ephemeral backend
|
|
@@ -1677,13 +1712,14 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
1677
1712
|
# done" and the fall-through paths behave as if the fast path
|
|
1678
1713
|
# was never attempted.
|
|
1679
1714
|
interaction = _CURRENT_INTERACTION.get()
|
|
1680
|
-
|
|
1715
|
+
acting = (
|
|
1681
1716
|
interaction is not None
|
|
1682
1717
|
and interaction.type == discord.InteractionType.component
|
|
1683
1718
|
and interaction.message is not None
|
|
1684
1719
|
and interaction.message.id == self._message.id
|
|
1685
1720
|
and not interaction.response.is_done()
|
|
1686
|
-
)
|
|
1721
|
+
)
|
|
1722
|
+
if acting and not self._ephemeral:
|
|
1687
1723
|
fast_path_timeout = max(0.5, self.auto_defer_delay - 1.0)
|
|
1688
1724
|
try:
|
|
1689
1725
|
await asyncio.wait_for(
|
|
@@ -1723,6 +1759,18 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
1723
1759
|
# Any other HTTP error falls through to the channel path
|
|
1724
1760
|
# so a transient failure on the interaction endpoint does
|
|
1725
1761
|
# not lose the edit entirely.
|
|
1762
|
+
elif acting and self._ephemeral:
|
|
1763
|
+
# Ephemeral messages edit only through the interaction webhook,
|
|
1764
|
+
# which runs slower than a channel PATCH. The edit-as-ack fast
|
|
1765
|
+
# path couples the ack to that edit, so a slow webhook response
|
|
1766
|
+
# starves the 3s ack deadline: the panel misses its ack (the
|
|
1767
|
+
# interaction-failed toast) and loses the edit (a frozen body).
|
|
1768
|
+
# Acknowledge first with a deferred update, then fall through to
|
|
1769
|
+
# the webhook edit below. self._message is the
|
|
1770
|
+
# InteractionMessage/WebhookMessage, whose .edit() routes through
|
|
1771
|
+
# the webhook and stays valid for the token's 15-minute life with
|
|
1772
|
+
# no 3s deadline on the edit itself.
|
|
1773
|
+
await self._safe_defer(interaction)
|
|
1726
1774
|
|
|
1727
1775
|
# Interaction-response messages ignore embed edits via the channel
|
|
1728
1776
|
# endpoint (PATCH /channels/{id}/messages/{id}). When the caller
|
|
@@ -1731,12 +1779,19 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
1731
1779
|
# update embeds. Falls back to the plain Message on token expiry.
|
|
1732
1780
|
if ("embed" in kwargs or "embeds" in kwargs) and self._webhook_message:
|
|
1733
1781
|
try:
|
|
1734
|
-
await self._webhook_message.edit(view=self, **kwargs)
|
|
1782
|
+
await self._bounded(self._webhook_message.edit(view=self, **kwargs))
|
|
1735
1783
|
self._last_tree_digest = self._compute_tree_digest()
|
|
1736
1784
|
if perf_on:
|
|
1737
1785
|
store._record_edit()
|
|
1738
1786
|
self._stamp_cooldown()
|
|
1739
1787
|
return
|
|
1788
|
+
except asyncio.TimeoutError:
|
|
1789
|
+
logger.warning(
|
|
1790
|
+
f"Webhook edit stalled past {self.edit_timeout}s in "
|
|
1791
|
+
f"{type(self).__name__}; the next refresh re-ships."
|
|
1792
|
+
)
|
|
1793
|
+
self._last_tree_digest = None
|
|
1794
|
+
return
|
|
1740
1795
|
except discord.HTTPException as e:
|
|
1741
1796
|
if self._handle_rate_limit(e):
|
|
1742
1797
|
return
|
|
@@ -1744,13 +1799,19 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
1744
1799
|
self._webhook_message = None
|
|
1745
1800
|
|
|
1746
1801
|
try:
|
|
1747
|
-
await self._message.edit(view=self, **kwargs)
|
|
1802
|
+
await self._bounded(self._message.edit(view=self, **kwargs))
|
|
1748
1803
|
self._last_tree_digest = self._compute_tree_digest()
|
|
1749
1804
|
if perf_on:
|
|
1750
1805
|
store._record_edit()
|
|
1751
1806
|
self._stamp_cooldown()
|
|
1752
1807
|
except discord.NotFound:
|
|
1753
1808
|
pass
|
|
1809
|
+
except asyncio.TimeoutError:
|
|
1810
|
+
logger.warning(
|
|
1811
|
+
f"Edit stalled past {self.edit_timeout}s in {type(self).__name__}; "
|
|
1812
|
+
f"the next refresh re-ships."
|
|
1813
|
+
)
|
|
1814
|
+
self._last_tree_digest = None
|
|
1754
1815
|
except discord.HTTPException as e:
|
|
1755
1816
|
if not self._handle_rate_limit(e):
|
|
1756
1817
|
raise
|
|
@@ -1780,6 +1841,20 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
1780
1841
|
self._refresh_not_before = time.monotonic() + retry
|
|
1781
1842
|
return True
|
|
1782
1843
|
|
|
1844
|
+
async def _bounded(self, coro):
|
|
1845
|
+
"""Await a Discord HTTP coroutine under the ``edit_timeout`` ceiling.
|
|
1846
|
+
|
|
1847
|
+
``asyncio.wait_for`` cancels the request when it stalls past
|
|
1848
|
+
``edit_timeout`` seconds; aiohttp closes the connection on
|
|
1849
|
+
cancellation, so a hung socket cannot pin the awaiting code.
|
|
1850
|
+
``edit_timeout = None`` awaits the coroutine directly with no
|
|
1851
|
+
ceiling. Raises ``asyncio.TimeoutError`` on stall so the caller
|
|
1852
|
+
can release the interaction lock and recover on the next edit.
|
|
1853
|
+
"""
|
|
1854
|
+
if self.edit_timeout is None:
|
|
1855
|
+
return await coro
|
|
1856
|
+
return await asyncio.wait_for(coro, self.edit_timeout)
|
|
1857
|
+
|
|
1783
1858
|
def _check_placement(self) -> None:
|
|
1784
1859
|
"""Run the V2 placement validator on this view if enabled.
|
|
1785
1860
|
|
|
@@ -2413,19 +2488,27 @@ class _StatefulMixin(_InteractionMixin, _NavigationMixin):
|
|
|
2413
2488
|
if self._message:
|
|
2414
2489
|
try:
|
|
2415
2490
|
if delete_message:
|
|
2416
|
-
await self._message.delete()
|
|
2491
|
+
await self._bounded(self._message.delete())
|
|
2417
2492
|
elif self._is_layout():
|
|
2418
2493
|
# V2 messages ARE their components -- edit(view=None) would
|
|
2419
2494
|
# produce an empty message (error 50006). Freeze instead.
|
|
2420
2495
|
self._freeze_components()
|
|
2421
|
-
await self._message.edit(view=self)
|
|
2496
|
+
await self._bounded(self._message.edit(view=self))
|
|
2422
2497
|
else:
|
|
2423
|
-
await self._message.edit(view=None)
|
|
2498
|
+
await self._bounded(self._message.edit(view=None))
|
|
2424
2499
|
except discord.NotFound:
|
|
2425
2500
|
# Expected lifecycle: user dismissed the ephemeral, an
|
|
2426
2501
|
# admin deleted the message, or the channel was deleted.
|
|
2427
2502
|
# Nothing left to clean up on Discord's side.
|
|
2428
2503
|
pass
|
|
2504
|
+
except asyncio.TimeoutError:
|
|
2505
|
+
# Visual cleanup stalled past edit_timeout. State teardown
|
|
2506
|
+
# already ran above, so the view is gone regardless of the
|
|
2507
|
+
# stale message on screen.
|
|
2508
|
+
logger.warning(
|
|
2509
|
+
f"Exit cleanup edit stalled past {self.edit_timeout}s for "
|
|
2510
|
+
f"{type(self).__name__}; view torn down regardless."
|
|
2511
|
+
)
|
|
2429
2512
|
except discord.HTTPException as e:
|
|
2430
2513
|
if self._ephemeral and getattr(e, "status", None) == 401:
|
|
2431
2514
|
# Expected lifecycle for ephemerals past the 15-minute
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# // ========================================( Modules )======================================== // #
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
4
6
|
from typing import Any, Dict, Optional, Sequence
|
|
5
7
|
|
|
6
8
|
import discord
|
|
@@ -9,6 +11,8 @@ from discord.ui import ActionRow, Item, LayoutView
|
|
|
9
11
|
from ..components.base import StatefulButton
|
|
10
12
|
from .base import _StatefulMixin
|
|
11
13
|
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
12
16
|
# // ========================================( Classes )======================================== // #
|
|
13
17
|
|
|
14
18
|
|
|
@@ -98,9 +102,17 @@ class StatefulLayoutView(_StatefulMixin, LayoutView):
|
|
|
98
102
|
# an empty message. Freeze instead.
|
|
99
103
|
try:
|
|
100
104
|
self._freeze_components()
|
|
101
|
-
await interaction.edit_original_response(view=self)
|
|
102
|
-
except
|
|
103
|
-
|
|
105
|
+
await self._bounded(interaction.edit_original_response(view=self))
|
|
106
|
+
except asyncio.TimeoutError:
|
|
107
|
+
logger.debug(
|
|
108
|
+
f"Back-navigation freeze stalled past {self.edit_timeout}s "
|
|
109
|
+
f"in {type(self).__name__}."
|
|
110
|
+
)
|
|
111
|
+
except discord.HTTPException as e:
|
|
112
|
+
logger.debug(
|
|
113
|
+
f"Back-navigation freeze failed in {type(self).__name__}: "
|
|
114
|
+
f"status={e.status} code={e.code}"
|
|
115
|
+
)
|
|
104
116
|
# When prev_view is non-None, pop() routed through _apply_navigation_edit
|
|
105
117
|
# which already swapped the message to the restored view.
|
|
106
118
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# // ========================================( Modules )======================================== // #
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
import asyncio
|
|
4
5
|
import logging
|
|
5
6
|
import re
|
|
6
7
|
from typing import Dict
|
|
@@ -107,7 +108,7 @@ class _PersistentMixin:
|
|
|
107
108
|
V2 calls ``delete()`` because V2 messages ARE their components --
|
|
108
109
|
``edit(view=None)`` would produce an empty message (Discord error 50006).
|
|
109
110
|
"""
|
|
110
|
-
await old_message.edit(view=None)
|
|
111
|
+
await self._bounded(old_message.edit(view=None))
|
|
111
112
|
|
|
112
113
|
async def _register_persistent(self, message) -> None:
|
|
113
114
|
"""Perform duplicate-key cleanup and dispatch PERSISTENT_VIEW_REGISTERED.
|
|
@@ -154,7 +155,12 @@ class _PersistentMixin:
|
|
|
154
155
|
f"Cleaned up previous message {old_msg_id} for "
|
|
155
156
|
f"persistence_key '{self.persistence_key}'"
|
|
156
157
|
)
|
|
157
|
-
except (
|
|
158
|
+
except (
|
|
159
|
+
discord.NotFound,
|
|
160
|
+
discord.Forbidden,
|
|
161
|
+
discord.HTTPException,
|
|
162
|
+
asyncio.TimeoutError,
|
|
163
|
+
):
|
|
158
164
|
pass # Old message already gone, nothing to clean up
|
|
159
165
|
except Exception as e:
|
|
160
166
|
logger.debug(
|
|
@@ -296,4 +302,4 @@ class PersistentLayoutView(_PersistentMixin, StatefulLayoutView):
|
|
|
296
302
|
|
|
297
303
|
async def _cleanup_orphan_message(self, old_message):
|
|
298
304
|
"""V2 messages ARE their components -- delete instead of edit."""
|
|
299
|
-
await old_message.delete()
|
|
305
|
+
await self._bounded(old_message.delete())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycascadeui
|
|
3
|
-
Version: 3.3.
|
|
3
|
+
Version: 3.3.2
|
|
4
4
|
Summary: Redux-inspired UI framework for discord.py
|
|
5
5
|
Author-email: HollowTheSilver <hollow@users.noreply.github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -665,6 +665,7 @@ for row in rows:
|
|
|
665
665
|
- Auto-defer with `respond()`, `open_modal()`, and `_safe_defer()` helpers
|
|
666
666
|
- Interaction serialization so rapid clicks process sequentially
|
|
667
667
|
- Refresh throttling via `refresh_cooldown_ms` and reactive 429 backoff
|
|
668
|
+
- `edit_timeout` ceiling that cancels stalled Discord edits before they pin a view
|
|
668
669
|
- Silent snowflake coercion at every public boundary
|
|
669
670
|
- Class-attribute validation at subclass-definition time
|
|
670
671
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"""Tests for auto-defer safety net on StatefulView."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
4
6
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
5
7
|
|
|
8
|
+
import discord
|
|
6
9
|
import pytest
|
|
7
10
|
from helpers import make_interaction as _make_interaction
|
|
8
11
|
|
|
@@ -19,6 +22,23 @@ def _make_item(callback):
|
|
|
19
22
|
return item
|
|
20
23
|
|
|
21
24
|
|
|
25
|
+
def _http_error(status, code):
|
|
26
|
+
"""Build a ``discord.HTTPException`` carrying a specific status/code.
|
|
27
|
+
|
|
28
|
+
The post-callback defer classifies errors by ``code`` (40060 is the
|
|
29
|
+
benign already-acknowledged race), so tests need to control it directly.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
class _Err(discord.HTTPException):
|
|
33
|
+
def __init__(self):
|
|
34
|
+
Exception.__init__(self, str(status))
|
|
35
|
+
self.status = status
|
|
36
|
+
self.code = code
|
|
37
|
+
self.retry_after = 0
|
|
38
|
+
|
|
39
|
+
return _Err()
|
|
40
|
+
|
|
41
|
+
|
|
22
42
|
# // ========================================( Timer Fires )======================================== // #
|
|
23
43
|
|
|
24
44
|
|
|
@@ -149,6 +169,55 @@ class TestPostCallbackDefer:
|
|
|
149
169
|
|
|
150
170
|
interaction.response.defer.assert_not_called()
|
|
151
171
|
|
|
172
|
+
async def test_post_defer_40060_race_logged_at_debug(self, caplog):
|
|
173
|
+
"""A 40060 (already acknowledged) on the post-callback defer is the
|
|
174
|
+
benign cancellation race the acting-view fast path can produce.
|
|
175
|
+
Logged at debug, never warning, so a successful fast-path edit does
|
|
176
|
+
not spam warnings on every interaction.
|
|
177
|
+
"""
|
|
178
|
+
view = StatefulView(interaction=_make_interaction())
|
|
179
|
+
view.auto_defer_delay = 10
|
|
180
|
+
view.owner_only = False
|
|
181
|
+
|
|
182
|
+
interaction = _make_interaction(is_done=False)
|
|
183
|
+
interaction.response.defer = AsyncMock(side_effect=_http_error(400, 40060))
|
|
184
|
+
|
|
185
|
+
async def silent_callback(inter):
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
item = _make_item(silent_callback)
|
|
189
|
+
with caplog.at_level(logging.DEBUG, logger="cascadeui.views._interaction"):
|
|
190
|
+
await view._scheduled_task(item, interaction)
|
|
191
|
+
|
|
192
|
+
assert any(
|
|
193
|
+
rec.levelno == logging.DEBUG and "40060" in rec.getMessage() for rec in caplog.records
|
|
194
|
+
)
|
|
195
|
+
assert not any(rec.levelno >= logging.WARNING for rec in caplog.records)
|
|
196
|
+
|
|
197
|
+
async def test_post_defer_genuine_failure_logged_at_warning(self, caplog):
|
|
198
|
+
"""A non-40060 HTTP failure is a real ack failure -- the user saw an
|
|
199
|
+
interaction-failed toast -- so it surfaces at warning with the
|
|
200
|
+
status and code instead of vanishing at debug.
|
|
201
|
+
"""
|
|
202
|
+
view = StatefulView(interaction=_make_interaction())
|
|
203
|
+
view.auto_defer_delay = 10
|
|
204
|
+
view.owner_only = False
|
|
205
|
+
|
|
206
|
+
interaction = _make_interaction(is_done=False)
|
|
207
|
+
interaction.response.defer = AsyncMock(side_effect=_http_error(404, 10062))
|
|
208
|
+
|
|
209
|
+
async def silent_callback(inter):
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
item = _make_item(silent_callback)
|
|
213
|
+
with caplog.at_level(logging.WARNING, logger="cascadeui.views._interaction"):
|
|
214
|
+
await view._scheduled_task(item, interaction)
|
|
215
|
+
|
|
216
|
+
assert any(
|
|
217
|
+
rec.levelno == logging.WARNING and "status=404" in rec.getMessage()
|
|
218
|
+
for rec in caplog.records
|
|
219
|
+
)
|
|
220
|
+
|
|
152
221
|
|
|
153
222
|
# // ========================================( Timer Skipped )======================================== // #
|
|
154
223
|
|
|
@@ -232,6 +301,28 @@ class TestAutoDeferErrorHandling:
|
|
|
232
301
|
# Timer should not crash
|
|
233
302
|
await view._auto_defer_timer(interaction)
|
|
234
303
|
|
|
304
|
+
async def test_safe_defer_bounds_a_stalled_ack(self):
|
|
305
|
+
"""``_safe_defer`` cancels a stalled defer at ``auto_defer_delay`` and
|
|
306
|
+
swallows the timeout, so a hung Discord ack endpoint cannot pin the
|
|
307
|
+
interaction lock on the socket lifetime.
|
|
308
|
+
"""
|
|
309
|
+
view = StatefulView(interaction=_make_interaction())
|
|
310
|
+
view.auto_defer_delay = 0.05
|
|
311
|
+
view.owner_only = False
|
|
312
|
+
|
|
313
|
+
interaction = _make_interaction(is_done=False)
|
|
314
|
+
|
|
315
|
+
async def stall(*args, **kwargs):
|
|
316
|
+
await asyncio.sleep(60)
|
|
317
|
+
|
|
318
|
+
interaction.response.defer = AsyncMock(side_effect=stall)
|
|
319
|
+
|
|
320
|
+
before = time.monotonic()
|
|
321
|
+
await view._safe_defer(interaction) # must return, not hang
|
|
322
|
+
elapsed = time.monotonic() - before
|
|
323
|
+
|
|
324
|
+
assert elapsed < 2.0 # bounded by auto_defer_delay, not the 60s stall
|
|
325
|
+
|
|
235
326
|
async def test_callback_error_still_triggers_on_error(self):
|
|
236
327
|
"""When the callback raises, on_error is called even with auto-defer active."""
|
|
237
328
|
view = StatefulView(interaction=_make_interaction())
|