fix(lora): keep the save outcome, honour the umask and sweep stale downloads

Leaving Integrations drops the CivitAI key draft only when a key was
typed, so the last Save's outcome survives and Sources still marks a
refused source. The key status line is rewritten only when it changes.

Cached CivitAI images are opened like a plain save, so they keep the
umask's mode, and each import removes .pm-*.part files older than an
hour from the image cache. A legacy scan stopped because its page left
ends cancelled in Activity, and staging is awaited to the end even if
the handler is cancelled again.
This commit is contained in:
Vito Sansevero
2026-10-25 09:48:55 -07:00
parent 2ff74fda25
commit a073dc613e
9 changed files with 213 additions and 22 deletions
+8 -4
View File
@@ -2664,8 +2664,9 @@ Recorded deviations (plan D60–D70, D74, D75, D79–D82 renumbered D92–D102,
A `p.small#civitai-key-status[role=status]` (it also describes the field) says "A key
is saved." / "No key saved." / "The new key replaces the saved one when you save." /
"The saved key is removed when you save."; pressing "Clear saved key" keeps the
keyboard on the key field. A typed key is dropped when the section is left (the field
comes back empty). The field is `autocomplete=new-password`, so browsers never offer it
keyboard on the key field, and the line is rewritten only when its text changes. A typed
key is dropped when the section is left (the field comes back empty); leaving without one
keeps the last Save's outcome, so Sources still marks a refused source. The field is `autocomplete=new-password`, so browsers never offer it
as a login; the concept's
prototype warning is dropped. The LoRA rows save through the one Save row:
`POST /lora/enable` with `expected_revision` (a config.json changed elsewhere is
@@ -3147,9 +3148,12 @@ imports changed meanwhile), `blocked-by-pairs`, `nothing-to-import`, `backup-fai
whatever happened after. The tests change real files behind a held download
(`test_files_changed_while_staging_fail_the_job`, removed and added files too). The
replace binds the category, not one parameter per prompt, so SQLite's variable limit
never caps a collection. The legacy `POST /lora/scan` runs the same stages under its SSE
never caps a collection. Cached CivitAI images are written to a `.pm-*.part` file in the
same folder and renamed into place, with the umask's mode as a plain save gives; each import
first removes `.pm-*.part` files older than an hour from the image cache and its LoRA
folders (`TestStaleDownloadSweep`). The legacy `POST /lora/scan` runs the same stages under its SSE
event shape; a page that closes its connection stops staging before its next LoRA, and the
scan keeps its records hold until staging has stopped
scan keeps its records hold until staging has stopped, then ends `cancelled` in Activity
(`test_a_disconnected_legacy_scan_stops_and_holds_until_it_stopped`) (`test_legacy_scan_events_unchanged_on_success`), holds
the registry as "LoRA import (admin page)" and takes no safety backup. `POST /lora/enable`
accepts the optional `expected_revision`. LoraManager detection also searches every
+1
View File
@@ -135,6 +135,7 @@ class LoraImportRoutesMixin:
from ..config import IntegrationConfig
cache_dir = await self._run_in_executor(lora_utils.get_lora_image_cache_dir)
await self._run_in_executor(lora_utils.sweep_stale_downloads, cache_dir)
loop = asyncio.get_running_loop()
def report(index, record, staged):
+26 -5
View File
@@ -73,6 +73,13 @@ def _closed(request):
return closed
def _stopped(error) -> bool:
"""Whether a legacy scan ended because its page closed the connection."""
from .. import lora_import
return isinstance(error, (ConnectionError, lora_import.ImportCancelled))
class LoraIntegrationMixin:
"""Mixin providing LoraManager detection, scanning, and trigger word endpoints."""
@@ -357,8 +364,13 @@ class LoraIntegrationMixin:
await self._lora_scan_stream(
allowed, send_progress, disconnected=_closed(request)
)
except asyncio.CancelledError:
# The server cancelled the handler when the page left.
job.finish("cancelled")
raise
except Exception as e:
job.finish("failed")
# A page that left stopped the scan; nothing failed (R2).
job.finish("cancelled" if _stopped(e) else "failed")
await self._send_scan_failure(e, send_progress)
with contextlib.suppress(ConnectionError, RuntimeError):
await response.write_eof()
@@ -375,14 +387,13 @@ class LoraIntegrationMixin:
import) are warnings in the admin page's own words; anything else is
logged with its traceback.
"""
from .. import lora_import
from .lora_import_routes import (
EmptyReplacement,
PairDependencyError,
PlanChanged,
)
if isinstance(error, (ConnectionError, lora_import.ImportCancelled)):
if _stopped(error):
self.logger.info("LoRA scan stopped: the page closed its connection")
return
if isinstance(error, PlanChanged):
@@ -498,8 +509,18 @@ class LoraIntegrationMixin:
await report(*events.get_nowait())
finally:
stop.set()
with contextlib.suppress(Exception):
await asyncio.shield(staging)
# Staging must have stopped before the records hold is released,
# even when the handler is cancelled again while it waits (R3).
interrupted = False
while not staging.done():
try:
await asyncio.shield(staging)
except asyncio.CancelledError:
interrupted = True
except Exception:
pass
if interrupted:
raise asyncio.CancelledError
staged = await staging
if not await self.lora_files_unchanged(lm_path, reviewed):
raise LoraFilesChanged(_FILES_CHANGED)
+50 -9
View File
@@ -524,21 +524,35 @@ def _download_one(url: str, local_path: Path, api_key: str) -> Optional[str]:
return None
_PART_PREFIX = ".pm-"
_PART_SUFFIX = ".part"
# A temp download older than this was left by a killed process, not a live one
# (each download has a 10 s timeout).
STALE_PART_SECONDS = 3600
def _save_atomically(image, local_path: Path) -> None:
"""Write ``image`` as JPEG so ``local_path`` only ever names a whole file.
The image goes to a temp file in the same folder, which is closed before
``os.replace`` puts it in place (Windows cannot replace an open file); a
failure removes the temp file. A reader (another import's cache check)
never sees a half-written image.
The image goes to a ``.pm-*.part`` file in the same folder, which is
closed before ``os.replace`` puts it in place (Windows cannot replace an
open file); a failure removes the temp file. A reader (another import's
cache check) never sees a half-written image. The temp file is opened
like ``img.save`` opens a file, so the image keeps the umask's mode.
"""
import tempfile
import secrets
fd, temp = tempfile.mkstemp(
dir=str(local_path.parent), prefix=".pm-", suffix=".part"
)
while True:
temp = local_path.parent / (
f"{_PART_PREFIX}{secrets.token_hex(8)}{_PART_SUFFIX}"
)
try:
handle = open(temp, "xb")
except FileExistsError:
continue
break
try:
with os.fdopen(fd, "wb") as handle:
with handle:
image.save(handle, "JPEG", quality=85)
os.replace(temp, local_path)
except BaseException:
@@ -549,6 +563,33 @@ def _save_atomically(image, local_path: Path) -> None:
raise
def sweep_stale_downloads(cache_dir: Path) -> None:
"""Remove ``.pm-*.part`` files a killed download left in the image cache.
Only that exact pattern, only in ``cache_dir`` and its LoRA folders, and
only once older than ``STALE_PART_SECONDS``; a file that cannot be read
or removed is left for a later import.
"""
import time
cutoff = time.time() - STALE_PART_SECONDS
try:
folders = [cache_dir] + [p for p in cache_dir.iterdir() if p.is_dir()]
except OSError:
return
for folder in folders:
try:
candidates = list(folder.glob(f"{_PART_PREFIX}*{_PART_SUFFIX}"))
except OSError:
continue
for path in candidates:
try:
if path.is_file() and path.stat().st_mtime < cutoff:
os.remove(path)
except OSError:
logger.debug(f"Could not remove stale download {path}")
def _civitai_cache_plan(
metadata: Dict, metadata_path: Path, cache_dir: Path
) -> Tuple[Path, List[str], List[Tuple[str, Path]]]:
@@ -71,7 +71,6 @@ function measureIntegrations(mainSelector) {
// The concept's `.small` text (its Save row status); production's row notes use the same class.
small: css(layout.querySelector('#settings-status'), 'fontSize', 'color'),
note: note && css(note, 'fontSize', 'color'),
// Whether each select shows its whole chosen option (its text within the box, less the native arrow).
// Whether each select is as wide as the browser lays out a copy holding only its chosen option, i.e. it shows
// that option whole (the browser's own select sizing, arrow included).
selectsFit: [...layout.querySelectorAll('.setting-row > select')].map(select => {
@@ -178,6 +177,16 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
});
}
// D106 below the concept's smallest size: on a 320px phone both selects still show their whole option, without page
// scroll (S10 re-review N9).
for (const theme of THEMES) test(`integrations selects fit at 320 wide ${theme}`, async ({ page, request }) => {
await page.setViewportSize({ width: 320, height: 700 });
await fakeLoraManager(request);
const production = await productionIntegrations(page, theme);
expect(production.selectsFit).toEqual([true, true]);
expect(production.pageScroll).toBe(false);
});
test('integrations comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await fakeLoraManager(request);
@@ -218,6 +218,18 @@ test('key never prefilled; blank keeps; Clear saved key clears; saved through Sa
await expect(field).toHaveValue('');
await expect(key.locator('p.small')).toHaveText('A key is saved.');
expect(statusBodies.join('')).not.toContain('sk-test-key');
// The announced line is rewritten only when its text changes, not on every edit elsewhere (S10 re-review N8).
await page.evaluate(() => {
window.__keyNoteWrites = 0;
new MutationObserver(records => { window.__keyNoteWrites += records.length; })
.observe(document.querySelector('#civitai-key-status'), { childList: true, characterData: true, subtree: true });
});
const triggers = settingOf(page, 'Automatically include trigger words').locator('input');
await triggers.check();
await expect(status(page)).toHaveText('Unsaved changes.');
await triggers.uncheck();
await expect(status(page)).toHaveText('No unsaved changes.');
expect(await page.evaluate(() => window.__keyNoteWrites)).toBe(0);
// A blank field keeps the saved key: nothing to save.
await field.fill(' ');
await expect(status(page)).toHaveText('No unsaved changes.');
@@ -285,6 +297,29 @@ test('a save where only the LoRA part is written says so; the next save needs no
expect((await serverJson(request, '/prompt_manager/settings')).sources.configured.map(source => source.path)).toEqual(['output']);
});
test('a partial save made from Integrations keeps its outcome when Sources opens (S10 re-review R1)',
async ({ page, request }) => {
await lora(request);
await expect(await request.post('/__test__/system/missing-source', { data: { inside: true } })).toBeOK();
await openIntegrations(page);
const nav = page.locator('#workspace-main .section-nav');
const sourceRow = title => layoutOf(page).locator('#source-list .operation').filter({ has: page.locator('h3', { hasText: title }) });
await nav.getByRole('button', { name: 'Sources & scans', exact: true }).click();
await expect(sourceRow('unplugged')).toBeVisible();
await expect(await request.post('/__test__/system/unplug')).toBeOK();
await sourceRow('ComfyUI input').getByRole('button', { name: 'Remove', exact: true }).click();
// Back in Integrations, a LoRA change, then Save from there.
await nav.getByRole('button', { name: 'Integrations', exact: true }).click();
await settingOf(page, 'Automatically include trigger words').locator('input').check();
await save(page).click();
const reason = "output/unplugged can't be used: Gallery root does not exist. Remove it or reconnect it, then save.";
await expect(status(page)).toHaveText(reason);
// Leaving Integrations (no key was typed) keeps the outcome: Sources shows the reason on the refused row.
await nav.getByRole('button', { name: 'Sources & scans', exact: true }).click();
await expect(sourceRow('unplugged')).toHaveAttribute('aria-invalid', 'true');
await expect(status(page)).toHaveText(reason);
});
test('Review import shows replacement, blocked items and network effects with the backup box; Start opens the job (D107)',
async ({ page, request }) => {
await lora(request, { loras: 3, civitai: true, previous: 2 });
+19
View File
@@ -13,6 +13,7 @@ import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest.mock import patch
@@ -531,6 +532,9 @@ class LoraImportRouteTests(LoraImportAPITestCase):
break
await asyncio.sleep(0.01)
self.assertIsNone(self.api._jobs.active("lora_import"))
# R2 (re-review): the user left and nothing failed, so Activity says so.
job = next(j for j in self.api._jobs.list() if j.kind == "lora_import")
self.assertEqual(job.state, "cancelled")
# Staging stopped at the next LoRA: no download runs after the hold.
served = len(self.requests)
self.assertLess(served, 4)
@@ -538,6 +542,21 @@ class LoraImportRouteTests(LoraImportAPITestCase):
self.assertEqual(len(self.requests), served)
self.assertEqual(_snapshot(self.api.db), before)
async def test_an_import_sweeps_stale_temp_downloads_from_the_cache(self):
# N7 (re-review): a download killed mid-write leaves a .pm-*.part file;
# the next import removes it once it is an hour old.
self._two_loras()
stale = self.cache_dir / "old_lora" / ".pm-dead.part"
stale.parent.mkdir(parents=True)
stale.write_bytes(b"x")
stamp = time.time() - 2 * 3600
os.utime(stale, (stamp, stamp))
resp = await self.client.request("POST", SCAN)
await resp.text()
self.assertFalse(stale.exists())
async def test_legacy_scan_events_unchanged_on_success(self):
self._two_loras()
(self.loras / "broken.metadata.json").write_text("{{{")
+56
View File
@@ -12,6 +12,7 @@ import shutil
import sys
import tempfile
import threading
import time
import types
import unittest
import urllib.parse
@@ -576,6 +577,61 @@ class TestDownloadIsAtomic(unittest.TestCase):
self.assertIsNone(result)
self.assertEqual(list(self.tmpdir.iterdir()), [])
@unittest.skipIf(os.name == "nt", "POSIX file modes")
def test_the_image_gets_the_permissions_a_plain_save_gives(self):
# S10 re-review N7: the cached JPEG honours the umask like img.save did.
previous = os.umask(0o022)
self.addCleanup(os.umask, previous)
opener = _FakeOpener(_FakeResponse(_png_bytes()))
with patch.object(lora_utils, "_open_url", opener):
lora_utils._download_one(
"https://civitai.com/api/download/1", self.target, ""
)
self.assertEqual(self.target.stat().st_mode & 0o777, 0o644)
class TestStaleDownloadSweep(unittest.TestCase):
"""S10 re-review N7: a killed download's ``.pm-*.part`` is swept later."""
def setUp(self):
self.cache = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.cache, True)
self.lora = self.cache / "my_lora"
self.lora.mkdir()
def _file(self, path, age_seconds):
path.write_bytes(b"x")
stamp = time.time() - age_seconds
os.utime(path, (stamp, stamp))
return path
def test_only_old_temp_downloads_are_removed(self):
old = self._file(self.lora / ".pm-abc123.part", 2 * 3600)
old_top = self._file(self.cache / ".pm-top.part", 2 * 3600)
fresh = self._file(self.lora / ".pm-new.part", 60)
image = self._file(self.lora / "abc.jpg", 2 * 3600)
other = self._file(self.lora / "keep.part", 2 * 3600)
prefixed = self._file(self.lora / ".pm-keep.jpg", 2 * 3600)
deep = self.lora / "nested"
deep.mkdir()
nested = self._file(deep / ".pm-deep.part", 2 * 3600)
lora_utils.sweep_stale_downloads(self.cache)
self.assertFalse(old.exists())
self.assertFalse(old_top.exists())
for kept in (fresh, image, other, prefixed, nested):
self.assertTrue(kept.exists(), kept.name)
def test_a_missing_cache_is_fine(self):
lora_utils.sweep_stale_downloads(self.cache / "absent")
def test_a_file_that_cannot_be_removed_is_left(self):
old = self._file(self.lora / ".pm-abc.part", 2 * 3600)
with patch.object(lora_utils.os, "remove", side_effect=PermissionError):
lora_utils.sweep_stale_downloads(self.cache)
self.assertTrue(old.exists())
class _FakeHTTPSHandler(urllib.request.HTTPSHandler):
"""Serves canned responses through a real urllib opener, recording requests.
+8 -3
View File
@@ -414,7 +414,9 @@ export function mountIntegrations(container, { api, activity, scope, drafts, cli
pathInput.value = values.lora_path || values.lora_detected_path || '';
setNote(triggers, cacheText(values));
keyInput.disabled = unread;
keyNote.textContent = keyText(values, changes) ?? '';
// A live region: rewritten only when it says something new, so it is not re-announced on every edit.
const note = keyText(values, changes) ?? '';
if (keyNote.textContent !== note) keyNote.textContent = note;
// D114: "Clear saved key" is offered only while a saved key would stay.
if (values.lora_has_key === true && changes.clear_civitai_api_key !== true) key.children[0].append(keyActions);
else {
@@ -434,8 +436,11 @@ export function mountIntegrations(container, { api, activity, scope, drafts, cli
};
scope.cleanup(drafts.subscribe(repaint));
// A typed key is held only while its field shows it: leaving the section drops it, since the field comes back
// empty and a hidden key draft could not be seen or discarded (S10 review N4).
scope.cleanup(() => drafts.set('host', 'civitai_api_key', undefined));
// empty and a hidden key draft could not be seen or discarded (S10 review N4). Only a typed key: any set clears
// the last Save's outcome, which Sources still needs (S10 re-review R1).
scope.cleanup(() => {
if (drafts.changes('host').civitai_api_key !== undefined) drafts.set('host', 'civitai_api_key', undefined);
});
repaint();
return [comfy, surface];
}