Merge pull request 'audit-g2: async race / state-leak fixes across eight stores' (#50) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 23s
CI / frontend-build (push) Successful in 37s
Build images / build-web (push) Successful in 13s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 8m15s
CI / intcore (push) Successful in 8m50s

This commit was merged in pull request #50.
This commit is contained in:
2026-06-02 14:17:12 -04:00
9 changed files with 234 additions and 30 deletions
@@ -0,0 +1,52 @@
// Inflight-token guard for stores whose async loads can be re-triggered
// by rapid filter/navigation changes. Without this, late responses
// from a prior load overwrite the store with stale data
// (last-writer-wins, not request-order-wins). gallery.js had a
// hand-rolled `inflightId` of the same shape; this composable
// extracts it so every store can use the same pattern.
//
// Audit 2026-06-02 (workflow wf_bbe3fdb1-e62) found this missing in
// modal/suggestions/artist/downloads/directory/posts. The two most
// operator-impacting consequences: (1) modal tag mutations could
// land DELETE/POST on the wrong image when the user navigated mid-
// flight; (2) suggestions accept could push a tag to the wrong
// image AND add it to the allowlist.
//
// Usage:
// const inflight = useInflightToken()
//
// async function load() {
// const t = inflight.claim()
// const body = await api.get(...)
// if (!t.isCurrent()) return // stale — abort write
// items.value = body.items // safe to commit
// }
//
// function setFilter(f) {
// inflight.cancel() // any in-flight token is now stale
// filter.value = f
// load() // claims a fresh token
// }
//
// For multi-await flows (POST then GET, optimistic mutation then
// reconcile), check isCurrent() after EACH await — any intervening
// claim() or cancel() invalidates the prior token.
export function useInflightToken() {
let _seq = 0
let _current = 0
function claim() {
_current = ++_seq
const id = _current
return {
id,
isCurrent: () => _current === id,
}
}
function cancel() {
_current = ++_seq
}
return { claim, cancel }
}
+16 -3
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useInflightToken } from '../composables/useInflightToken.js'
import { usePostsStore } from './posts.js'
const PAGE = 60
@@ -15,12 +16,17 @@ export const useArtistStore = defineStore('artist', () => {
const error = ref(null)
const notFound = ref(false)
let started = false
// Rapid artist-to-artist navigation used to render the previous
// artist's overview/images briefly when the second load resolved
// after the third. Audit 2026-06-02.
const inflight = useInflightToken()
async function load (slug) {
// Cross-artist reset: clear this store AND the posts store so the new
// artist doesn't briefly render with the previous artist's content
// when the user is on the Posts tab. (Gallery tab uses this artist
// store's own images list — cleared above.)
inflight.cancel()
overview.value = null
images.value = []
nextCursor.value = null
@@ -29,14 +35,18 @@ export const useArtistStore = defineStore('artist', () => {
error.value = null
loading.value = true
usePostsStore().$reset?.()
const t = inflight.claim()
try {
overview.value = await api.get(`/api/artist/${encodeURIComponent(slug)}`)
const body = await api.get(`/api/artist/${encodeURIComponent(slug)}`)
if (!t.isCurrent()) return
overview.value = body
await loadMoreImages(slug)
} catch (e) {
if (!t.isCurrent()) return
if (e.status === 404) notFound.value = true
else error.value = e.message
} finally {
loading.value = false
if (t.isCurrent()) loading.value = false
}
}
@@ -44,19 +54,22 @@ export const useArtistStore = defineStore('artist', () => {
if (imagesLoading.value) return
if (started && nextCursor.value === null) return
imagesLoading.value = true
const t = inflight.claim()
try {
const params = { limit: PAGE }
if (nextCursor.value) params.cursor = nextCursor.value
const body = await api.get(
`/api/artist/${encodeURIComponent(slug)}/images`, { params }
)
if (!t.isCurrent()) return
images.value.push(...body.images)
nextCursor.value = body.next_cursor
started = true
} catch (e) {
if (!t.isCurrent()) return
error.value = e.message
} finally {
imagesLoading.value = false
if (t.isCurrent()) imagesLoading.value = false
}
}
+10 -1
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
const PAGE = 60
@@ -13,16 +14,23 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
const q = ref('')
const platform = ref(null)
let started = false
// Typed "alice" then "alice bob" used to drop the second fetch
// entirely (loading flag still true from the first), so the UI
// showed alice results while the input said "alice bob". Inflight
// token + reset() cancelling in-flight requests fixes both: the
// first response is discarded, the second is fetched. Audit 2026-06-02.
const inflight = useInflightToken()
async function loadMore() {
if (loading.value) return
if (started && nextCursor.value === null) return
const t = inflight.claim()
await run(async () => {
const params = { limit: PAGE }
if (q.value) params.q = q.value
if (platform.value) params.platform = platform.value
if (nextCursor.value) params.cursor = nextCursor.value
const body = await api.get('/api/artists/directory', { params })
if (!t.isCurrent()) return
cards.value.push(...body.cards)
nextCursor.value = body.next_cursor
started = true
@@ -30,6 +38,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
}
async function reset() {
inflight.cancel()
cards.value = []
nextCursor.value = null
started = false
+12
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
export const useDownloadsStore = defineStore('downloads', () => {
const api = useApi()
@@ -22,6 +23,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
// the "active now" panel always reflects what's happening regardless of
// how the operator has filtered the historical list below.
const activeEvents = ref([])
// Filter changes (applyFilter) and rapid pagination can interleave
// responses; without an inflight guard the late response from a
// prior filter overwrites the current view. Audit 2026-06-02.
const inflight = useInflightToken()
function _params(extra = {}) {
const out = { limit: 50, ...extra }
@@ -32,8 +37,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
}
async function loadFirst() {
const t = inflight.claim()
await run(async () => {
const body = await api.get('/api/downloads', { params: _params() })
if (!t.isCurrent()) return
events.value = body
cursor.value = body.length ? body[body.length - 1].id : null
hasMore.value = body.length === 50
@@ -42,8 +49,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
async function loadMore() {
if (!hasMore.value || cursor.value == null) return
const t = inflight.claim()
await run(async () => {
const body = await api.get('/api/downloads', { params: _params({ before: cursor.value }) })
if (!t.isCurrent()) return
events.value.push(...body)
cursor.value = body.length ? body[body.length - 1].id : cursor.value
hasMore.value = body.length === 50
@@ -71,6 +80,9 @@ export const useDownloadsStore = defineStore('downloads', () => {
}
async function applyFilter(patch) {
// Drop any in-flight loadFirst/loadMore from the previous filter
// so its late response doesn't overwrite this filter's results.
inflight.cancel()
filter.value = { ...filter.value, ...patch }
await loadFirst()
}
+14 -4
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useInflightToken } from '../composables/useInflightToken.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
@@ -22,9 +23,12 @@ export const useGalleryStore = defineStore('gallery', () => {
const timelineBuckets = ref([])
const timelineLoading = ref(false)
let inflightId = 0
// Was a hand-rolled inflightId counter; the audit-2026-06-02 fan-out
// moved this pattern into useInflightToken so every store can share it.
const inflight = useInflightToken()
async function loadInitial() {
inflight.cancel()
images.value = []
dateGroups.value = []
nextCursor.value = null
@@ -41,19 +45,19 @@ export const useGalleryStore = defineStore('gallery', () => {
if (loading.value) return
loading.value = true
error.value = null
const myId = ++inflightId
const t = inflight.claim()
try {
const params = { limit: PAGE, ...activeFilterParam() }
if (nextCursor.value) params.cursor = nextCursor.value
const body = await api.get('/api/gallery/scroll', { params })
if (myId !== inflightId) return // stale response
if (!t.isCurrent()) return
images.value.push(...body.images)
dateGroups.value = mergeGroups(dateGroups.value, body.date_groups)
nextCursor.value = body.next_cursor
} catch (e) {
error.value = e.message
} finally {
if (myId === inflightId) loading.value = false
if (t.isCurrent()) loading.value = false
}
}
@@ -68,8 +72,14 @@ export const useGalleryStore = defineStore('gallery', () => {
}
async function jumpTo(year, month) {
// Rapid timeline-jump clicks need the same race guard as
// loadMore — first jump's late body could clobber the second
// jump's already-applied state.
inflight.cancel()
const t = inflight.claim()
const params = { year, month, ...activeFilterParam() }
const body = await api.get('/api/gallery/jump', { params })
if (!t.isCurrent()) return
if (body.cursor) {
images.value = []
dateGroups.value = []
+62 -12
View File
@@ -3,6 +3,7 @@ import { toast } from '../utils/toast.js'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
export const useModalStore = defineStore('modal', () => {
const api = useApi()
@@ -10,6 +11,11 @@ export const useModalStore = defineStore('modal', () => {
const currentImageId = ref(null)
const current = ref(null)
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
// Tag mutations interpolate the image id into the URL after an
// await; without an inflight token, a fast prev/next can route the
// DELETE/POST to the wrong image AND the response to the wrong
// chip rail. Audit 2026-06-02.
const inflight = useInflightToken()
// Post-scoped cycle. When set, prev/next cycles within this array
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When
@@ -19,6 +25,9 @@ export const useModalStore = defineStore('modal', () => {
const postImageIndex = ref(0)
async function open (id, opts = {}) {
// Cancel any in-flight tag mutation or reloadTags from the
// previous image so its late response can't apply to this one.
inflight.cancel()
currentImageId.value = id
current.value = null // cleared upfront so it stays null on error
// Update post-scoped state if caller passed it; otherwise clear so
@@ -31,12 +40,16 @@ export const useModalStore = defineStore('modal', () => {
postImageIds.value = null
postImageIndex.value = 0
}
const t = inflight.claim()
await run(async () => {
current.value = await api.get(`/api/gallery/image/${id}`)
const body = await api.get(`/api/gallery/image/${id}`)
if (!t.isCurrent()) return
current.value = body
})
}
async function close () {
inflight.cancel()
currentImageId.value = null
current.value = null
error.value = null
@@ -75,37 +88,74 @@ export const useModalStore = defineStore('modal', () => {
}
async function reloadTags () {
if (!currentImageId.value) return
const tags = await api.get(`/api/images/${currentImageId.value}/tags`)
if (current.value) current.value.tags = tags
// Capture image id at call-time. After an await, currentImageId
// may have advanced via prev/next navigation; without capture, the
// GET would target the new image and write its tags onto a chip
// rail the user didn't open. Audit 2026-06-02.
const imageId = currentImageId.value
if (!imageId) return
const t = inflight.claim()
const tags = await api.get(`/api/images/${imageId}/tags`)
if (!t.isCurrent()) return
// Only commit if the modal is still showing this image — guards
// against close() / nav clearing current between the await and now.
if (current.value && currentImageId.value === imageId) {
current.value.tags = tags
}
}
async function removeTag (tagId) {
if (!currentImageId.value) return
const imageId = currentImageId.value
if (!imageId) return
const prev = current.value.tags
// Optimistic UI: drop the chip immediately.
current.value.tags = current.value.tags.filter(t => t.id !== tagId)
// Split the two POSTs so a dismiss failure (secondary side-effect)
// doesn't roll back the successful DELETE — previously the catch
// unconditionally restored the chip rail even when only the
// dismiss had failed, so the UI lied until refresh. Audit 2026-06-02.
try {
await api.delete(`/api/images/${currentImageId.value}/tags/${tagId}`)
await api.post(`/api/images/${currentImageId.value}/suggestions/dismiss`, {
await api.delete(`/api/images/${imageId}/tags/${tagId}`)
} catch (e) {
// Real failure: roll back, surface, rethrow.
if (current.value && currentImageId.value === imageId) {
current.value.tags = prev
}
toast({ text: `Failed to remove tag: ${e.message}`, type: 'error' })
throw e
}
// DELETE landed. The dismiss is fire-and-best-effort — log on
// failure but DON'T roll back the chip rail; the tag is gone
// server-side regardless.
try {
await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: tagId },
})
} catch (e) {
current.value.tags = prev
toast({ text: `Failed to remove tag: ${e.message}`, type: 'error' })
throw e
toast({
text: `Tag removed, but failed to dismiss suggestion: ${e.message}`,
type: 'warning',
})
}
}
async function addExistingTag (tagId) {
if (!currentImageId.value) return
await api.post(`/api/images/${currentImageId.value}/tags`, {
const imageId = currentImageId.value
if (!imageId) return
await api.post(`/api/images/${imageId}/tags`, {
body: { tag_id: tagId, source: 'manual' },
})
await reloadTags()
}
async function createAndAdd ({ name, kind, fandom_id = null }) {
// Capture imageId so the post-create reloadTags / addExistingTag
// flow stays bound to the image the user clicked on, even if
// they navigated during the /api/tags POST.
const imageId = currentImageId.value
if (!imageId) return
const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } })
if (currentImageId.value !== imageId) return // navigated away
await addExistingTag(tag.id)
}
+23 -3
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
export const usePostsStore = defineStore('posts', () => {
const api = useApi()
@@ -19,6 +20,12 @@ export const usePostsStore = defineStore('posts', () => {
const doneOlder = ref(false)
const doneNewer = ref(false)
const anchorId = ref(null)
// loadInitial, loadMore, loadAround, loadOlder, loadNewer all share
// one `loading` flag and previously had no inflight guard. A filter
// change (loadInitial) racing a still-in-flight loadMore would
// append the prior filter's items into the new filter's feed.
// Audit 2026-06-02.
const inflight = useInflightToken()
function _qs() {
const q = {}
@@ -36,6 +43,7 @@ export const usePostsStore = defineStore('posts', () => {
}
async function loadInitial(newFilters) {
inflight.cancel()
filters.value = {
artist_id: newFilters?.artist_id ?? null,
platform: newFilters?.platform ?? null,
@@ -46,8 +54,10 @@ export const usePostsStore = defineStore('posts', () => {
async function loadMore() {
if (loading.value || done.value) return
const t = inflight.claim()
await run(async () => {
const body = await api.get('/api/posts', { params: _qs() })
if (!t.isCurrent()) return
items.value.push(...body.items)
cursor.value = body.next_cursor
if (body.next_cursor == null) done.value = true
@@ -79,6 +89,7 @@ export const usePostsStore = defineStore('posts', () => {
// posts feed; without this the older/newer scroll loaded unfiltered
// global posts instead of staying in the artist's stream).
async function loadAround(postId, newFilters) {
inflight.cancel()
filters.value = {
artist_id: newFilters?.artist_id ?? null,
platform: newFilters?.platform ?? null,
@@ -86,10 +97,12 @@ export const usePostsStore = defineStore('posts', () => {
loading.value = true
error.value = null
anchorId.value = null
const t = inflight.claim()
try {
const body = await api.get('/api/posts', {
params: _aroundParams({ around: postId }),
})
if (!t.isCurrent()) return
items.value = body.items
cursorOlder.value = body.cursor_older
cursorNewer.value = body.cursor_newer
@@ -97,47 +110,54 @@ export const usePostsStore = defineStore('posts', () => {
doneNewer.value = body.cursor_newer == null
anchorId.value = body.anchor_id
} catch (e) {
if (!t.isCurrent()) return
error.value = e
} finally {
loading.value = false
if (t.isCurrent()) loading.value = false
}
}
async function loadOlder() {
if (loading.value || doneOlder.value || cursorOlder.value == null) return
loading.value = true
const t = inflight.claim()
try {
const body = await api.get('/api/posts', {
params: _aroundParams({
cursor: cursorOlder.value, direction: 'older',
}),
})
if (!t.isCurrent()) return
items.value.push(...body.items)
cursorOlder.value = body.next_cursor
if (body.next_cursor == null) doneOlder.value = true
} catch (e) {
if (!t.isCurrent()) return
error.value = e
} finally {
loading.value = false
if (t.isCurrent()) loading.value = false
}
}
async function loadNewer() {
if (loading.value || doneNewer.value || cursorNewer.value == null) return
loading.value = true
const t = inflight.claim()
try {
const body = await api.get('/api/posts', {
params: _aroundParams({
cursor: cursorNewer.value, direction: 'newer',
}),
})
if (!t.isCurrent()) return
items.value.unshift(...body.items)
cursorNewer.value = body.next_cursor
if (body.next_cursor == null) doneNewer.value = true
} catch (e) {
if (!t.isCurrent()) return
error.value = e
} finally {
loading.value = false
if (t.isCurrent()) loading.value = false
}
}
+37 -6
View File
@@ -3,6 +3,7 @@ import { toast } from '../utils/toast.js'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
// Category display order: people first, general last.
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired — only
@@ -18,12 +19,25 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
const byCategory = ref({}) // { category: [suggestion, ...] }
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
let currentImageId = null
// Audit 2026-06-02: this store had no inflight guard — a late
// /suggestions response from a prior image could overwrite
// byCategory while currentImageId pointed at a new one, and
// accept() dereferenced currentImageId AFTER an awaited POST so
// the subsequent /suggestions/accept could apply A's chosen tag
// to image B (and push it to the allowlist). Both fixed below
// by capturing imageId at call-time and gating writes on the token.
const inflight = useInflightToken()
async function load(imageId) {
// Cancel any in-flight load from the previous image so its late
// response can't overwrite this image's byCategory.
inflight.cancel()
currentImageId = imageId
byCategory.value = {} // cleared upfront so it stays empty on error
const t = inflight.claim()
await run(async () => {
const body = await api.get(`/api/images/${imageId}/suggestions`)
if (!t.isCurrent()) return
byCategory.value = body.by_category || {}
})
}
@@ -35,6 +49,11 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
}
async function accept(suggestion) {
// Capture imageId so a mid-flight prev/next can't reroute the
// accept POST to a different image AND push the tag to that
// image's allowlist.
const imageId = currentImageId
if (imageId == null) return
// Raw tags (creates_new_tag) have no canonical_tag_id; the backend's
// accept endpoint needs a tag_id, so for raw tags we create the tag
// first via the existing /api/tags endpoint, then accept by id.
@@ -45,10 +64,14 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
})
tagId = created.id
}
await api.post(`/api/images/${currentImageId}/suggestions/accept`, {
await api.post(`/api/images/${imageId}/suggestions/accept`, {
body: { tag_id: tagId }
})
_drop(suggestion.category, s => s === suggestion)
// Only drop from THIS image's category list — if the user navigated,
// the new image has its own suggestions and this drop would corrupt them.
if (currentImageId === imageId) {
_drop(suggestion.category, s => s === suggestion)
}
toast({
text: `Tagged: ${suggestion.display_name}`,
type: 'success'
@@ -56,14 +79,18 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
}
async function aliasAccept(suggestion, canonicalTagId) {
await api.post(`/api/images/${currentImageId}/suggestions/alias`, {
const imageId = currentImageId
if (imageId == null) return
await api.post(`/api/images/${imageId}/suggestions/alias`, {
body: {
alias_string: suggestion.display_name,
alias_category: suggestion.category,
canonical_tag_id: canonicalTagId
}
})
_drop(suggestion.category, s => s === suggestion)
if (currentImageId === imageId) {
_drop(suggestion.category, s => s === suggestion)
}
toast({
text: `Aliased & tagged: ${suggestion.display_name}`,
type: 'success'
@@ -71,15 +98,19 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
}
async function dismiss(suggestion) {
const imageId = currentImageId
if (imageId == null) return
// Dismiss needs a tag_id; raw tags have none, so dismissing a raw
// suggestion just hides it client-side (nothing to persist a rejection
// against until the tag exists).
if (suggestion.canonical_tag_id != null) {
await api.post(`/api/images/${currentImageId}/suggestions/dismiss`, {
await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: suggestion.canonical_tag_id }
})
}
_drop(suggestion.category, s => s === suggestion)
if (currentImageId === imageId) {
_drop(suggestion.category, s => s === suggestion)
}
}
return {
+8 -1
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
const PAGE = 60
@@ -13,16 +14,21 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => {
const kind = ref(null)
const q = ref('')
let started = false
// Same shape as artistDirectory — rapid setQuery/setKind dropped
// the second fetch because loading was still true from the first.
// Audit 2026-06-02.
const inflight = useInflightToken()
async function loadMore() {
if (loading.value) return
if (started && nextCursor.value === null) return
const t = inflight.claim()
await run(async () => {
const params = { limit: PAGE }
if (kind.value) params.kind = kind.value
if (q.value) params.q = q.value
if (nextCursor.value) params.cursor = nextCursor.value
const body = await api.get('/api/tags/directory', { params })
if (!t.isCurrent()) return
cards.value.push(...body.cards)
nextCursor.value = body.next_cursor
started = true
@@ -30,6 +36,7 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => {
}
async function reset() {
inflight.cancel()
cards.value = []
nextCursor.value = null
started = false