fix(desktop): close eager-upload races flagged in review
Two races in the drop-time eager upload: - Resurrected chip: the success path used addComposerAttachment, which re-appends when the id is gone, so a file removed mid-upload reappeared once the upload resolved. Add updateComposerAttachment (update-only; no-op when the chip was removed) and use it on both the eager success path and submit-time sync. - Duplicate upload: submit-time sync didn't join an eager upload still in flight, so drop-then-Enter could fire file.attach twice and leave a duplicate under .hermes/desktop-attachments/. Track in-flight eager uploads by id and await the pending one before deciding to re-upload, reusing its gateway ref. Tests: composer-store no-resurrect unit tests + a join-on-submit integration test asserting a single file.attach. Addresses @helix4u review on #43109.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
$composerAttachments,
|
||||
addComposerAttachment,
|
||||
type ComposerAttachment,
|
||||
removeComposerAttachment,
|
||||
updateComposerAttachment
|
||||
} from './composer'
|
||||
|
||||
function attachment(overrides: Partial<ComposerAttachment> & Pick<ComposerAttachment, 'id'>): ComposerAttachment {
|
||||
return { kind: 'file', label: 'doc.pdf', ...overrides }
|
||||
}
|
||||
|
||||
describe('updateComposerAttachment', () => {
|
||||
afterEach(() => {
|
||||
$composerAttachments.set([])
|
||||
})
|
||||
|
||||
it('replaces an existing attachment in place', () => {
|
||||
addComposerAttachment(attachment({ id: 'file:a', uploadState: 'uploading' }))
|
||||
|
||||
const updated = updateComposerAttachment(attachment({ id: 'file:a', attachedSessionId: 'sess-1' }))
|
||||
|
||||
expect(updated).toBe(true)
|
||||
const current = $composerAttachments.get()
|
||||
expect(current).toHaveLength(1)
|
||||
expect(current[0]?.attachedSessionId).toBe('sess-1')
|
||||
expect(current[0]?.uploadState).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does NOT resurrect an attachment the user removed mid-upload', () => {
|
||||
// Drop → eager upload starts → user removes the chip → upload resolves.
|
||||
// The late success must not re-add the removed attachment.
|
||||
addComposerAttachment(attachment({ id: 'file:a', uploadState: 'uploading' }))
|
||||
removeComposerAttachment('file:a')
|
||||
|
||||
const updated = updateComposerAttachment(attachment({ id: 'file:a', attachedSessionId: 'sess-1' }))
|
||||
|
||||
expect(updated).toBe(false)
|
||||
expect($composerAttachments.get()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -73,6 +73,25 @@ export function removeComposerAttachment(id: string): ComposerAttachment | null
|
||||
return removed
|
||||
}
|
||||
|
||||
/** Replace an existing attachment in place by id. No-op (returns false) when the
|
||||
* id is gone — e.g. the user removed the chip while an eager upload was still in
|
||||
* flight, so a late success must NOT resurrect it. Use this instead of
|
||||
* addComposerAttachment for async results that may land after a removal. */
|
||||
export function updateComposerAttachment(attachment: ComposerAttachment): boolean {
|
||||
const current = $composerAttachments.get()
|
||||
const index = current.findIndex(item => item.id === attachment.id)
|
||||
|
||||
if (index < 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const next = [...current]
|
||||
next[index] = attachment
|
||||
$composerAttachments.set(next)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function clearComposerAttachments() {
|
||||
$composerAttachments.set([])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user