Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
"stylelint-config-standard-scss": "^17.0.0",
"typescript": "5.9.3",
"unplugin": "3.3.0",
"unplugin-dts": "^1.0.3",
"unplugin-dts": "^1.1.0",
"uuid": "^14.0.2",
"vite": "7.3.6",
"vite-plugin-vuetify": "^2.1.3",
Expand Down
5 changes: 3 additions & 2 deletions src/components/damImage/uploadQueue/api/damImageApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const imageUploadStart = (
}

export const imageUploadChunk = (
client: (timeout?: number) => AxiosInstance,
client: () => AxiosInstance,
endPoint: string,
item: UploadQueueItem,
imageId: DocId,
Expand Down Expand Up @@ -162,11 +162,12 @@ export const imageUploadChunk = (

formData.append('chunk', chunkData)

client(CHUNK_UPLOAD_TIMEOUT)
client()
.post(url, formData, {
headers: {
'Content-Type': undefined, // Let Axios automatically set multipart/form-data with boundary
},
timeout: CHUNK_UPLOAD_TIMEOUT * 1000,
cancelToken: item.latestChunkCancelToken ? item.latestChunkCancelToken.token : undefined,
onUploadProgress: onUploadProgressCallback,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,16 @@ import type { UploadQueueKey } from '@/types/coreDam/UploadQueue'
import type { AssetSelectReturnData } from '@/types/coreDam/AssetSelect'
import type { DamConfigLicenceExtSystemReturnType } from '@/types/coreDam/DamConfig'
import ImageDetailDialogMetadata from '@/components/damImage/uploadQueue/components/ImageDetailDialogMetadata.vue'
import { computed, inject, onMounted, ref, type ShallowRef, toRaw, watch } from 'vue'
import {
computed,
inject,
onBeforeUnmount,
onMounted,
ref,
type ShallowRef,
toRaw,
watch,
} from 'vue'
import AssetDetailDialog from '@/components/damImage/uploadQueue/components/AssetDetailDialog.vue'
import { useAssetDetailStore } from '@/components/damImage/uploadQueue/composables/assetDetailStore'
import { storeToRefs } from 'pinia'
Expand Down Expand Up @@ -243,6 +252,22 @@ const { cachedExtSystemId } = useExtSystemIdForCached()
// this ref is updating only when the lock was really success or failure using sockets, needed for drop files
const collabFieldLockReallyLocked = ref(false)

// Cancelling must settle the promise, not just clear the timer — `onDrop` awaits it. A Set
// because overlapping drops each have their own wait.
const LOCK_WAIT_CANCELLED = Symbol('lock wait cancelled')
type PendingLockWait = { timer: ReturnType<typeof setTimeout>; reject: (reason: unknown) => void }
const pendingLockWaits = new Set<PendingLockWait>()
let disposed = false

onBeforeUnmount(() => {
disposed = true
pendingLockWaits.forEach((wait) => {
clearTimeout(wait.timer)
wait.reject(LOCK_WAIT_CANCELLED)
})
pendingLockWaits.clear()
})

const waitForFieldLockIsReallyAcquired = async () => {
if (
!collabOptions.value.enabled ||
Expand All @@ -255,13 +280,25 @@ const waitForFieldLockIsReallyAcquired = async () => {
let count = 0

const checkLock: () => Promise<Awaited<boolean>> = () => {
if (disposed) {
return Promise.reject(LOCK_WAIT_CANCELLED)
}
if (collabFieldLockReallyLocked.value) {
return Promise.resolve(true)
}

count++
if (count < 50) {
return new Promise((resolve) => setTimeout(() => resolve(checkLock()), 100))
return new Promise((resolve, reject) => {
const wait: PendingLockWait = {
timer: setTimeout(() => {
pendingLockWaits.delete(wait)
resolve(checkLock())
}, 100),
reject,
}
pendingLockWaits.add(wait)
})
}

return Promise.reject(false)
Expand All @@ -275,10 +312,13 @@ const onDrop = async (files: File[]) => {
const config = imageWidgetUploadConfig.value!
try {
await waitForFieldLockIsReallyAcquired()
// The lock can resolve after the widget is gone; uploading into it is worse than dropping.
if (disposed) return
cachedExtSystemId.value = config.extSystem
uploadQueuesStore.addByFiles(props.queueKey, config.extSystem, config.licence, files)
uploadQueueDialog.value = props.queueKey
} catch (e) {
if (disposed || e === LOCK_WAIT_CANCELLED) return
showError('Unable to lock image widget by current user.')
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,16 @@ import AAssetSelect from '@/components/dam/assetSelect/AAssetSelect.vue'
import type { AssetSelectReturnData } from '@/types/coreDam/AssetSelect'
import type { DamConfigLicenceExtSystemReturnType } from '@/types/coreDam/DamConfig'
import ImageDetailDialogMetadata from '@/components/damImage/uploadQueue/components/ImageDetailDialogMetadata.vue'
import { computed, inject, onMounted, ref, type ShallowRef, toRaw, watch } from 'vue'
import {
computed,
inject,
onBeforeUnmount,
onMounted,
ref,
type ShallowRef,
toRaw,
watch,
} from 'vue'
import AssetDetailDialog from '@/components/damImage/uploadQueue/components/AssetDetailDialog.vue'
import { useAssetDetailStore } from '@/components/damImage/uploadQueue/composables/assetDetailStore'
import { storeToRefs } from 'pinia'
Expand Down Expand Up @@ -235,6 +244,22 @@ const { cachedExtSystemId } = useExtSystemIdForCached()
// this ref is updating only when the lock was really success or failure using sockets, needed for drop files
const collabFieldLockReallyLocked = ref(false)

// Cancelling must settle the promise, not just clear the timer — `onDrop` awaits it. A Set
// because overlapping drops each have their own wait.
const LOCK_WAIT_CANCELLED = Symbol('lock wait cancelled')
type PendingLockWait = { timer: ReturnType<typeof setTimeout>; reject: (reason: unknown) => void }
const pendingLockWaits = new Set<PendingLockWait>()
let disposed = false

onBeforeUnmount(() => {
disposed = true
pendingLockWaits.forEach((wait) => {
clearTimeout(wait.timer)
wait.reject(LOCK_WAIT_CANCELLED)
})
pendingLockWaits.clear()
})

const waitForFieldLockIsReallyAcquired = async () => {
if (
!collabOptions.value.enabled ||
Expand All @@ -247,13 +272,25 @@ const waitForFieldLockIsReallyAcquired = async () => {
let count = 0

const checkLock: () => Promise<Awaited<boolean>> = () => {
if (disposed) {
return Promise.reject(LOCK_WAIT_CANCELLED)
}
if (collabFieldLockReallyLocked.value) {
return Promise.resolve(true)
}

count++
if (count < 50) {
return new Promise((resolve) => setTimeout(() => resolve(checkLock()), 100))
return new Promise((resolve, reject) => {
const wait: PendingLockWait = {
timer: setTimeout(() => {
pendingLockWaits.delete(wait)
resolve(checkLock())
}, 100),
reject,
}
pendingLockWaits.add(wait)
})
}

return Promise.reject(false)
Expand All @@ -267,10 +304,13 @@ const onDrop = async (files: File[]) => {
const config = imageWidgetUploadConfig.value!
try {
await waitForFieldLockIsReallyAcquired()
// The lock can resolve after the widget is gone; uploading into it is worse than dropping.
if (disposed) return
cachedExtSystemId.value = config.extSystem
uploadQueuesStore.addByFiles(props.queueKey, config.extSystem, config.licence, files)
uploadQueueDialog.value = props.queueKey
} catch (e) {
if (disposed || e === LOCK_WAIT_CANCELLED) return
showError('Unable to lock image widget by current user.')
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useWebSocket } from '@vueuse/core'
import { ref } from 'vue'
import { type EffectScope, effectScope, ref } from 'vue'
import { i18n } from '@/plugins/i18n'
import {
type DamNotification,
Expand All @@ -12,23 +12,34 @@ const { t } = i18n.global

const damNotificationsInitialized = ref(false)

export function initDamNotifications() {
type DamNotificationsHandle = ReturnType<typeof createDamNotifications>

let singleton: DamNotificationsHandle | undefined = undefined
let singletonScope: EffectScope | undefined = undefined
let singletonKey: string | undefined = undefined

function createDamNotifications() {
const { notification } = useCommonAdminCoreDamOptionsGlobal()

const enabled = notification.enabled && notification.webSocketUrl.length > 0

const eventBus = useDamNotificationsEventBus()

const { open } = useWebSocket(notification.webSocketUrl, {
let warningTimer: ReturnType<typeof setTimeout> | undefined = undefined
let disposed = false

const { open, close, status } = useWebSocket(notification.webSocketUrl, {
immediate: false,
autoClose: false,
// Plain string url — VueUse's `watch(urlRef, open)` would never fire.
autoConnect: false,
autoReconnect: {
retries: 5,
delay: 5000,
onFailed() {
if (!enabled) return
const { showWarning } = useAlerts()
setTimeout(() => {
warningTimer = setTimeout(() => {
showWarning(t('common.damImage.notificationsNotConnected'))
}, 3000)
},
Expand All @@ -41,21 +52,72 @@ export function initDamNotifications() {
},
})

const closeConnection = () => {
if (warningTimer) {
clearTimeout(warningTimer)
warningTimer = undefined
}
close()
}

const openConnection = () => {
if (disposed) return
damNotificationsInitialized.value = true
if (!enabled) return
// VueUse `open()` closes and re-inits even a live socket.
if (status.value === 'OPEN' || status.value === 'CONNECTING') return
open()
}

const dispose = () => {
disposed = true
closeConnection()
}

return {
openConnection,
closeConnection,
dispose,
damNotificationsInitialized,
status,
}
}

/**
* One DAM notification socket per page. Memoized by `enabled|webSocketUrl`, so a reconfigured
* plugin gets a fresh socket while repeated calls with the same config reuse the existing one.
*/
export function initDamNotifications() {
const { notification } = useCommonAdminCoreDamOptionsGlobal()
const key = `${notification.enabled}|${notification.webSocketUrl}`

if (singleton && singletonKey === key) return singleton
if (singleton) destroyDamNotifications()

singletonScope = effectScope(true)
singleton = singletonScope.run(() => createDamNotifications())!
singletonKey = key

return singleton
}

/**
* Tears the singleton down and invalidates the previous handle. Owned by the app bootstrap —
* tying it to a component unmount would kill the shared connection for everyone else.
*/
export function destroyDamNotifications() {
singleton?.dispose()
singletonScope?.stop()
damNotificationsInitialized.value = false
singleton = undefined
singletonScope = undefined
singletonKey = undefined
}

export function useDamNotifications() {
const eventBus = useDamNotificationsEventBus()

/** Returns the `off` handle; outside a component setup the caller must keep and call it. */
const addDamNotificationListener = (callback: (event: DamNotification) => void) => {
return eventBus.on(callback)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,8 @@ export const useUploadQueuesStore = defineStore('commonUploadQueuesStore', () =>
function removeByIndex(queueKey: UploadQueueKey, index: number) {
const queue = queues.value.get(queueKey)
if (!queue || !queue.items[index]) return
// The fallback reschedules itself for up to 550s, polling fetchAsset and holding the File.
clearTimeout(queue.items[index].notificationFallbackTimer)
queue.items.splice(index, 1)
recalculateQueueCounts(queueKey)
}
Expand Down Expand Up @@ -512,6 +514,7 @@ export const useUploadQueuesStore = defineStore('commonUploadQueuesStore', () =>
function clearQueue(queueKey: UploadQueueKey) {
const queue = queues.value.get(queueKey)
if (!queue) return
queue.items.forEach((item) => clearTimeout(item.notificationFallbackTimer))
queues.value.set(queueKey, {
items: [],
totalCount: 0,
Expand Down
2 changes: 2 additions & 0 deletions src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@ import { damFileTypeFix } from '@/components/file/composables/fileType'
import { useDamAcceptTypeAndSizeHelper } from '@/components/damImage/uploadQueue/composables/acceptTypeAndSizeHelper'
import { useAssetSuggestions } from '@/components/damImage/uploadQueue/composables/assetSuggestions'
import {
destroyDamNotifications,
initDamNotifications,
useDamNotifications,
} from '@/components/damImage/uploadQueue/composables/damNotifications'
Expand Down Expand Up @@ -796,6 +797,7 @@ export {
useDamUploadChunkSize,
useDamAcceptTypeAndSizeHelper,
useAssetSuggestions,
destroyDamNotifications,
initDamNotifications,
useDamNotifications,
useDropzoneGlobalDragState,
Expand Down
40 changes: 40 additions & 0 deletions src/test/composables/damImageApiChunkTimeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest'
import type { AxiosInstance } from 'axios'
import type { UploadQueueItem } from '@/types/coreDam/UploadQueue'
import { imageUploadChunk } from '@/components/damImage/uploadQueue/api/damImageApi'

// imageUploadChunk passed the raw 420 into a factory declared `() => AxiosInstance`, so it was
// dropped and the chunk fell back to the instance default. Pins factory arity and the timeout.

const CHUNK_TIMEOUT_MS = 420 * 1000

const buildClient = () => {
const post = vi.fn().mockResolvedValue({ status: 201, data: { id: 'chunk-1' } })
const factory = vi.fn(() => ({ post }) as unknown as AxiosInstance)
return { factory, post }
}

const item = { latestChunkCancelToken: null } as unknown as UploadQueueItem

const uploadOneChunk = (factory: () => AxiosInstance) =>
imageUploadChunk(factory, '/adm/v1/image', item, 'image-id', new Blob(['chunk-payload']), 13, 0)

describe('imageUploadChunk chunk timeout', () => {
it('calls the client factory without arguments', async () => {
const { factory } = buildClient()

await uploadOneChunk(factory)

expect(factory).toHaveBeenCalledTimes(1)
expect(factory.mock.calls[0]).toHaveLength(0)
})

it('sends the chunk timeout in the per-request config, in milliseconds', async () => {
const { factory, post } = buildClient()

await uploadOneChunk(factory)

expect(post).toHaveBeenCalledTimes(1)
expect(post.mock.calls[0][2]).toMatchObject({ timeout: CHUNK_TIMEOUT_MS })
})
})
Loading
Loading