Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf2fa3f50e | ||
|
|
2e9322d5c4 | ||
|
|
a1377cec36 | ||
|
|
fa98d0644d |
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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])
|
||||
|
||||
Vendored
+4
-2
@@ -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');
|
||||
|
||||
Vendored
+2
-2
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user