Compare commits

...
Author SHA1 Message Date
Vito bf2fa3f50e chore: release 3.5.0 (#608)
Adds the thumbnail location and move (#320), Send to All (#542), skipped
tags and saved instructions (#431), the Pair category repair (#550) and
lower image memory use (#523). Raises the Pillow minimum to 10.1.0 (#604).
2026-11-01 22:13:00 +00:00
Vito 2e9322d5c4 fix(thumbnails): never let an older folder check overwrite a newer answer (#611) (#612)
Two refresh() callers could publish out of order. A check that looked
before the folder was removed could set "available" after a newer check
said "missing", and the state stayed wrong until the next probe. Each
check now has an order number, and an older answer is dropped.
2026-11-01 21:16:55 +00:00
Vito a1377cec36 ci: pin the Playwright image by digest (#609) (#610)
The v1.64.1-noble tag was re-pushed with Node 26, which fails the Node 24
guard in every browser job. Pin the last green image's digest so the
browsers, fonts and Node only change through a reviewed bump.
2026-11-01 19:58:19 +00:00
Vito fa98d0644d Match ComfyUI's dependency floors (#604) and fix the stalled store test race (#603) (#606)
* chore(deps): match ComfyUI's Pillow, aiohttp and numpy floors (#604)

PromptManager never fights ComfyUI on versions: the floors follow its
requirements.txt and Dependabot no longer raises them. The full suite passes on
Pillow 10.1.0 and aiohttp 3.11.8; numpy was tested at 1.26.4, the first with
Python 3.12 wheels. A test that left response bodies unread hung the aiohttp
client before 3.11.11, so it reads them now.

* test(thumbnails): the stalled store call test runs without the probe (#603)

note_failure() wakes the probe, which finds the healthy test folder again at
once, so the next lookup could do store I/O and the test failed intermittently.
2026-11-01 16:38:29 +00:00
13 changed files with 114 additions and 27 deletions
+5
View File
@@ -17,6 +17,11 @@ updates:
prefix: "deps(pip)"
include: "scope"
rebase-strategy: "auto"
# These floors follow ComfyUI's requirements.txt, never the newest release.
ignore:
- dependency-name: "Pillow"
- dependency-name: "aiohttp"
- dependency-name: "numpy"
# Optional: group non-breaking updates to reduce PR noise
groups:
python-minor-and-patch:
+1 -1
View File
@@ -80,7 +80,7 @@ jobs:
# pulled them from Ubuntu's apt mirror on every run, and three runs (#349, #378, #382) crawled at
# ~26 kB/s for 11-12 minutes until the job hit its limit after every test had passed.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001
timeout-minutes: 24
steps:
+3 -3
View File
@@ -53,7 +53,7 @@ jobs:
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
# tests/vendor/browser-image.test.mjs checks it). Browsers and fonts come from the image.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001
timeout-minutes: 20
# Public-repo ubuntu-latest runners have 4 vCPUs: three Playwright workers, each with its own test
@@ -121,7 +121,7 @@ jobs:
runs-on: ubuntu-latest
# Same image as the shards: the raw files name sources by the container's workspace path.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001
timeout-minutes: 10
steps:
@@ -192,7 +192,7 @@ jobs:
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
# tests/vendor/browser-image.test.mjs checks it). Browsers and fonts come from the image.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
options: --user 1001
timeout-minutes: 20
# Public-repo ubuntu-latest runners have 4 vCPUs: three Playwright workers, each with its own test
+4 -1
View File
@@ -111,8 +111,11 @@ distributions, browser system dependencies may need manual installation. For a
quick browser run, use `test:workflows -- --project chromium`.
CI runs the browser jobs inside the pinned image
`mcr.microsoft.com/playwright:v<@playwright/test version>-noble`
`mcr.microsoft.com/playwright:v<@playwright/test version>-noble@sha256:<digest>`
(`tests/vendor/browser-image.test.mjs` keeps the tag in step with the lockfile).
The digest is part of the pin, because Microsoft re-pushes tags: `v1.64.1-noble`
moved from Node 24 to Node 26 on 2026-11-01 (#609). To bump it, change the tag and
digest together and check the new image's Node against the Node 24 guard.
Concept comparisons measure geometry, and geometry follows the fonts a browser
falls back to: the concept names Inter, which neither the image nor a stock
runner ships. On a plain `ubuntu-latest` runner the fallback is DejaVu Sans,
+33 -11
View File
@@ -69,6 +69,8 @@ _state_lock = threading.Lock()
_parsed = preview_location.ParsedLocation()
_available = True
_problem = None
# The newest check whose answer was published.
_published_order = 0
_keys = {}
_key_cache = {}
_clock = time.monotonic
@@ -167,10 +169,13 @@ def refresh():
if parsed.mode != FOLDER_MODE or parsed.problem is not None:
return parsed.mode == SOURCE_MODE
try:
available, problem, marker = _probe.check(parsed.path, parsed.id)
(available, problem, marker), order = _probe.ordered_check(
parsed.path, parsed.id
)
except (OSError, TimeoutError, RuntimeError):
available, problem, marker = False, "unreachable", None
_set_state(parsed, available, problem, marker)
order = _probe.latest_order()
_set_state(parsed, available, problem, marker, order)
return available
@@ -207,11 +212,16 @@ def mark_invalid():
_available, _problem = False, "invalid"
def _set_state(parsed, available, problem, marker):
global _available, _problem
def _set_state(parsed, available, problem, marker, order):
"""Publish a check's answer, unless a check that started later already
published (two callers can finish out of order, #611)."""
global _available, _problem, _published_order
with _state_lock:
if _parsed is not parsed:
return # the setting changed meanwhile
if order < _published_order:
return
_published_order = order
_available, _problem = available, problem
keys = dict((marker or {}).get("keys") or {})
if keys != _keys:
@@ -261,14 +271,25 @@ class _Probe:
self._stop = threading.Event()
self._thread = None
self._checking = None
self._order = 0
def check(self, path, expected_id):
"""``folder_state`` within IO_BUDGET; raises TimeoutError.
"""``folder_state`` within IO_BUDGET; see :meth:`ordered_check`."""
return self.ordered_check(path, expected_id)[0]
The answer comes from a check that started after this call: one
already running may have looked before what the caller just did, so
it is waited for (never run twice at once; a stuck one times out here)
and followed by a new one."""
def latest_order(self):
"""The order number of the check started last."""
with self._lock:
return self._order
def ordered_check(self, path, expected_id):
"""``(folder_state, order)`` within IO_BUDGET; raises TimeoutError.
``order`` grows with each check started, so answers can be published
in the order they looked. The answer comes from a check that started
after this call: one already running may have looked before what the
caller just did, so it is waited for (never run twice at once; a stuck
one times out here) and followed by a new one."""
key = (path, expected_id)
with self._lock:
earlier = self._running(key)
@@ -284,7 +305,7 @@ class _Probe:
self._checking = None
if "error" in pending[2]:
raise pending[2]["error"]
return pending[2]["value"]
return pending[2]["value"], pending[2]["order"]
def _running(self, key):
"""The check of *key* still running (lock held), else None."""
@@ -300,7 +321,8 @@ class _Probe:
def _start_check(self, path, expected_id):
"""Start one check on its own thread (lock held)."""
result = {}
self._order += 1
result = {"order": self._order}
done = threading.Event()
def run():
+2 -2
View File
@@ -1,10 +1,10 @@
[project]
name = "promptmanager"
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
version = "3.4.1"
version = "3.5.0"
license = {file = "LICENSE"}
requires-python = ">=3.12"
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
dependencies = ["watchdog>=2.1.0", "Pillow>=10.1.0"]
[build-system]
requires = ["setuptools>=84.2.1"]
+4 -4
View File
@@ -1,11 +1,11 @@
# Runtime dependencies
# Runtime dependencies (Pillow, aiohttp and numpy floors match ComfyUI requirements.txt)
watchdog>=6.0.0
Pillow>=12.3.0
aiohttp>=3.14.5
Pillow>=10.1.0
aiohttp>=3.11.8
# Tests and coverage
coverage>=7.16.2
numpy>=2.2.6 # WD14 engine tests
numpy>=1.25.0 # WD14 engine tests
# Code quality
black==26.10.1
+1 -1
View File
@@ -1,4 +1,4 @@
# Runtime dependencies for PromptManager.
# aiohttp, numpy and torch come from ComfyUI itself and are not listed here.
watchdog>=2.1.0 # Output directory monitoring
Pillow>=8.0.0 # Image metadata extraction and thumbnails
Pillow>=10.1.0 # Image metadata extraction and thumbnails
+2
View File
@@ -796,6 +796,7 @@ class TestRevalidatedCachingBehindMiddlewares(CoreRoutesTestCase):
self.assertEqual(resp.status, 200, path)
self.assertEqual(resp.headers.get("Content-Encoding"), "gzip", path)
self.assertEqual(resp.headers["Cache-Control"], "no-cache", path)
await resp.read() # aiohttp before 3.11.11 hangs on an unread body
again = await self.client.request(
"GET",
path,
@@ -806,6 +807,7 @@ class TestRevalidatedCachingBehindMiddlewares(CoreRoutesTestCase):
)
self.assertEqual(again.status, 304, path)
self.assertEqual(again.headers["Cache-Control"], "no-cache", path)
await again.read()
# ── instance helpers ──────────────────────────────────────────────────
+50
View File
@@ -89,5 +89,55 @@ class TestProbeCheck(unittest.TestCase):
self.probe.check(*KEY)
class TestRefreshOrder(unittest.TestCase):
"""Only the newest check's answer is published (#611)."""
def test_an_older_check_never_overwrites_a_newer_answer(self):
parsed = preview_location.ParsedLocation(
mode=preview_store.FOLDER_MODE, path="/thumbs", id="x"
)
self.addCleanup(setattr, preview_store, "_available", preview_store._available)
self.addCleanup(setattr, preview_store, "_problem", preview_store._problem)
started = threading.Event()
release = threading.Event()
newer_published = threading.Event()
calls = []
publish = preview_store._set_state
def state(_path, _expected_id):
calls.append(1)
if len(calls) == 1:
started.set()
self.assertTrue(release.wait(WAIT))
return HERE
return GONE
def set_state(*args, **kwargs):
if args[1]: # the older check's "available" arrives last
self.assertTrue(newer_published.wait(WAIT))
publish(*args, **kwargs)
def newer():
preview_store.refresh()
newer_published.set()
with (
patch.object(preview_store, "_parsed", parsed),
patch.object(preview_location, "folder_state", state),
patch.object(preview_store, "_set_state", set_state),
):
first = threading.Thread(target=preview_store.refresh)
first.start()
self.assertTrue(started.wait(WAIT))
second = threading.Thread(target=newer)
second.start()
release.set()
second.join(WAIT)
first.join(WAIT)
self.assertFalse(first.is_alive() or second.is_alive())
self.assertFalse(preview_store._available)
self.assertEqual(preview_store._problem, "missing")
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -287,6 +287,9 @@ class TestUnavailableFolder(FolderModeCase):
self.assertTrue(stray.exists())
async def test_a_stalled_store_call_marks_it_unreachable_and_stops_io(self):
# Without the probe: woken by the stall, it finds this healthy folder again
# at once, and the lookup below would rightly do store I/O.
self.assertTrue(preview_store.stop_probe())
make_png(self.output_dir / "m.png", size=(64, 64))
self.preview("m.png").write_bytes(b"x")
ticks = iter([0.0, preview_store.IO_BUDGET + 1.0])
+4 -2
View File
@@ -8,6 +8,8 @@ import { readFileSync } from 'node:fs';
const workflow = readFileSync(new URL('../../.github/workflows/viewerjs-check.yml', import.meta.url), 'utf8');
const lock = JSON.parse(readFileSync(new URL('../../tools/vendor/package-lock.json', import.meta.url), 'utf8'));
const version = lock.packages['node_modules/@playwright/test'].version;
// The tag alone is not a pin: v1.64.1-noble was re-pushed with Node 26 (#609). The digest fixes the image's contents.
const pinnedImage = `^ container:\\n image: mcr\\.microsoft\\.com/playwright:v${version.replaceAll('.', '\\.')}-noble@sha256:[0-9a-f]{64}$`;
function job(name) {
const match = workflow.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z][a-z-]*:\\n|(?![\\s\\S]))`, 'm'));
@@ -18,7 +20,7 @@ function job(name) {
for (const name of ['frontend-coverage', 'browser-workflows']) {
test(`${name} runs in the pinned Playwright image matching @playwright/test`, () => {
const body = job(name);
assert.match(body, new RegExp(`^ container:\\n image: mcr\\.microsoft\\.com/playwright:v${version.replaceAll('.', '\\.')}-noble$`, 'm'));
assert.match(body, new RegExp(pinnedImage, 'm'));
assert.doesNotMatch(body, /playwright install/, 'browsers and their fonts come from the image, not a runner install');
// Toolcache interpreters are the runner's own builds: their entry points name host paths that do not exist in
// the container (setup-python failed with `spawn .../bin/pip ENOENT`). Use the image's Python and Node.
@@ -39,7 +41,7 @@ test('real ComfyUI compatibility runs in the pinned Playwright image on the revi
const match = testWorkflow.match(/^ {2}comfyui:\n([\s\S]*?)(?=^ {2}[a-z][a-z-]*:\n|(?![\s\S]))/m);
assert.ok(match, 'comfyui job exists');
const body = match[1];
assert.match(body, new RegExp(`^ container:\\n image: mcr\\.microsoft\\.com/playwright:v${version.replaceAll('.', '\\.')}-noble$`, 'm'));
assert.match(body, new RegExp(pinnedImage, 'm'));
assert.doesNotMatch(body, /playwright install/, 'Chromium and its system libraries come from the image, not apt');
assert.doesNotMatch(body, /apt-get|actions\/setup-(python|node)@/);
assert.match(body, /python3 -m venv --without-pip /, 'a user-space venv on the image Python');
+2 -2
View File
@@ -10,7 +10,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
const workflow = readFileSync(new URL('../../.github/workflows/viewerjs-check.yml', import.meta.url), 'utf8');
const PINNED_IMAGE = /^\s+image: mcr\.microsoft\.com\/playwright:v\d+\.\d+\.\d+-noble$/m;
const PINNED_IMAGE = /^\s+image: mcr\.microsoft\.com\/playwright:v\d+\.\d+\.\d+-noble@sha256:[0-9a-f]{64}$/m;
function jobs(text) {
const lines = text.split('\n');
@@ -237,7 +237,7 @@ test('every npm ci restores the npm download cache keyed on the vendor lockfile
assert.match(jobSteps[cacheAt], /hashFiles\('tools\/vendor\/package-lock\.json'\)/, `${id} keys the cache on the lockfile`);
assert.match(jobSteps[installAt], /npm ci --prefix tools\/vendor --ignore-scripts/, `${id} still installs without scripts`);
// The key names the job's own pinned image, so an image bump starts a fresh cache.
const image = /^ {6}image: mcr\.microsoft\.com\/playwright:(v\d+\.\d+\.\d+)-noble$/m.exec(body)?.[1];
const image = /^ {6}image: mcr\.microsoft\.com\/playwright:(v\d+\.\d+\.\d+)-noble@sha256:[0-9a-f]{64}$/m.exec(body)?.[1];
assert.ok(image, `${id} runs in the pinned Playwright image`);
assert.match(jobSteps[cacheAt], new RegExp(`key: npm-\\$\\{\\{ runner\\.os \\}\\}-playwright-${image.replaceAll('.', '\\.')}-\\$\\{\\{ hashFiles`),
`${id} keys the cache on its image ${image}`);