Compare commits

..

11 Commits

Author SHA1 Message Date
Will Miao
c1bf9c6221 test(aria2): verify _wait_until_ready captures stderr on subprocess early exit
Regression test for the pipe-race bug where _drain_stderr consumed
aria2's error output before _wait_until_ready could read it.
2026-06-26 14:41:32 +08:00
Will Miao
75fffc1e25 fix(aria2): move stderr drain after _wait_until_ready to avoid swallowing startup errors
_drain_stderr and _wait_until_ready both read from the same stderr pipe.
Starting the drain task before _wait_until_ready creates a race where the
drain task consumes aria2's early-exit error message before the startup
waiter can read it, resulting in an empty error message in the logs.

Also confirmed that --fsync does not exist as an aria2 option (exit code
28 = Invalid argument).
2026-06-26 14:32:43 +08:00
Will Miao
f264bab65c fix(aria2): remove --fsync=false to avoid crash on older aria2c versions
Exit code 28 (Invalid argument) indicates this user's aria2c does not
support the --fsync option. Remove it unconditionally; the stderr drain,
relaxed RPC timeouts, and increased retry coverage remain in place.
2026-06-26 14:24:46 +08:00
Will Miao
154fcd803b fix(aria2): disable fsync and relax RPC timeouts to prevent aria2 freeze on large files
aria2 default --fsync=true calls fsync() after each write, which blocks
the entire single-threaded process on large files under Docker overlay.
Add --fsync=false to eliminate this blocking source.

Relax aiohttp session timeout: total=30 → sock_connect=10, sock_read=60
so that transient I/O delays don't cut off legitimate tellStatus RPCs.

Increase retry params (4 attempts, 3s delay) to give aria2 more recovery
time when blocked on synchronous I/O.
2026-06-26 14:19:37 +08:00
Will Miao
4ef32d3a96 fix(ui): prevent bulk-mode highlight from being clipped on edge cards 2026-06-26 11:59:28 +08:00
Will Miao
d2d109a69c feat(ui): replace native sort select with custom dropdown sized to selected text 2026-06-26 09:53:04 +08:00
Will Miao
3a2941d751 fix(aria2): drain stderr pipe to prevent aria2 freeze, retry RPC status on transient failure
Root cause: aria2c subprocess stderr pipe (64 KB buffer) was never
drained. When enough error/warning output accumulated, aria2's write()
blocked, freezing the entire process including its RPC handler. The
tellStatus call then timed out after 30s with asyncio.TimeoutError(),
producing the empty error message in 'Failed to query aria2 download
status: '.

Fixes:
- Drain stderr in a background task so pipe never fills up
- Retry get_status() RPC calls up to 3 times on transient failure
- In the failure path, preserve .safetensors when .aria2 is absent
  (the download was likely complete on disk)
2026-06-26 08:25:05 +08:00
Will Miao
0ac10dfd42 fix(ui): prevent Launch LoRA Manager button from disappearing when opening properties panel in subgraph (#996) 2026-06-25 20:47:29 +08:00
Will Miao
9c95856b2f fix(trigger-wheel): prevent Vue render mode from intercepting strength wheel events
In Vue render mode, ComfyUI's TransformPane uses a capture-phase wheel
handler (@wheel.capture) that fires before the tag element's bubble-phase
strength-adjustment listener. It checks wheelCapturedByFocusedElement(),
which requires data-capture-wheel on a focused element. The tag divs had
data-capture-wheel but were not focusable, so the check failed, causing
the capture handler to forward the event to the canvas (triggering zoom)
and stopPropagation() which prevented the strength handler from running.

Fix: move data-capture-wheel from individual tags to the container, make
it focusable (tabIndex=-1), and add a window-level capture-phase wheel
listener that focuses the container before TransformPane checks it.
2026-06-25 14:58:20 +08:00
Will Miao
5ce4667d32 feat(node-marker): add 🎯 emoji prefix to Mark as context menu item 2026-06-24 22:36:45 +08:00
willmiao
be53fda6df docs: auto-update supporters list in README 2026-06-24 14:11:36 +00:00
13 changed files with 745 additions and 29 deletions

File diff suppressed because one or more lines are too long

View File

@@ -84,6 +84,7 @@ class Aria2Downloader:
self._transfers: Dict[str, Aria2Transfer] = {}
self._poll_interval = 0.5
self._state_store = Aria2TransferStateStore()
self._stderr_reader_task: Optional[asyncio.Task] = None
@property
def is_running(self) -> bool:
@@ -115,7 +116,7 @@ class Aria2Downloader:
try:
while True:
status = await self.get_status(download_id)
status = await self._get_status_with_retry(download_id)
if status is None:
return False, "aria2 download not found"
@@ -136,6 +137,35 @@ class Aria2Downloader:
finally:
self._transfers.pop(download_id, None)
async def _get_status_with_retry(
self, download_id: str, *, max_retries: int = 4, retry_delay: float = 3.0
) -> Optional[Dict[str, Any]]:
"""Call get_status with retry for transient RPC failures.
Only retries on :exc:`Aria2Error` (RPC-level failure). Returns
``None`` immediately when the download_id is not tracked (a missing
transfer is not a transient condition, so retrying is pointless).
A single failed RPC call should not immediately fail the download,
because aria2 may be temporarily busy (e.g. finalizing multiple
concurrent downloads) and a retry will often succeed.
"""
last_exc: Optional[Exception] = None
for attempt in range(max_retries):
try:
return await self.get_status(download_id)
except Aria2Error as exc:
last_exc = exc
if attempt < max_retries - 1:
logger.warning(
"aria2 get_status transient failure (attempt %d/%d) for %s: %s",
attempt + 1, max_retries, download_id, exc,
)
await asyncio.sleep(retry_delay)
raise Aria2Error(
f"Failed to query aria2 download status after {max_retries} attempts: {last_exc}"
) from last_exc
async def _schedule_download(
self,
url: str,
@@ -312,6 +342,16 @@ class Aria2Downloader:
async def close(self) -> None:
"""Shut down the RPC process and session."""
# Cancel the background stderr reader first so it stops reading
# from the pipe before the subprocess is terminated.
if self._stderr_reader_task is not None:
self._stderr_reader_task.cancel()
try:
await asyncio.wait_for(self._stderr_reader_task, timeout=2.0)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
self._stderr_reader_task = None
if self._rpc_session is not None:
await self._rpc_session.close()
self._rpc_session = None
@@ -331,6 +371,23 @@ class Aria2Downloader:
process.kill()
await process.wait()
async def _drain_stderr(self) -> None:
"""Continuously drain aria2's stderr pipe so it never blocks.
When the 64 KB pipe buffer fills up, aria2's ``write()`` to stderr
blocks, which freezes the entire ``aria2c`` process — including its
RPC handler. This background task reads lines from stderr as they
arrive and forwards them to Python's logger.
"""
try:
assert self._process is not None and self._process.stderr is not None
async for line in self._process.stderr:
text = line.decode("utf-8", errors="replace").rstrip()
if text:
logger.debug("aria2 stderr: %s", text)
except Exception:
pass
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
try:
result = callback(snapshot, snapshot)
@@ -465,6 +522,17 @@ class Aria2Downloader:
await self._wait_until_ready()
# Drain aria2's stderr in a background task so the pipe buffer
# never fills up. If the pipe blocks, aria2 itself freezes and
# cannot respond to RPC — this was the root cause of the
# "Failed to query aria2 download status" timeout bug.
# Must start AFTER _wait_until_ready to avoid a race where the
# drain task consumes aria2's early-exit error message before
# _wait_until_ready can read it.
self._stderr_reader_task = asyncio.create_task(
self._drain_stderr()
)
def _resolve_executable(self) -> str:
settings = get_settings_manager()
configured_path = (settings.get("aria2c_path") or "").strip()
@@ -584,7 +652,9 @@ class Aria2Downloader:
if self._rpc_session is None or self._rpc_session.closed:
async with self._rpc_session_lock:
if self._rpc_session is None or self._rpc_session.closed:
timeout = aiohttp.ClientTimeout(total=30)
timeout = aiohttp.ClientTimeout(
total=None, sock_connect=10, sock_read=60
)
self._rpc_session = aiohttp.ClientSession(timeout=timeout)
return self._rpc_session

View File

@@ -2029,7 +2029,21 @@ class DownloadManager:
break
last_error = result
if os.path.exists(save_path):
# For aria2: if the .aria2 control file is missing, aria2 considers
# the download complete. A transient RPC failure may have made us
# think the download failed even though the file is fully on disk.
# Keep the file so a retry can find it already complete.
if (
transfer_backend == "aria2"
and os.path.exists(save_path)
and not os.path.exists(f"{save_path}.aria2")
):
logger.warning(
"aria2 download reported failure but .aria2 file is absent "
"for %s — the file is likely complete. Preserving it for retry.",
save_path,
)
elif os.path.exists(save_path):
try:
os.remove(save_path)
except Exception as e:

View File

@@ -1,6 +1,7 @@
/* Style for selected cards */
.model-card.selected {
box-shadow: 0 0 0 2px var(--lora-accent);
outline: 2px solid var(--lora-accent);
outline-offset: -2px;
position: relative;
}

View File

@@ -281,6 +281,157 @@
box-shadow: none;
}
/* === Sort dropdown — decoupled trigger width ===========================
The native <select> sizes its trigger to the widest <option>, wasting
horizontal space when a short option is selected. This custom trigger
sizes to the currently selected text only; the dropdown menu sizes to
its content independently. The native <select> is kept in the DOM
(visually hidden) so existing JS that reads/writes `.value` / `.disabled`
and dynamically adds/removes <option>s keeps working. */
.sort-dropdown-group {
position: relative;
display: flex;
}
.sort-trigger {
display: flex;
align-items: center;
gap: 6px;
min-width: 100px;
max-width: 240px;
padding: 4px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background: var(--card-bg);
color: var(--text-color);
font-size: 0.85em;
cursor: pointer;
transition: var(--transition-base);
box-shadow: var(--shadow-xs);
}
.sort-trigger:hover,
.sort-trigger:focus-visible {
border-color: var(--lora-accent);
background: var(--bg-color);
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
outline: none;
}
.sort-trigger:active {
transform: translateY(0);
box-shadow: var(--shadow-xs);
}
.sort-trigger__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sort-trigger__caret {
opacity: 0.8;
transition: transform var(--transition-base);
flex-shrink: 0;
}
.sort-dropdown-group.active .sort-trigger__caret {
transform: rotate(180deg);
}
.sort-dropdown-group.active .sort-trigger {
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px color-mix(in oklch, var(--lora-accent) 15%, transparent);
}
/* Disabled state — mirrors the native :disabled look (used when VLM is active) */
.sort-dropdown-group.is-disabled .sort-trigger {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
background: var(--bg-color);
border-color: var(--border-color);
box-shadow: none;
transform: none;
}
/* Dropdown menu — sizes to its content, independent of trigger width.
Inherits base .dropdown-menu styling; capped for very long i18n text. */
.sort-dropdown-menu {
min-width: max-content;
max-width: 320px;
width: max-content;
}
/* Optgroup label rendered as a section header */
.sort-dropdown-group .sort-optgroup-label {
padding: 8px 12px 4px;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
cursor: default;
user-select: none;
}
.sort-dropdown-group .sort-optgroup-label:first-child {
padding-top: 4px;
}
/* Option items */
.sort-dropdown-group .sort-option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
color: var(--text-color);
cursor: pointer;
transition: background-color 0.2s ease;
white-space: nowrap;
}
.sort-dropdown-group .sort-option::before {
content: '';
width: 14px;
flex-shrink: 0;
text-align: center;
font-weight: 700;
}
.sort-dropdown-group .sort-option:hover {
background-color: color-mix(in oklch, var(--lora-accent) 10%, transparent);
}
.sort-dropdown-group .sort-option.is-selected {
color: var(--lora-accent);
font-weight: 600;
}
.sort-dropdown-group .sort-option.is-selected::before {
content: '\2713';
color: var(--lora-accent);
}
/* Visually hidden native <select> — kept in the DOM for programmatic access.
High-specificity selector overrides .control-group select { min-width: 100px }. */
.control-group .sort-select-native {
position: absolute;
width: 1px;
height: 1px;
min-width: 0;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
opacity: 0;
pointer-events: none;
}
/* Ensure hidden class works properly */
.hidden {
display: none !important;

View File

@@ -4,6 +4,7 @@ import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setS
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js';
import { initSortDropdown } from './SortDropdown.js';
/**
* PageControls class - Unified control management for model pages
@@ -106,6 +107,7 @@ export class PageControls {
// Sort select handler
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
initSortDropdown(sortSelect);
sortSelect.value = this.pageState.sortBy;
sortSelect.addEventListener('change', async (e) => {
this.pageState.sortBy = e.target.value;

View File

@@ -0,0 +1,290 @@
// SortDropdown.js — Decoupled sort trigger.
//
// The native <select> sizes its trigger to the widest <option>, so long
// options (e.g. "Fewest versions first") or long i18n translations force the
// control to be far wider than the selected text needs. This module wraps the
// existing <select> with a custom trigger + menu that mirror its state, so the
// trigger sizes to the selected text while the menu sizes to its content.
//
// The native <select> stays in the DOM (visually hidden) so existing code that
// reads/writes `.value` / `.disabled` and dynamically adds/removes <option>s
// (e.g. the VLM temporary option) keeps working unchanged. The `value` and
// `disabled` setters are overridden on the instance to keep the trigger label
// and disabled styling in sync with programmatic changes.
//
// Keyboard navigation (arrows, Home/End, type-to-select) mirrors native
// <select> behavior so the control remains fully accessible.
const SORT_GROUP_SELECTOR = '.sort-dropdown-group';
const ACTIVE_GROUP_SELECTOR = '.sort-dropdown-group.active, .dropdown-group.active';
/**
* Initialize a decoupled sort dropdown around a native <select>.
* Idempotent: safe to call more than once on the same element.
* @param {HTMLSelectElement|null} select
* @returns {void}
*/
export function initSortDropdown(select) {
if (!select) return;
const group = select.closest(SORT_GROUP_SELECTOR);
if (!group || group.dataset.sortReady === '1') return;
const trigger = group.querySelector('.sort-trigger');
const menu = group.querySelector('.sort-dropdown-menu');
const label = group.querySelector('.sort-trigger__label');
if (!trigger || !menu || !label) return;
const getOptions = () => menu.querySelectorAll('.sort-option');
const buildItem = (opt) => {
const item = document.createElement('div');
item.className = 'sort-option';
item.setAttribute('role', 'option');
item.tabIndex = -1;
item.dataset.value = opt.value;
item.textContent = opt.textContent;
item.addEventListener('click', (event) => {
event.stopPropagation();
if (select.disabled) return;
choose(opt.value);
close();
});
return item;
};
const buildMenu = () => {
menu.innerHTML = '';
const fragment = document.createDocumentFragment();
for (const child of Array.from(select.children)) {
if (child.tagName === 'OPTGROUP') {
const header = document.createElement('div');
header.className = 'sort-optgroup-label';
header.textContent = child.label || '';
fragment.appendChild(header);
for (const opt of Array.from(child.children)) {
fragment.appendChild(buildItem(opt));
}
} else if (child.tagName === 'OPTION') {
fragment.appendChild(buildItem(child));
}
}
menu.appendChild(fragment);
syncSelected();
};
const syncSelected = () => {
const value = select.value;
let labelText = '';
let matched = false;
getOptions().forEach((el) => {
const selected = el.dataset.value === value;
el.classList.toggle('is-selected', selected);
el.setAttribute('aria-selected', selected ? 'true' : 'false');
if (selected) {
labelText = el.textContent;
matched = true;
}
});
if (!matched) {
const opt = select.querySelector(`option[value="${cssEscape(value)}"]`);
labelText = opt
? opt.textContent
: (select.options[select.selectedIndex]?.textContent ?? '');
}
label.textContent = labelText;
};
const choose = (value) => {
if (select.value === value) return;
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true }));
};
const open = () => {
document.querySelectorAll(ACTIVE_GROUP_SELECTOR).forEach((g) => {
if (g !== group) g.classList.remove('active');
});
group.classList.add('active');
trigger.setAttribute('aria-expanded', 'true');
// Focus the currently selected option (or the first option) so
// keyboard navigation starts from a sensible position.
requestAnimationFrame(() => {
const selected = menu.querySelector('.sort-option.is-selected');
(selected || getOptions()[0])?.focus();
});
};
const close = () => {
group.classList.remove('active');
trigger.setAttribute('aria-expanded', 'false');
};
const toggle = () => {
if (group.classList.contains('active')) close();
else open();
};
// ---- keyboard navigation ----
// Type-to-select buffer: accumulate characters and reset after a pause.
// Shared between trigger and menu keydown handlers.
let typeBuffer = '';
let typeTimer = null;
const focusOptionByText = (prefix) => {
const options = getOptions();
const lower = prefix.toLowerCase();
for (let i = 0; i < options.length; i++) {
if (options[i].textContent.toLowerCase().startsWith(lower)) {
options[i].focus();
return;
}
}
};
const moveFocus = (options, direction) => {
const focused = menu.querySelector('.sort-option:focus');
let idx = focused ? Array.from(options).indexOf(focused) : -1;
idx = Math.max(0, Math.min(options.length - 1, idx + direction));
options[idx]?.focus();
};
const handleTypeToSelect = (event) => {
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return false;
event.preventDefault();
clearTimeout(typeTimer);
typeBuffer += event.key;
focusOptionByText(typeBuffer);
typeTimer = setTimeout(() => { typeBuffer = ''; }, 800);
return true;
};
trigger.addEventListener('click', (event) => {
event.stopPropagation();
if (select.disabled) return;
toggle();
});
trigger.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
close();
} else if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
event.preventDefault();
if (!select.disabled) toggle();
} else if (!group.classList.contains('active')) {
// Type-to-select on closed dropdown: open and highlight match
if (handleTypeToSelect(event)) {
open();
}
}
});
menu.addEventListener('keydown', (event) => {
const options = getOptions();
if (options.length === 0) return;
switch (event.key) {
case 'Escape':
event.preventDefault();
close();
trigger.focus();
return;
case 'ArrowDown':
event.preventDefault();
moveFocus(options, 1);
return;
case 'ArrowUp':
event.preventDefault();
moveFocus(options, -1);
return;
case 'Home':
event.preventDefault();
options[0]?.focus();
return;
case 'End':
event.preventDefault();
options[options.length - 1]?.focus();
return;
case 'Enter':
case ' ':
event.preventDefault();
if (select.disabled) return;
const focused = menu.querySelector('.sort-option:focus');
if (focused) {
choose(focused.dataset.value);
close();
trigger.focus();
}
return;
}
handleTypeToSelect(event);
});
// Close dropdown when clicking outside
document.addEventListener('click', (event) => {
if (!group.contains(event.target)) {
close();
trigger.focus();
}
});
// ---- property overrides ----
// Override `value` and `disabled` on this instance so programmatic
// changes (loadSortPreference, VLM toggle, excluded-view sync, ...) keep
// the trigger label and disabled styling in sync without touching callers.
const proto = Object.getPrototypeOf(select);
const valueDescriptor =
Object.getOwnPropertyDescriptor(proto, 'value') ||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
const disabledDescriptor =
Object.getOwnPropertyDescriptor(proto, 'disabled') ||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'disabled');
if (valueDescriptor) {
Object.defineProperty(select, 'value', {
get() { return valueDescriptor.get.call(this); },
set(v) {
valueDescriptor.set.call(this, v);
syncSelected();
},
configurable: true,
});
}
if (disabledDescriptor) {
Object.defineProperty(select, 'disabled', {
get() { return disabledDescriptor.get.call(this); },
set(v) {
disabledDescriptor.set.call(this, v);
group.classList.toggle('is-disabled', Boolean(v));
trigger.disabled = Boolean(v);
if (v) close();
},
configurable: true,
});
}
// Rebuild the menu when <option>s change (VLM adds/removes a temporary
// option at runtime).
const observer = new MutationObserver(() => buildMenu());
observer.observe(select, { childList: true });
buildMenu();
group.dataset.sortReady = '1';
}
function cssEscape(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value);
}
// Fallback for environments without CSS.escape
return String(value).replace(/[!"#$%&'()*+,./:;<=>?@[\]^`{|}~\\ -]/g, '\\$&');
}

View File

@@ -10,6 +10,7 @@ import { DuplicatesManager } from './components/DuplicatesManager.js';
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
import { sidebarManager } from './components/SidebarManager.js';
import { initSortDropdown } from './components/controls/SortDropdown.js';
class RecipePageControls {
constructor() {
@@ -239,6 +240,7 @@ class RecipeManager {
// Sort select
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
initSortDropdown(sortSelect);
sortSelect.value = this.pageState.sortBy || 'date:desc';
sortSelect.addEventListener('change', () => {
this.pageState.sortBy = sortSelect.value;

View File

@@ -15,8 +15,13 @@
{% endif %}
<div class="actions">
<div class="action-buttons">
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.title') }}{% else %}{{ t('loras.controls.sort.title') }}{% endif %}" class="control-group">
<select id="sortSelect">
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.title') }}{% else %}{{ t('loras.controls.sort.title') }}{% endif %}" class="control-group sort-dropdown-group dropdown-group" data-sort-dropdown>
<button type="button" class="sort-trigger" aria-haspopup="listbox" aria-expanded="false">
<span class="sort-trigger__label"></span>
<i class="fas fa-caret-down sort-trigger__caret" aria-hidden="true"></i>
</button>
<div class="dropdown-menu sort-dropdown-menu" role="listbox"></div>
<select id="sortSelect" class="sort-select-native" tabindex="-1" aria-hidden="true">
<optgroup label="{{ t('loras.controls.sort.name') }}">
<option value="name:asc">{{ t('loras.controls.sort.nameAsc') }}</option>
<option value="name:desc">{{ t('loras.controls.sort.nameDesc') }}</option>

View File

@@ -352,3 +352,104 @@ async def test_resolve_authenticated_redirect_url_returns_location(monkeypatch):
)
assert result == "https://signed.example.com/file.safetensors"
@pytest.mark.asyncio
async def test_get_status_with_retry_passes_through_success(monkeypatch):
"""A successful first call returns immediately, no retries."""
downloader = Aria2Downloader()
call_count = 0
async def fake_get_status(_id):
nonlocal call_count
call_count += 1
return {"status": "active", "completedLength": "50", "totalLength": "100"}
monkeypatch.setattr(downloader, "get_status", fake_get_status)
result = await downloader._get_status_with_retry("dummy")
assert result is not None
assert result["status"] == "active"
assert call_count == 1
@pytest.mark.asyncio
async def test_get_status_with_retry_succeeds_after_transient_failure(monkeypatch):
"""A transient Aria2Error on the first call is retried and succeeds."""
downloader = Aria2Downloader()
call_count = 0
async def fake_get_status(_id):
nonlocal call_count
call_count += 1
if call_count == 1:
raise Aria2Error("timeout")
return {"status": "complete", "completedLength": "100", "totalLength": "100"}
monkeypatch.setattr(downloader, "get_status", fake_get_status)
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
result = await downloader._get_status_with_retry("dummy")
assert result is not None
assert result["status"] == "complete"
assert call_count == 2
@pytest.mark.asyncio
async def test_get_status_with_retry_raises_after_all_retries_exhausted(monkeypatch):
"""All retry attempts fail → Aria2Error with a descriptive message."""
downloader = Aria2Downloader()
async def fake_get_status(_id):
raise Aria2Error("connection reset")
monkeypatch.setattr(downloader, "get_status", fake_get_status)
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
with pytest.raises(Aria2Error) as exc_info:
await downloader._get_status_with_retry("dummy")
msg = str(exc_info.value)
assert "after 4 attempts" in msg
assert "connection reset" in msg
@pytest.mark.asyncio
async def test_get_status_with_retry_returns_none_when_not_tracked(monkeypatch):
"""No transfer in _transfers → get_status returns None → no retry needed."""
downloader = Aria2Downloader()
# get_status returns None when the download_id has no transfer;
# _get_status_with_retry should propagate that without raising.
result = await downloader._get_status_with_retry("nonexistent")
assert result is None
@pytest.mark.asyncio
async def test_wait_until_ready_includes_stderr_in_error():
"""When the subprocess exits early, its stderr output must be in Aria2Error."""
import sys
downloader = Aria2Downloader()
# Start a subprocess that writes a message to stderr and exits with code 28.
proc = await asyncio.create_subprocess_exec(
sys.executable, "-c",
"import sys; print('ERROR: unknown option --fsync', file=sys.stderr); sys.exit(28)",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
# Let the process exit
await asyncio.sleep(0.2)
# Point the downloader at this dead process and let _wait_until_ready
# discover the exit and read stderr.
downloader._process = proc
with pytest.raises(Aria2Error) as exc_info:
await downloader._wait_until_ready()
msg = str(exc_info.value)
assert "code 28" in msg
assert "ERROR: unknown option --fsync" in msg

View File

@@ -3,7 +3,7 @@ import { app } from "../../scripts/app.js";
// =============================================================================
// Node Marker right-click node marking (no dedicated node required)
//
// Adds a "Mark as →" submenu with role options to any node's context menu.
// Adds a "🎯 Mark as →" submenu with role options to any node's context menu.
// Roles are stored in ``node.properties.lm_marker_role`` and automatically
// persist with the workflow JSON.
//
@@ -107,7 +107,7 @@ function buildMenuItems(node) {
return [
null,
{
content: "Mark as",
content: "\uD83C\uDFAF Mark as",
has_submenu: true,
submenu: {
options: buildSubmenuOptions(node),

View File

@@ -260,7 +260,6 @@ function createTagElement({
}) {
const tagEl = document.createElement("div");
tagEl.className = "comfy-tag";
tagEl.dataset.captureWheel = "true";
const baseStyles = {
padding: `${roundScaled(group ? 5 : 3, styleScale)}px ${roundScaled(group ? 8 : 10, styleScale)}px`,
@@ -619,6 +618,36 @@ function showTagContextMenu(event, tagData, index, widget, anchorEl) {
setTimeout(() => document.addEventListener('click', closeMenu), 0);
}
// Singleton window capture-phase wheel hook: focuses the tags container when a
// wheel event occurs inside it, so that ComfyUI's wheelCapturedByFocusedElement
// recognises this zone and does NOT forward the event to canvas (which would
// trigger zoom and stopPropagation, preventing the strength-adjustment handler).
/** @type {boolean} */
let tagWheelCaptureHookInstalled = false;
function installTagWheelCaptureHook() {
if (tagWheelCaptureHookInstalled) return;
tagWheelCaptureHookInstalled = true;
window.addEventListener(
"wheel",
(event) => {
// Only handle vertical mouse wheel (not pinch-zoom or horizontal swipe)
if (event.ctrlKey || event.metaKey) return;
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
const target = /** @type {Element} */ (event.target);
if (!target?.closest) return;
const targetContainer = target.closest(
'.comfy-tags-container[data-capture-wheel="true"]'
);
if (!targetContainer) return;
targetContainer.focus({ preventScroll: true });
},
{ capture: true, passive: true }
);
}
export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.02, options = {}) {
const container = document.createElement("div");
container.className = "comfy-tags-container";
@@ -628,6 +657,29 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
forwardMiddleMouseToCanvas(container);
forwardWheelToCanvas(container);
// Vue render mode: ComfyUI's TransformPane uses a capture-phase wheel handler
// (TransformPane @wheel.capture) that checks wheelCapturedByFocusedElement.
// For that check to return true (preventing canvas zoom and allowing our
// strength-adjustment wheel handler to fire), the container needs both
// data-capture-wheel AND document.activeElement inside it.
// We make the container focusable and auto-focus it on wheel events via a
// window capture-phase hook.
container.dataset.captureWheel = "true";
container.tabIndex = -1;
// Blur on mouseleave to avoid lingering focus side effects.
container.addEventListener("mouseleave", () => {
if (document.activeElement === container) {
container.blur();
}
});
// Singleton window capture-phase wheel handler: focuses our container when
// a wheel event occurs inside it, so that wheelCapturedByFocusedElement
// recognises this zone and does NOT forward the event to canvas (which would
// trigger zoom and stopPropagation, preventing our strength handler).
installTagWheelCaptureHook();
Object.assign(container.style, {
display: "flex",
flexWrap: "wrap",
@@ -641,6 +693,7 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
overflow: "auto",
alignItems: "flex-start",
alignContent: "flex-start",
outline: "none",
});
const initialTagsData = opts?.defaultVal || [];

View File

@@ -186,32 +186,59 @@ const createExtensionObject = (useActionBar) => {
};
injectStyles();
const replaceButtonIcon = () => {
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
buttons.forEach(button => {
button.classList.add('lm-top-menu-button');
button.innerHTML = getLoraManagerIcon();
button.style.borderRadius = '4px';
button.style.padding = '6px';
button.style.backgroundColor = 'var(--primary-bg)';
const svg = button.querySelector('svg');
if (svg) {
svg.style.width = '20px';
svg.style.height = '20px';
}
});
if (buttons.length === 0) {
requestAnimationFrame(replaceButtonIcon);
const applyIconToButton = (button) => {
// Skip if the SVG icon is already in place
if (button.querySelector('svg')) return;
button.classList.add('lm-top-menu-button');
button.innerHTML = getLoraManagerIcon();
button.style.borderRadius = '4px';
button.style.padding = '6px';
button.style.backgroundColor = 'var(--primary-bg)';
const svg = button.querySelector('svg');
if (svg) {
svg.style.width = '20px';
svg.style.height = '20px';
}
};
requestAnimationFrame(replaceButtonIcon);
// Initial application — retry until the button is rendered by Vue
const pollUntilFound = () => {
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
if (buttons.length > 0) {
buttons.forEach(applyIconToButton);
} else {
requestAnimationFrame(pollUntilFound);
}
};
requestAnimationFrame(pollUntilFound);
// MutationObserver: keep the SVG icon in place after Vue re-renders
// (e.g. when the properties panel is toggled inside a subgraph)
if (typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver(() => {
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
buttons.forEach(button => {
// Only re-apply when Vue has reset innerHTML back to <i>
if (button.querySelector('i')) {
applyIconToButton(button);
}
});
});
// Watch the action bar and a broad ancestor so we cover re-mounts
const watchNode = document.querySelector('[data-testid="action-bar-buttons"]')
|| document.querySelector('.actionbar-container')
|| document.body;
observer.observe(watchNode, { childList: true, subtree: true });
// Store reference for potential cleanup
window.__lmIconObserver = observer;
}
},
};
if (useActionBar) {
extensionObj.actionBarButtons = [
{
icon: "icon-[mdi--alpha-l-box] size-4",
icon: "icon-[lucide--layers] size-4",
tooltip: BUTTON_TOOLTIP,
onClick: openLoraManager
}