Skip to content

Commit f66da40

Browse files
committed
fix(browser): claim agent download names on disk while bytes stage
The allocated destination gets an empty placeholder created with O_EXCL, as Firefox does, so another program picking a name sees it taken and the final rename only ever replaces Sim's own placeholder. A name that something else grabbed first stops the download instead of being overwritten. Teardown and failures remove the placeholder once, so a late-settling download cannot delete a name a newer download has claimed.
1 parent 67e1326 commit f66da40

2 files changed

Lines changed: 91 additions & 11 deletions

File tree

‎apps/desktop/src/main/browser-agent/session.test.ts‎

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, writeFileSync } from 'node:fs'
1+
import {
2+
existsSync,
3+
mkdtempSync,
4+
readdirSync,
5+
readFileSync,
6+
renameSync,
7+
writeFileSync,
8+
} from 'node:fs'
29
import { tmpdir } from 'node:os'
310
import { basename, dirname, join } from 'node:path'
411
import type { MenuItemConstructorOptions, WebContents } from 'electron'
@@ -3570,19 +3577,41 @@ describe('browser-agent session', () => {
35703577
}
35713578
})
35723579

3580+
it('never overwrites a file that takes the allocated name before the download claims it', async () => {
3581+
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
3582+
writeFileSync(join(directory, 'taken.bin'), 'user data')
3583+
session = freshSession(win, {}, undefined, {
3584+
getDirectory: () => directory,
3585+
getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
3586+
pathExists: () => false,
3587+
})
3588+
const contents = (session.ensureTab().view as unknown as MockView).webContents
3589+
const download = mockDownloadItem({ filename: 'taken.bin', totalBytes: 100 })
3590+
3591+
startMockDownload(contents, download)
3592+
3593+
await vi.waitFor(() => expect(download.item.cancel).toHaveBeenCalledOnce())
3594+
expect(download.item.resume).not.toHaveBeenCalled()
3595+
expect(readFileSync(join(directory, 'taken.bin'), 'utf8')).toBe('user data')
3596+
})
3597+
35733598
it('interrupts a completed download whose staging file cannot be moved into place', async () => {
35743599
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
3600+
const moveFile = vi.fn(() =>
3601+
Promise.reject(Object.assign(new Error('cross-device link'), { code: 'EXDEV' }))
3602+
)
35753603
session = freshSession(win, {}, undefined, {
35763604
getDirectory: () => directory,
35773605
getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
3606+
moveFile,
35783607
})
35793608
const contents = (session.ensureTab().view as unknown as MockView).webContents
35803609
const download = mockDownloadItem({ filename: 'blocked.bin', totalBytes: 100 })
35813610

35823611
startMockDownload(contents, download)
35833612
const stagingPath = expectOnlyStagingSavePath(download.item, directory)
35843613
await vi.waitFor(() => expect(download.item.resume).toHaveBeenCalledOnce())
3585-
mkdirSync(join(directory, 'blocked.bin'))
3614+
expect(readFileSync(join(directory, 'blocked.bin'), 'utf8')).toBe('')
35863615
download.emitDone('completed')
35873616

35883617
await vi.waitFor(() =>
@@ -3591,7 +3620,9 @@ describe('browser-agent session', () => {
35913620
state: 'interrupted',
35923621
})
35933622
)
3594-
await vi.waitFor(() => expect(existsSync(stagingPath)).toBe(false))
3623+
await vi.waitFor(() => expect(readdirSync(directory)).toEqual([]))
3624+
expect(existsSync(stagingPath)).toBe(false)
3625+
expect(moveFile).toHaveBeenCalledOnce()
35953626
const { id } = session.getBrowserDownloadsState('chat-test').downloads[0]
35963627
expect(session.completedBrowserDownload('chat-test', id)).toBeNull()
35973628

@@ -3970,6 +4001,7 @@ describe('browser-agent session', () => {
39704001
})
39714002

39724003
download.emitDone('cancelled')
4004+
await vi.waitFor(() => expect(readdirSync(directory)).toEqual([]))
39734005
const replacement = mockDownloadItem({ filename: 'stream.bin', totalBytes: 100 })
39744006
startMockDownload(contents, replacement)
39754007
expect(replacement.item.cancel).not.toHaveBeenCalled()
@@ -4118,6 +4150,7 @@ describe('browser-agent session', () => {
41184150
expect(second.item.resume).not.toHaveBeenCalled()
41194151
expect(session.getBrowserDownloadsState('chat-test').downloads).toEqual([])
41204152
expect(snapshots.get('chat-test')?.downloads).toEqual([])
4153+
await vi.waitFor(() => expect(finishedDownloadFiles(directory)).toEqual([]))
41214154

41224155
const nextContents = (session.ensureTab().view as unknown as MockView).webContents
41234156
const replacement = mockDownloadItem({ filename: 'same-name.bin', totalBytes: 100 })
@@ -4131,14 +4164,18 @@ describe('browser-agent session', () => {
41314164
startMockDownload(nextContents, concurrent)
41324165
await vi.waitFor(() => expect(concurrent.item.resume).toHaveBeenCalledOnce())
41334166
concurrent.emitDone('completed')
4167+
// The torn-down downloads settling late must not remove the replacement's claimed name.
41344168
await vi.waitFor(() =>
41354169
expect(finishedDownloadFiles(directory)).toEqual([
41364170
expect.stringMatching(/^same-name \(.+\)\.bin$/),
4171+
'same-name.bin',
41374172
])
41384173
)
4174+
expect(readFileSync(join(directory, 'same-name.bin'), 'utf8')).toBe('')
41394175
replacement.emitDone('completed')
4140-
await vi.waitFor(() => expect(finishedDownloadFiles(directory)).toHaveLength(2))
4141-
expect(finishedDownloadFiles(directory)).toContain('same-name.bin')
4176+
await vi.waitFor(() =>
4177+
expect(readFileSync(join(directory, 'same-name.bin'), 'utf8')).toBe('same-name.bin')
4178+
)
41424179
await vi.waitFor(() =>
41434180
expect(readdirSync(directory).filter((name) => name.startsWith('.'))).toEqual([])
41444181
)
@@ -4208,10 +4245,13 @@ describe('browser-agent session', () => {
42084245
await vi.waitFor(() =>
42094246
expect(finishedDownloadFiles(directory)).toEqual([
42104247
expect.stringMatching(/^shared \(.+\)\.bin$/),
4248+
'shared.bin',
42114249
])
42124250
)
42134251
second.emitDone('completed')
4214-
await vi.waitFor(() => expect(finishedDownloadFiles(directory)).toContain('shared.bin'))
4252+
await vi.waitFor(() =>
4253+
expect(readFileSync(join(directory, 'shared.bin'), 'utf8')).toBe('shared.bin')
4254+
)
42154255
})
42164256

42174257
it('cancels only the disposed scope and ignores its late download callbacks', async () => {
@@ -4334,7 +4374,9 @@ describe('browser-agent session', () => {
43344374
expect(rejected.item.cancel).toHaveBeenCalledOnce()
43354375

43364376
first.emitDone('completed')
4337-
await vi.waitFor(() => expect(finishedDownloadFiles(directory)).toEqual(['first.txt']))
4377+
await vi.waitFor(() =>
4378+
expect(readFileSync(join(directory, 'first.txt'), 'utf8')).toBe('first.txt')
4379+
)
43384380
const replacement = mockDownloadItem({ filename: 'fourth.txt', totalBytes: 100 })
43394381
startMockDownload(contents, replacement)
43404382
expect(replacement.item.cancel).not.toHaveBeenCalled()

‎apps/desktop/src/main/browser-agent/session.ts‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AsyncLocalStorage } from 'node:async_hooks'
22
import { existsSync } from 'node:fs'
3-
import { rename, rm, statfs } from 'node:fs/promises'
3+
import { rename, rm, statfs, writeFile } from 'node:fs/promises'
44
import { join } from 'node:path'
55
import type {
66
BrowserDataKind,
@@ -436,6 +436,11 @@ interface ActiveBrowserDownload {
436436
stagingPath: string
437437
/** The reserved final destination, once allocation has chosen one. */
438438
savePath?: string
439+
/**
440+
* The empty file claiming `savePath` on disk while bytes stage, as Firefox does, so another
441+
* program choosing a name sees it taken; the completed file replaces it.
442+
*/
443+
placeholderPath?: string
439444
/** Settles with the final destination, or null when allocation failed. */
440445
destination: Promise<string | null>
441446
/** Set once Electron reports the item done, so a late disk check never resumes or cancels it. */
@@ -712,6 +717,8 @@ function releaseActiveBrowserDownload(active: ActiveBrowserDownload): void {
712717
active.terminal = true
713718
activeBrowserDownloads.delete(active)
714719
releaseActiveBrowserDownloadPath(active)
720+
// Once Electron reports the item done, the move owns the placeholder until it settles.
721+
if (!active.finished) removeBrowserDownloadPlaceholder(active)
715722
}
716723

717724
function releaseActiveBrowserDownloadPath(
@@ -723,15 +730,41 @@ function releaseActiveBrowserDownloadPath(
723730
}
724731
}
725732

726-
function discardStagedBrowserDownload(active: ActiveBrowserDownload): void {
727-
void rm(active.stagingPath, { force: true }).catch((error) => {
733+
function removeBrowserDownloadFile(active: ActiveBrowserDownload, path: string): void {
734+
void rm(path, { force: true }).catch((error) => {
728735
logger.warn('Could not remove a staged agent browser download', {
729736
error: getErrorMessage(error),
730737
filename: active.download.filename,
731738
})
732739
})
733740
}
734741

742+
/**
743+
* Removes the destination placeholder at most once: after that the name is free, and a later
744+
* download may already have claimed it.
745+
*/
746+
function removeBrowserDownloadPlaceholder(active: ActiveBrowserDownload): void {
747+
const { placeholderPath } = active
748+
if (!placeholderPath) return
749+
active.placeholderPath = undefined
750+
removeBrowserDownloadFile(active, placeholderPath)
751+
}
752+
753+
/** Removes a failed download's staging file and its destination placeholder. */
754+
function discardStagedBrowserDownload(active: ActiveBrowserDownload): void {
755+
removeBrowserDownloadFile(active, active.stagingPath)
756+
removeBrowserDownloadPlaceholder(active)
757+
}
758+
759+
/** Claims the allocated destination with an empty file; throws if anything already holds it. */
760+
async function claimBrowserDownloadDestination(
761+
active: ActiveBrowserDownload,
762+
savePath: string
763+
): Promise<void> {
764+
await writeFile(savePath, '', { flag: 'wx' })
765+
active.placeholderPath = savePath
766+
}
767+
735768
/**
736769
* Errors a just-written file raises while antivirus or indexing briefly holds it open (Windows);
737770
* Chromium retries its own final download rename on these too.
@@ -1722,7 +1755,7 @@ function configureBrowserDownloads(ses: Session): void {
17221755
allocationExpired = true
17231756
}
17241757
)
1725-
.then((savePath) => {
1758+
.then(async (savePath) => {
17261759
if (active.terminal || !activeBrowserDownloads.has(active)) {
17271760
releaseActiveBrowserDownloadPath(active, savePath ?? undefined)
17281761
return null
@@ -1735,6 +1768,11 @@ function configureBrowserDownloads(ses: Session): void {
17351768
publishActiveBrowserDownload(active)
17361769
return null
17371770
}
1771+
await claimBrowserDownloadDestination(active, savePath)
1772+
if (active.terminal || !activeBrowserDownloads.has(active)) {
1773+
removeBrowserDownloadPlaceholder(active)
1774+
return null
1775+
}
17381776
checkBrowserDownloadDiskSpace(active, 'admission')
17391777
return savePath
17401778
})

0 commit comments

Comments
 (0)