Merge pull request 'Thumbnail URL fix + archive daemon fix + batched initial loads' (#37) from dev into main
This commit was merged in pull request #37.
This commit is contained in:
@@ -127,17 +127,20 @@ class ArtistDirectoryService:
|
|||||||
ImageRecord.artist_id.label("artist_id"),
|
ImageRecord.artist_id.label("artist_id"),
|
||||||
ImageRecord.sha256.label("sha256"),
|
ImageRecord.sha256.label("sha256"),
|
||||||
ImageRecord.mime.label("mime"),
|
ImageRecord.mime.label("mime"),
|
||||||
|
ImageRecord.thumbnail_path.label("thumbnail_path"),
|
||||||
rn,
|
rn,
|
||||||
)
|
)
|
||||||
.where(ImageRecord.artist_id.in_(artist_ids))
|
.where(ImageRecord.artist_id.in_(artist_ids))
|
||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(sub.c.artist_id, sub.c.sha256, sub.c.mime)
|
select(
|
||||||
|
sub.c.artist_id, sub.c.sha256, sub.c.mime, sub.c.thumbnail_path,
|
||||||
|
)
|
||||||
.where(sub.c.rn <= _PREVIEW_COUNT)
|
.where(sub.c.rn <= _PREVIEW_COUNT)
|
||||||
.order_by(sub.c.artist_id, sub.c.rn)
|
.order_by(sub.c.artist_id, sub.c.rn)
|
||||||
)
|
)
|
||||||
out: dict[int, list[str]] = {}
|
out: dict[int, list[str]] = {}
|
||||||
for aid, sha, mime in (await self.session.execute(stmt)).all():
|
for aid, sha, mime, tp in (await self.session.execute(stmt)).all():
|
||||||
out.setdefault(aid, []).append(thumbnail_url(sha, mime))
|
out.setdefault(aid, []).append(thumbnail_url(tp, sha, mime))
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ class ArtistService:
|
|||||||
"mime": r.mime,
|
"mime": r.mime,
|
||||||
"width": r.width,
|
"width": r.width,
|
||||||
"height": r.height,
|
"height": r.height,
|
||||||
"thumbnail_url": thumbnail_url(r.sha256, r.mime),
|
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -90,9 +90,27 @@ class TimelineBucket:
|
|||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|
||||||
def thumbnail_url(sha256_hex: str, mime: str) -> str:
|
def thumbnail_url(thumbnail_path: str | None, sha256_hex: str, mime: str) -> str:
|
||||||
# Quart serves /images/* via the frontend blueprint (FC-1); thumbnails go
|
"""Return the URL to fetch a thumbnail.
|
||||||
# under /images/thumbs/. The MIME determines the extension.
|
|
||||||
|
Prefers the stored thumbnail_path verbatim — Quart serves /images/*
|
||||||
|
1:1 from the volume (frontend.py:20-36), so the URL IS the disk
|
||||||
|
path. Falls back to deriving from (sha256, mime) only when the
|
||||||
|
record's thumbnail_path is NULL (thumbnailer hasn't run yet); that
|
||||||
|
URL will 404 until backfill catches it, same as before the path
|
||||||
|
was tracked.
|
||||||
|
|
||||||
|
Pre-2026-05-30 this was derived only from (sha256, mime), which
|
||||||
|
disagreed with the actual on-disk extension when the thumbnailer
|
||||||
|
chose its format from transparency rather than MIME — every PNG
|
||||||
|
source without alpha (extension was .jpg on disk) and every WebP
|
||||||
|
source with alpha (extension was .png on disk) silently 404'd
|
||||||
|
despite the thumbnail file existing.
|
||||||
|
"""
|
||||||
|
if thumbnail_path:
|
||||||
|
return thumbnail_path
|
||||||
|
# Fallback for records with no thumbnail recorded yet — preserves
|
||||||
|
# prior behavior (URL exists but 404s until backfill regenerates).
|
||||||
ext = ".png" if mime in ("image/png", "image/gif") else ".jpg"
|
ext = ".png" if mime in ("image/png", "image/gif") else ".jpg"
|
||||||
bucket = sha256_hex[:3]
|
bucket = sha256_hex[:3]
|
||||||
return f"/images/thumbs/{bucket}/{sha256_hex}{ext}"
|
return f"/images/thumbs/{bucket}/{sha256_hex}{ext}"
|
||||||
@@ -198,7 +216,7 @@ class GalleryService:
|
|||||||
created_at=record.created_at,
|
created_at=record.created_at,
|
||||||
effective_date=eff_date,
|
effective_date=eff_date,
|
||||||
posted_at=posted_at,
|
posted_at=posted_at,
|
||||||
thumbnail_url=thumbnail_url(record.sha256, record.mime),
|
thumbnail_url=thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
||||||
artist=artists.get(record.id),
|
artist=artists.get(record.id),
|
||||||
)
|
)
|
||||||
for record, posted_at, eff_date in rows
|
for record, posted_at, eff_date in rows
|
||||||
@@ -306,7 +324,7 @@ class GalleryService:
|
|||||||
"integrity_status": record.integrity_status,
|
"integrity_status": record.integrity_status,
|
||||||
"created_at": record.created_at.isoformat(),
|
"created_at": record.created_at.isoformat(),
|
||||||
"posted_at": posted_at.isoformat() if posted_at else None,
|
"posted_at": posted_at.isoformat() if posted_at else None,
|
||||||
"thumbnail_url": thumbnail_url(record.sha256, record.mime),
|
"thumbnail_url": thumbnail_url(record.thumbnail_path, record.sha256, record.mime),
|
||||||
"image_url": f"/images/{record.path.split('/images/', 1)[-1]}",
|
"image_url": f"/images/{record.path.split('/images/', 1)[-1]}",
|
||||||
"artist": (
|
"artist": (
|
||||||
{"id": artist.id, "name": artist.name, "slug": artist.slug}
|
{"id": artist.id, "name": artist.name, "slug": artist.slug}
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ class PostFeedService:
|
|||||||
ImageRecord.primary_post_id,
|
ImageRecord.primary_post_id,
|
||||||
ImageRecord.sha256,
|
ImageRecord.sha256,
|
||||||
ImageRecord.mime,
|
ImageRecord.mime,
|
||||||
|
ImageRecord.thumbnail_path,
|
||||||
func.row_number().over(
|
func.row_number().over(
|
||||||
partition_by=ImageRecord.primary_post_id,
|
partition_by=ImageRecord.primary_post_id,
|
||||||
order_by=ImageRecord.id.asc(),
|
order_by=ImageRecord.id.asc(),
|
||||||
@@ -220,18 +221,18 @@ class PostFeedService:
|
|||||||
)
|
)
|
||||||
stmt = select(
|
stmt = select(
|
||||||
ranked.c.id, ranked.c.primary_post_id,
|
ranked.c.id, ranked.c.primary_post_id,
|
||||||
ranked.c.sha256, ranked.c.mime, ranked.c.total,
|
ranked.c.sha256, ranked.c.mime, ranked.c.thumbnail_path, ranked.c.total,
|
||||||
)
|
)
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
stmt = stmt.where(ranked.c.rn <= limit)
|
stmt = stmt.where(ranked.c.rn <= limit)
|
||||||
rows = (await self.session.execute(stmt)).all()
|
rows = (await self.session.execute(stmt)).all()
|
||||||
|
|
||||||
out: dict[int, dict] = {pid: {"thumbs": [], "more": 0} for pid in post_ids}
|
out: dict[int, dict] = {pid: {"thumbs": [], "more": 0} for pid in post_ids}
|
||||||
for img_id, pid, sha, mime, total in rows:
|
for img_id, pid, sha, mime, tp, total in rows:
|
||||||
entry = out.setdefault(pid, {"thumbs": [], "more": 0})
|
entry = out.setdefault(pid, {"thumbs": [], "more": 0})
|
||||||
entry["thumbs"].append({
|
entry["thumbs"].append({
|
||||||
"image_id": img_id,
|
"image_id": img_id,
|
||||||
"thumbnail_url": thumbnail_url(sha, mime),
|
"thumbnail_url": thumbnail_url(tp, sha, mime),
|
||||||
"mime": mime,
|
"mime": mime,
|
||||||
})
|
})
|
||||||
# `total` is constant per partition; overflow = total - THUMBNAIL_LIMIT.
|
# `total` is constant per partition; overflow = total - THUMBNAIL_LIMIT.
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class SeriesService:
|
|||||||
ImageRecord.sha256,
|
ImageRecord.sha256,
|
||||||
ImageRecord.mime,
|
ImageRecord.mime,
|
||||||
ImageRecord.path,
|
ImageRecord.path,
|
||||||
|
ImageRecord.thumbnail_path,
|
||||||
)
|
)
|
||||||
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
|
.join(ImageRecord, ImageRecord.id == SeriesPage.image_id)
|
||||||
.where(SeriesPage.series_tag_id == series_tag_id)
|
.where(SeriesPage.series_tag_id == series_tag_id)
|
||||||
@@ -75,7 +76,7 @@ class SeriesService:
|
|||||||
{
|
{
|
||||||
"image_id": r.image_id,
|
"image_id": r.image_id,
|
||||||
"page_number": r.page_number,
|
"page_number": r.page_number,
|
||||||
"thumbnail_url": thumbnail_url(r.sha256, r.mime),
|
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
|
||||||
"image_url": f"/images/{r.path.split('/images/', 1)[-1]}",
|
"image_url": f"/images/{r.path.split('/images/', 1)[-1]}",
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class ShowcaseService:
|
|||||||
"mime": r.mime,
|
"mime": r.mime,
|
||||||
"width": r.width,
|
"width": r.width,
|
||||||
"height": r.height,
|
"height": r.height,
|
||||||
"thumbnail_url": thumbnail_url(r.sha256, r.mime),
|
"thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -115,12 +115,17 @@ class TagDirectoryService:
|
|||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(sub.c.tag_id, ImageRecord.sha256, ImageRecord.mime)
|
select(
|
||||||
|
sub.c.tag_id,
|
||||||
|
ImageRecord.sha256,
|
||||||
|
ImageRecord.mime,
|
||||||
|
ImageRecord.thumbnail_path,
|
||||||
|
)
|
||||||
.join(ImageRecord, ImageRecord.id == sub.c.image_record_id)
|
.join(ImageRecord, ImageRecord.id == sub.c.image_record_id)
|
||||||
.where(sub.c.rn <= 3)
|
.where(sub.c.rn <= 3)
|
||||||
.order_by(sub.c.tag_id, sub.c.rn)
|
.order_by(sub.c.tag_id, sub.c.rn)
|
||||||
)
|
)
|
||||||
out: dict[int, list[str]] = {}
|
out: dict[int, list[str]] = {}
|
||||||
for tag_id, sha, mime in (await self.session.execute(stmt)).all():
|
for tag_id, sha, mime, tp in (await self.session.execute(stmt)).all():
|
||||||
out.setdefault(tag_id, []).append(thumbnail_url(sha, mime))
|
out.setdefault(tag_id, []).append(thumbnail_url(tp, sha, mime))
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Subprocess entrypoint for safe_probe.probe_archive — see safe_probe.py.
|
||||||
|
|
||||||
|
probe_archive spawns this via subprocess (not multiprocessing.Process)
|
||||||
|
because Celery's prefork worker pool runs tasks in DAEMON processes and
|
||||||
|
Python's multiprocessing forbids daemon processes from spawning children
|
||||||
|
("AssertionError: daemonic processes are not allowed to have children",
|
||||||
|
operator-flagged 2026-05-30 — every archive import failed at task
|
||||||
|
startup). subprocess has no such restriction; we still get crash-
|
||||||
|
isolation because a probe segfault/OOM exits non-zero rather than
|
||||||
|
killing the worker.
|
||||||
|
|
||||||
|
Prints a single JSON line on stdout: {"status": "ok"|"error",
|
||||||
|
"detail": "..."?}. Exit code 0 for clean outcomes; non-zero exit
|
||||||
|
(signal / OOM-kill / unhandled exception) is the poison-pill signature
|
||||||
|
the parent maps to ProbeResult(crashed=True).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .safe_probe import _run_probe
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print(json.dumps({"status": "error", "detail": "usage: <path>"}))
|
||||||
|
return 2
|
||||||
|
status, detail = _run_probe(sys.argv[1])
|
||||||
|
print(json.dumps({"status": status, "detail": detail}))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -28,8 +28,8 @@ Operator-requested 2026-05-28 (Layer 3).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import multiprocessing as mp
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -40,6 +40,13 @@ ARCHIVE_PROBE_TIMEOUT_SECONDS = 120
|
|||||||
# art-pack archives while stopping a few-KB zip that expands to TB).
|
# art-pack archives while stopping a few-KB zip that expands to TB).
|
||||||
MAX_ARCHIVE_UNCOMPRESSED_BYTES = 4 * 1024 * 1024 * 1024
|
MAX_ARCHIVE_UNCOMPRESSED_BYTES = 4 * 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
# Repo root for the subprocess cwd so `python -m backend.app.utils.*`
|
||||||
|
# resolves regardless of where Celery / pytest started. backend/app/utils
|
||||||
|
# = parents[0]; backend/app = parents[1]; backend = parents[2]; repo root
|
||||||
|
# = parents[3].
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
_PROBE_RUNNER_MODULE = "backend.app.utils._archive_probe_runner"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ProbeResult:
|
class ProbeResult:
|
||||||
@@ -91,54 +98,68 @@ def probe_video(path: Path, *, timeout: float = VIDEO_PROBE_TIMEOUT_SECONDS) ->
|
|||||||
|
|
||||||
|
|
||||||
def probe_archive(path: Path, *, timeout: float = ARCHIVE_PROBE_TIMEOUT_SECONDS) -> ProbeResult:
|
def probe_archive(path: Path, *, timeout: float = ARCHIVE_PROBE_TIMEOUT_SECONDS) -> ProbeResult:
|
||||||
"""Bomb-size guard + isolated integrity test for an archive."""
|
"""Bomb-size guard + isolated integrity test for an archive.
|
||||||
ctx = mp.get_context("spawn")
|
|
||||||
q = ctx.Queue()
|
Runs via subprocess (not multiprocessing.Process) because Celery's
|
||||||
proc = ctx.Process(target=_archive_probe_target, args=(str(path), q))
|
prefork worker pool is daemon-mode and Python's multiprocessing
|
||||||
proc.start()
|
forbids daemon processes from spawning children ("AssertionError:
|
||||||
proc.join(timeout)
|
daemonic processes are not allowed to have children"). subprocess
|
||||||
if proc.is_alive():
|
has no such restriction and still gives the crash isolation: a probe
|
||||||
proc.terminate()
|
segfault/OOM exits non-zero rather than killing the worker.
|
||||||
proc.join(5)
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", _PROBE_RUNNER_MODULE, str(path)],
|
||||||
|
capture_output=True, text=True, timeout=timeout,
|
||||||
|
cwd=str(_REPO_ROOT),
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
return ProbeResult(ok=False, crashed=True, reason="archive probe timed out")
|
return ProbeResult(ok=False, crashed=True, reason="archive probe timed out")
|
||||||
if proc.exitcode != 0:
|
if result.returncode != 0:
|
||||||
# Negative exitcode = killed by signal (segfault); positive =
|
# Negative = killed by signal (segfault); positive = unhandled
|
||||||
# the child os._exit'd or was OOM-killed. Either way the file
|
# exception or OOM-kill. Either way: poison-pill signature.
|
||||||
# hard-crashed the probe — the poison-pill signature.
|
|
||||||
return ProbeResult(
|
return ProbeResult(
|
||||||
ok=False, crashed=True,
|
ok=False, crashed=True,
|
||||||
reason=f"archive probe crashed (exit {proc.exitcode})",
|
reason=f"archive probe crashed (exit {result.returncode})",
|
||||||
)
|
)
|
||||||
|
last_line = result.stdout.strip().splitlines()[-1:] or [""]
|
||||||
try:
|
try:
|
||||||
outcome = q.get(timeout=5)
|
outcome = json.loads(last_line[0])
|
||||||
except Exception: # noqa: BLE001 — empty queue / broken pipe
|
except json.JSONDecodeError as exc:
|
||||||
return ProbeResult(ok=False, crashed=True, reason="archive probe produced no result")
|
return ProbeResult(
|
||||||
status, detail = outcome
|
ok=False, crashed=True,
|
||||||
if status == "ok":
|
reason=f"archive probe produced no parseable result: {exc}",
|
||||||
|
)
|
||||||
|
if outcome.get("status") == "ok":
|
||||||
return ProbeResult(ok=True)
|
return ProbeResult(ok=True)
|
||||||
return ProbeResult(ok=False, crashed=False, reason=detail)
|
return ProbeResult(
|
||||||
|
ok=False, crashed=False,
|
||||||
|
reason=outcome.get("detail") or "archive probe rejected",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _archive_probe_target(path_str: str, q) -> None:
|
def _run_probe(path_str: str) -> tuple[str, str | None]:
|
||||||
"""Runs in the spawned child. Reads member sizes (bomb guard) then
|
"""Pure-Python body of the archive probe — bomb-guard + integrity test.
|
||||||
runs the format's integrity test. Puts ('ok', None) or
|
|
||||||
('error', reason). A crash/OOM here never reaches the queue — the
|
Returns ('ok', None) or ('error', reason). Caught exceptions become
|
||||||
parent reads the non-zero exit code instead."""
|
clean 'error' rejections; uncaught crashes in the subprocess become
|
||||||
|
non-zero exit codes (poison-pill signature) handled by probe_archive.
|
||||||
|
|
||||||
|
Exposed at the module level so the subprocess runner and tests both
|
||||||
|
call the same code path.
|
||||||
|
"""
|
||||||
path = Path(path_str)
|
path = Path(path_str)
|
||||||
ext = path.suffix.lower()
|
ext = path.suffix.lower()
|
||||||
try:
|
try:
|
||||||
total, test_bad = _inspect_archive(path, ext)
|
total, test_bad = _inspect_archive(path, ext)
|
||||||
except Exception as exc: # noqa: BLE001 — clean rejection
|
except Exception as exc: # noqa: BLE001 — clean rejection
|
||||||
q.put(("error", f"{type(exc).__name__}: {exc}"))
|
return ("error", f"{type(exc).__name__}: {exc}")
|
||||||
return
|
|
||||||
if total is not None and total > MAX_ARCHIVE_UNCOMPRESSED_BYTES:
|
if total is not None and total > MAX_ARCHIVE_UNCOMPRESSED_BYTES:
|
||||||
gib = total / (1024 ** 3)
|
gib = total / (1024 ** 3)
|
||||||
q.put(("error", f"uncompressed size {gib:.1f} GiB exceeds the bomb-guard cap"))
|
return ("error", f"uncompressed size {gib:.1f} GiB exceeds the bomb-guard cap")
|
||||||
return
|
|
||||||
if test_bad is not None:
|
if test_bad is not None:
|
||||||
q.put(("error", f"integrity test failed at member {test_bad!r}"))
|
return ("error", f"integrity test failed at member {test_bad!r}")
|
||||||
return
|
return ("ok", None)
|
||||||
q.put(("ok", None))
|
|
||||||
|
|
||||||
|
|
||||||
def _inspect_archive(path: Path, ext: str):
|
def _inspect_archive(path: Path, ext: str):
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
|
|
||||||
|
// Operator-confirmed 2026-05-30: fetch PAGE-sized chunks instead of one
|
||||||
|
// 50-item request so items render as each batch lands. Total initial
|
||||||
|
// count is unchanged (PAGE * INITIAL_BATCHES = 50). Infinite-scroll also
|
||||||
|
// pulls PAGE per trigger to keep appends progressive.
|
||||||
|
const PAGE = 5
|
||||||
|
const INITIAL_BATCHES = 10
|
||||||
|
|
||||||
export const useGalleryStore = defineStore('gallery', () => {
|
export const useGalleryStore = defineStore('gallery', () => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
@@ -21,8 +28,14 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
images.value = []
|
images.value = []
|
||||||
dateGroups.value = []
|
dateGroups.value = []
|
||||||
nextCursor.value = null
|
nextCursor.value = null
|
||||||
|
// Sequentially fetch INITIAL_BATCHES chunks so items render as each
|
||||||
|
// batch lands rather than blocking on one big response. Stop early
|
||||||
|
// when the backend reports no more pages.
|
||||||
|
for (let i = 0; i < INITIAL_BATCHES; i++) {
|
||||||
|
if (i > 0 && nextCursor.value === null) break
|
||||||
await loadMore()
|
await loadMore()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
@@ -30,7 +43,7 @@ export const useGalleryStore = defineStore('gallery', () => {
|
|||||||
error.value = null
|
error.value = null
|
||||||
const myId = ++inflightId
|
const myId = ++inflightId
|
||||||
try {
|
try {
|
||||||
const params = { limit: 50, ...activeFilterParam() }
|
const params = { limit: PAGE, ...activeFilterParam() }
|
||||||
if (nextCursor.value) params.cursor = nextCursor.value
|
if (nextCursor.value) params.cursor = nextCursor.value
|
||||||
const body = await api.get('/api/gallery/scroll', { params })
|
const body = await api.get('/api/gallery/scroll', { params })
|
||||||
if (myId !== inflightId) return // stale response
|
if (myId !== inflightId) return // stale response
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { ref, computed } from 'vue'
|
|||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||||
|
|
||||||
const PAGE = 60
|
// Operator-confirmed 2026-05-30: instead of one 60-item request, fetch
|
||||||
|
// PAGE-sized chunks sequentially so items render as each batch lands
|
||||||
|
// rather than blocking on the full 60-item response. Total initial count
|
||||||
|
// is unchanged (PAGE * INITIAL_BATCHES = 60). Infinite-scroll also pulls
|
||||||
|
// PAGE items per trigger so subsequent appends stay progressive too.
|
||||||
|
const PAGE = 5
|
||||||
|
const INITIAL_BATCHES = 12
|
||||||
|
|
||||||
export const useShowcaseStore = defineStore('showcase', () => {
|
export const useShowcaseStore = defineStore('showcase', () => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -23,17 +29,27 @@ export const useShowcaseStore = defineStore('showcase', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shuffle() {
|
// Reset state and fetch INITIAL_BATCHES chunks in sequence. Used by
|
||||||
|
// mount, the Shuffle button, and the R-key handler — all want the
|
||||||
|
// same progressive-cascade behavior.
|
||||||
|
async function loadInitial() {
|
||||||
images.value = []
|
images.value = []
|
||||||
seen.clear()
|
seen.clear()
|
||||||
exhausted.value = false
|
exhausted.value = false
|
||||||
|
for (let i = 0; i < INITIAL_BATCHES; i++) {
|
||||||
|
if (exhausted.value) break
|
||||||
await fetchPage()
|
await fetchPage()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shuffle() {
|
||||||
|
await loadInitial()
|
||||||
|
}
|
||||||
|
|
||||||
const hasMore = computed(() => !exhausted.value)
|
const hasMore = computed(() => !exhausted.value)
|
||||||
const isEmpty = computed(
|
const isEmpty = computed(
|
||||||
() => !loading.value && images.value.length === 0 && error.value === null
|
() => !loading.value && images.value.length === 0 && error.value === null
|
||||||
)
|
)
|
||||||
|
|
||||||
return { images, loading, error, hasMore, isEmpty, fetchPage, shuffle }
|
return { images, loading, error, hasMore, isEmpty, fetchPage, shuffle, loadInitial }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ function onKeydown(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (store.images.length === 0) store.fetchPage()
|
if (store.images.length === 0) store.loadInitial()
|
||||||
window.addEventListener('keydown', onKeydown)
|
window.addEventListener('keydown', onKeydown)
|
||||||
})
|
})
|
||||||
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"""Layer-3 subprocess-isolated probe tests.
|
"""Layer-3 subprocess-isolated probe tests.
|
||||||
|
|
||||||
The bomb-guard cap is exercised against `_archive_probe_target` directly
|
The bomb-guard cap is exercised against `_run_probe` directly (in-process,
|
||||||
(in-process, where a monkeypatch on the module constant takes effect) —
|
where a monkeypatch on the module constant takes effect) — the real probe
|
||||||
spawn re-imports the module in the child, so a parent-process
|
runs in a subprocess that re-imports the module, so a parent-process
|
||||||
monkeypatch wouldn't reach the spawned worker.
|
monkeypatch wouldn't reach the spawned interpreter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import multiprocessing as mp
|
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -46,16 +45,14 @@ def test_inspect_archive_reports_size_and_clean_integrity(tmp_path):
|
|||||||
assert bad is None
|
assert bad is None
|
||||||
|
|
||||||
|
|
||||||
def test_archive_probe_target_bomb_guard(tmp_path, monkeypatch):
|
def test_run_probe_bomb_guard(tmp_path, monkeypatch):
|
||||||
"""In-process call to the child target so the monkeypatched cap
|
"""In-process call to _run_probe so the monkeypatched cap takes effect.
|
||||||
takes effect. A normal zip whose uncompressed size exceeds the
|
The public probe_archive runs in a subprocess which re-imports the
|
||||||
(lowered) cap is rejected with the bomb-guard reason."""
|
module and wouldn't see the patched constant."""
|
||||||
monkeypatch.setattr(safe_probe, "MAX_ARCHIVE_UNCOMPRESSED_BYTES", 10)
|
monkeypatch.setattr(safe_probe, "MAX_ARCHIVE_UNCOMPRESSED_BYTES", 10)
|
||||||
z = tmp_path / "bomb.zip"
|
z = tmp_path / "bomb.zip"
|
||||||
_zip(z, {"big.txt": b"x" * 5000}) # 5000 uncompressed > 10-byte cap
|
_zip(z, {"big.txt": b"x" * 5000}) # 5000 uncompressed > 10-byte cap
|
||||||
q = mp.get_context("spawn").Queue()
|
status, detail = safe_probe._run_probe(str(z))
|
||||||
safe_probe._archive_probe_target(str(z), q)
|
|
||||||
status, detail = q.get(timeout=5)
|
|
||||||
assert status == "error"
|
assert status == "error"
|
||||||
assert "bomb-guard cap" in detail
|
assert "bomb-guard cap" in detail
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user