Skip to content

Commit 891c9a6

Browse files
committed
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.
1 parent 153060e commit 891c9a6

4 files changed

Lines changed: 150 additions & 10 deletions

File tree

apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ import type { MutableRefObject } from 'react'
33
import { useEffect } from 'react'
44
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
import { $sessions, setSessions } from '@/store/session'
7-
import { $connection } from '@/store/session'
86
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
7+
import { $connection, $sessions, setSessions } from '@/store/session'
98
import type { SessionInfo } from '@/types/hermes'
109

1110
import { usePromptActions } from './use-prompt-actions'
@@ -456,6 +455,63 @@ describe('usePromptActions file attachment sync', () => {
456455
})
457456
})
458457

458+
describe('usePromptActions eager-upload races', () => {
459+
beforeEach(() => {
460+
setSessions(() => [sessionInfo()])
461+
$composerAttachments.set([])
462+
})
463+
464+
afterEach(() => {
465+
cleanup()
466+
$composerAttachments.set([])
467+
$connection.set(null)
468+
vi.restoreAllMocks()
469+
})
470+
471+
it('joins an in-flight eager upload at submit instead of staging the file twice', async () => {
472+
// Drop-then-immediately-Enter: the drop kicks off an eager file.attach; if
473+
// submit doesn't join it, both calls stage the file and leave a duplicate
474+
// under .hermes/desktop-attachments/. Submit must await the in-flight upload
475+
// and reuse its gateway-side ref.
476+
$connection.set({ mode: 'remote' } as never)
477+
Object.defineProperty(window, 'hermesDesktop', {
478+
configurable: true,
479+
value: { readFileDataUrl: vi.fn(async () => 'data:application/pdf;base64,JVBERi0=') }
480+
})
481+
482+
let releaseAttach: () => void = () => {}
483+
const methods: string[] = []
484+
const requestGateway = vi.fn(async (method: string) => {
485+
methods.push(method)
486+
if (method === 'file.attach') {
487+
// Block until released so submit runs while the upload is in flight.
488+
await new Promise<void>(resolve => {
489+
releaseAttach = resolve
490+
})
491+
return { attached: true, ref_text: '@file:.hermes/desktop-attachments/doc.pdf', uploaded: true } as never
492+
}
493+
return {} as never
494+
})
495+
496+
let handle: HarnessHandle | null = null
497+
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
498+
await waitFor(() => expect(handle).not.toBeNull())
499+
500+
// Drop a file → the eager effect fires file.attach and blocks on it.
501+
$composerAttachments.set([{ id: 'file:doc.pdf', kind: 'file', label: 'doc.pdf', path: '/Users/me/doc.pdf' }])
502+
await waitFor(() => expect(methods.filter(m => m === 'file.attach').length).toBe(1))
503+
504+
// Submit reads the store, sees the upload in flight, and joins it.
505+
const submitting = handle!.submitText('here you go')
506+
releaseAttach()
507+
508+
expect(await submitting).toBe(true)
509+
// Exactly one file.attach (submit reused the eager result), then the send.
510+
expect(methods.filter(m => m === 'file.attach').length).toBe(1)
511+
expect(methods).toContain('prompt.submit')
512+
})
513+
})
514+
459515
describe('usePromptActions sleep/wake session recovery', () => {
460516
const STORED_SESSION_ID = 'stored-db-xyz789'
461517
const RECOVERED_SESSION_ID = 'rt-recovered-456'

apps/desktop/src/app/session/hooks/use-prompt-actions.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
2525
import { setSessionYolo } from '@/lib/yolo-session'
2626
import {
2727
$composerAttachments,
28-
addComposerAttachment,
2928
clearComposerAttachments,
3029
type ComposerAttachment,
3130
setComposerAttachmentUploadState,
32-
terminalContextBlocksFromDraft
31+
terminalContextBlocksFromDraft,
32+
updateComposerAttachment
3333
} from '@/store/composer'
3434
import { clearNotifications, notify, notifyError } from '@/store/notifications'
3535
import { requestDesktopOnboarding } from '@/store/onboarding'
@@ -310,6 +310,11 @@ export function usePromptActions({
310310
[selectedStoredSessionIdRef, updateSessionState]
311311
)
312312

313+
// In-flight drop-time eager uploads, keyed by attachment id. Submit joins
314+
// these before re-uploading so a drop-then-immediately-Enter can't fire
315+
// file.attach twice and stage duplicate copies on the gateway.
316+
const eagerUploadInFlight = useRef<Map<string, Promise<void>>>(new Map())
317+
313318
const syncAttachmentsForSubmit = useCallback(
314319
async (
315320
sessionId: string,
@@ -320,7 +325,21 @@ export function usePromptActions({
320325
const remote = $connection.get()?.mode === 'remote'
321326
const synced: ComposerAttachment[] = []
322327

323-
for (const attachment of attachments) {
328+
for (const original of attachments) {
329+
let attachment = original
330+
331+
// Join a drop-time eager upload still in flight for this attachment
332+
// before deciding anything — otherwise submit and the eager task both
333+
// call file.attach and stage duplicate files. After it settles, take the
334+
// store's updated copy (its gateway ref, or its failure) over the stale
335+
// pre-upload snapshot.
336+
const inFlight = eagerUploadInFlight.current.get(attachment.id)
337+
338+
if (inFlight) {
339+
await inFlight
340+
attachment = $composerAttachments.get().find(item => item.id === attachment.id) ?? attachment
341+
}
342+
324343
// Already-synced or pathless refs (terminal, url, etc.) pass through.
325344
// A drop-time eager upload may already have staged this one (matching
326345
// attachedSessionId) — don't re-upload it.
@@ -333,8 +352,9 @@ export function usePromptActions({
333352
if (attachment.kind === 'image' || attachment.kind === 'file') {
334353
const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
335354

355+
// Update-only: never resurrect a chip the user removed mid-upload.
336356
if (updateComposerAttachments) {
337-
addComposerAttachment(nextAttachment)
357+
updateComposerAttachment(nextAttachment)
338358
}
339359

340360
synced.push(nextAttachment)
@@ -367,7 +387,9 @@ export function usePromptActions({
367387
setComposerAttachmentUploadState(attachment.id, 'uploading')
368388

369389
try {
370-
addComposerAttachment(await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }))
390+
// Update-only: if the user removed the chip while this was uploading,
391+
// don't resurrect it — just drop the staged result on the floor.
392+
updateComposerAttachment(await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }))
371393
} catch (err) {
372394
// Leave the chip in place so submit-time sync can retry (or the user can
373395
// remove it) and flag the card; also toast so a hard failure (unreadable
@@ -380,7 +402,6 @@ export function usePromptActions({
380402
)
381403

382404
const composerAttachments = useStore($composerAttachments)
383-
const eagerUploadInFlight = useRef<Set<string>>(new Set())
384405

385406
useEffect(() => {
386407
if (!activeSessionId) {
@@ -399,10 +420,11 @@ export function usePromptActions({
399420
continue
400421
}
401422

402-
eagerUploadInFlight.current.add(attachment.id)
403-
void eagerlyUploadAttachment(activeSessionId, attachment).finally(() =>
423+
const task = eagerlyUploadAttachment(activeSessionId, attachment).finally(() =>
404424
eagerUploadInFlight.current.delete(attachment.id)
405425
)
426+
427+
eagerUploadInFlight.current.set(attachment.id, task)
406428
}
407429
}, [activeSessionId, composerAttachments, eagerlyUploadAttachment])
408430

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { afterEach, describe, expect, it } from 'vitest'
2+
3+
import {
4+
$composerAttachments,
5+
addComposerAttachment,
6+
type ComposerAttachment,
7+
removeComposerAttachment,
8+
updateComposerAttachment
9+
} from './composer'
10+
11+
function attachment(overrides: Partial<ComposerAttachment> & Pick<ComposerAttachment, 'id'>): ComposerAttachment {
12+
return { kind: 'file', label: 'doc.pdf', ...overrides }
13+
}
14+
15+
describe('updateComposerAttachment', () => {
16+
afterEach(() => {
17+
$composerAttachments.set([])
18+
})
19+
20+
it('replaces an existing attachment in place', () => {
21+
addComposerAttachment(attachment({ id: 'file:a', uploadState: 'uploading' }))
22+
23+
const updated = updateComposerAttachment(attachment({ id: 'file:a', attachedSessionId: 'sess-1' }))
24+
25+
expect(updated).toBe(true)
26+
const current = $composerAttachments.get()
27+
expect(current).toHaveLength(1)
28+
expect(current[0]?.attachedSessionId).toBe('sess-1')
29+
expect(current[0]?.uploadState).toBeUndefined()
30+
})
31+
32+
it('does NOT resurrect an attachment the user removed mid-upload', () => {
33+
// Drop → eager upload starts → user removes the chip → upload resolves.
34+
// The late success must not re-add the removed attachment.
35+
addComposerAttachment(attachment({ id: 'file:a', uploadState: 'uploading' }))
36+
removeComposerAttachment('file:a')
37+
38+
const updated = updateComposerAttachment(attachment({ id: 'file:a', attachedSessionId: 'sess-1' }))
39+
40+
expect(updated).toBe(false)
41+
expect($composerAttachments.get()).toHaveLength(0)
42+
})
43+
})

apps/desktop/src/store/composer.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,25 @@ export function removeComposerAttachment(id: string): ComposerAttachment | null
7373
return removed
7474
}
7575

76+
/** Replace an existing attachment in place by id. No-op (returns false) when the
77+
* id is gone — e.g. the user removed the chip while an eager upload was still in
78+
* flight, so a late success must NOT resurrect it. Use this instead of
79+
* addComposerAttachment for async results that may land after a removal. */
80+
export function updateComposerAttachment(attachment: ComposerAttachment): boolean {
81+
const current = $composerAttachments.get()
82+
const index = current.findIndex(item => item.id === attachment.id)
83+
84+
if (index < 0) {
85+
return false
86+
}
87+
88+
const next = [...current]
89+
next[index] = attachment
90+
$composerAttachments.set(next)
91+
92+
return true
93+
}
94+
7695
export function clearComposerAttachments() {
7796
$composerAttachments.set([])
7897
}

0 commit comments

Comments
 (0)