Skip to content

Commit ae8cb3c

Browse files
committed
fix(browser): close cancellation and teardown races in holds and downloads
- An already-aborted click presses nothing, and a held click marks its outcome pending before dispatch, so cancelling mid-hold reports an unknown outcome with doNotRetry instead of an error that invites a retry. - The disablePortal exemption resets when a visibility walk crosses from an iframe into its host page, where the host's own aria-hidden applies. - Teardown keeps a download's name reserved until its placeholder claim settles, so a newer download cannot be handed a name the claim then takes. - Downloads are paused before the staging path is set, as on staging, so a pause failure leaves no staging file behind.
1 parent f66da40 commit ae8cb3c

8 files changed

Lines changed: 190 additions & 16 deletions

File tree

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,17 @@ describe('browser-agent CDP instrumentation', () => {
276276
}
277277
})
278278

279+
it('presses nothing when its click was aborted before dispatch', async () => {
280+
const contents = new WebContentsView().webContents
281+
const controller = new AbortController()
282+
controller.abort()
283+
284+
await expect(
285+
clickAt(contents, 5, 6, false, PRIMARY_CLICK, controller.signal)
286+
).rejects.toMatchObject({ name: 'AbortError' })
287+
expect(contents.debugger.sendCommand).not.toHaveBeenCalled()
288+
})
289+
279290
it('releases a held button as soon as its click is aborted', async () => {
280291
const contents = new WebContentsView().webContents
281292
const types = () =>

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -984,9 +984,10 @@ export function clearAgentContextMenu(contents: WebContents): void {
984984
}
985985

986986
/**
987-
* Clicks at viewport coordinates. `signal` ends a press-and-hold early: the click rejects with the
988-
* abort reason and the button is released at once, so a cancelled or timed-out click cannot stay
989-
* held into the next action. That release can still activate the control under the pointer.
987+
* Clicks at viewport coordinates. An already-aborted `signal` rejects before anything is pressed.
988+
* During a press-and-hold it ends the hold early: the click rejects with the abort reason and the
989+
* button is released at once, so a cancelled or timed-out click cannot stay held into the next
990+
* action. That release can still activate the control under the pointer.
990991
*/
991992
export async function clickAt(
992993
contents: WebContents,
@@ -997,6 +998,7 @@ export async function clickAt(
997998
signal?: AbortSignal
998999
): Promise<void> {
9991000
if (moveBeforePress) await moveMouse(contents, x, y)
1001+
signal?.throwIfAborted()
10001002
const { button, clickCount, modifiers, holdMs } = click
10011003
const buttons = BUTTON_MASKS[button]
10021004
let pressed = false

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3603,6 +3603,34 @@ describe('credential protection', () => {
36033603
})
36043604
})
36053605

3606+
it('reports a press-and-hold cancelled mid-hold as an unknown outcome', async () => {
3607+
const contents = await openPage()
3608+
respondWith(contents, {})
3609+
3610+
const pending = driver.executeTool(
3611+
'chat-test',
3612+
'browser_click',
3613+
{ elementId: 0, holdMs: 5_000 },
3614+
'hold-call'
3615+
)
3616+
await vi.waitFor(() =>
3617+
expect(
3618+
cdpCalls(contents, 'Input.dispatchMouseEvent').some(
3619+
([, params]) => toRecord(params).type === 'mousePressed'
3620+
)
3621+
).toBe(true)
3622+
)
3623+
driver.cancelTool('chat-test', 'hold-call')
3624+
3625+
await expect(pending).resolves.toMatchObject({
3626+
ok: true,
3627+
result: { outcomeUnknown: true, doNotRetry: true },
3628+
})
3629+
expect(
3630+
cdpCalls(contents, 'Input.dispatchMouseEvent').map(([, params]) => toRecord(params).type)
3631+
).toContain('mouseReleased')
3632+
})
3633+
36063634
it('rejects batches that name non-action tools or observe per action', async () => {
36073635
await openPage()
36083636

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3223,6 +3223,9 @@ async function executeToolInner(
32233223
try {
32243224
assertCurrentExecution()
32253225
assertElementActionCurrent(contents, elementId, target)
3226+
// A hold keeps the press in flight for seconds; cancelling it mid-gesture must read as
3227+
// an outcome that may have acted, never as a click that did not start.
3228+
if (click.holdMs > 0) onActionOutcome?.({ status: 'pending' })
32263229
await cdp.clickAt(contents, x, y, false, click, signal)
32273230
trusted = true
32283231
activation = 'native-pointer'
@@ -3263,6 +3266,7 @@ async function executeToolInner(
32633266
try {
32643267
assertCurrentExecution()
32653268
assertElementActionCurrent(contents, elementId, target)
3269+
if (click.holdMs > 0) onActionOutcome?.({ status: 'pending' })
32663270
await cdp.clickAt(contents, finalTopPoint.x, finalTopPoint.y, false, click, signal)
32673271
trusted = true
32683272
activation = 'native-pointer'
@@ -4641,6 +4645,7 @@ async function executeToolInner(
46414645
const beforeElement = await activeElementState(contents)
46424646
assertCurrentExecution()
46434647
assertActiveContents(contents, clickNavigationEpoch)
4648+
if (click.holdMs > 0) onActionOutcome?.({ status: 'pending' })
46444649
try {
46454650
await cdp.clickAt(contents, x, y, true, click, signal)
46464651
} catch (error) {

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2570,6 +2570,52 @@ describe('modal hidden together with its own app root', () => {
25702570
expect(outline).not.toContain('Framed compose')
25712571
})
25722572

2573+
it('does not carry a framed modal exemption into the host page', () => {
2574+
document.body.innerHTML = '<div id="app" aria-hidden="true"></div>'
2575+
const frame = visible(document.createElement('iframe'))
2576+
;(document.getElementById('app') as HTMLElement).append(frame)
2577+
const inner = frame.contentDocument as Document
2578+
inner.body.innerHTML = `
2579+
<div id="root" aria-hidden="true">
2580+
<div role="dialog" aria-modal="true" aria-label="Framed"><button>Framed send</button></div>
2581+
</div>`
2582+
for (const element of Array.from(inner.body.querySelectorAll('*'))) visible(element)
2583+
register(inner.querySelector('button') as HTMLButtonElement)
2584+
2585+
expect(runSerialized(clickElement, [0, false])).toMatchObject({ error: 'not-visible' })
2586+
})
2587+
2588+
it('does not scroll a framed modal list whose host frame is hidden', () => {
2589+
document.body.innerHTML = '<div id="app" aria-hidden="true"></div>'
2590+
const frame = visible(document.createElement('iframe'))
2591+
;(document.getElementById('app') as HTMLElement).append(frame)
2592+
const inner = frame.contentDocument as Document
2593+
inner.body.innerHTML = `
2594+
<div id="root" aria-hidden="true">
2595+
<div role="dialog" aria-modal="true" aria-label="Framed">
2596+
<div id="list" style="overflow-y: auto"><div>row</div></div>
2597+
</div>
2598+
</div>`
2599+
for (const element of Array.from(inner.body.querySelectorAll('*'))) visible(element)
2600+
const list = inner.getElementById('list') as HTMLDivElement
2601+
Object.defineProperties(list, {
2602+
clientHeight: { configurable: true, value: 200 },
2603+
scrollHeight: { configurable: true, value: 1_000 },
2604+
scrollTop: { configurable: true, writable: true, value: 0 },
2605+
scrollBy: {
2606+
configurable: true,
2607+
value: ({ top }: ScrollToOptions) => {
2608+
list.scrollTop += top || 0
2609+
},
2610+
},
2611+
})
2612+
register(list.firstElementChild as HTMLDivElement)
2613+
2614+
scrollPage('down', 100, 0)
2615+
2616+
expect(list.scrollTop).toBe(0)
2617+
})
2618+
25732619
it('exposes a disablePortal modal nested inside another open modal', () => {
25742620
document.body.innerHTML = `
25752621
<div id="__next" aria-hidden="true"><main>

‎apps/desktop/src/main/browser-agent/page-functions.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1123,6 +1123,8 @@ export function clickElement(
11231123
if ('host' in root) current = root.host as Element
11241124
else {
11251125
const frame: Element | null = current.ownerDocument.defaultView?.frameElement ?? null
1126+
// The modal exemption belongs to one document; the host page's own aria-hidden applies.
1127+
aboveExemptModal = false
11261128
current = frame ? (frame as Element) : null
11271129
}
11281130
}
@@ -2662,6 +2664,8 @@ export function scrollPage(direction: string, amount?: number, elementId?: numbe
26622664
if ('host' in root) current = root.host as Element
26632665
else {
26642666
const frame: Element | null = current.ownerDocument.defaultView?.frameElement ?? null
2667+
// The modal exemption belongs to one document; the host page's own aria-hidden applies.
2668+
aboveExemptModal = false
26652669
current = frame
26662670
}
26672671
}

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4199,6 +4199,66 @@ describe('browser-agent session', () => {
41994199
expect(download.item.resume).not.toHaveBeenCalled()
42004200
})
42014201

4202+
it('cancels a download it cannot pause before giving it a staging file', () => {
4203+
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
4204+
session = freshSession(win, {}, undefined, {
4205+
getDirectory: () => directory,
4206+
getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
4207+
})
4208+
const contents = (session.ensureTab().view as unknown as MockView).webContents
4209+
const download = mockDownloadItem({ filename: 'unpausable.bin', totalBytes: 100 })
4210+
download.item.pause.mockImplementation(() => {
4211+
throw new Error('pause unavailable')
4212+
})
4213+
4214+
startMockDownload(contents, download)
4215+
4216+
expect(download.item.cancel).toHaveBeenCalledOnce()
4217+
expect(download.item.setSavePath).not.toHaveBeenCalled()
4218+
expect(session.getBrowserDownloadsState('chat-test').downloads[0]).toMatchObject({
4219+
filename: 'unpausable.bin',
4220+
state: 'interrupted',
4221+
})
4222+
})
4223+
4224+
it('keeps a torn-down download name reserved until its pending claim settles', async () => {
4225+
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
4226+
const claims: Array<{ path: string; gate: ReturnType<typeof deferred<void>> }> = []
4227+
session = freshSession(win, {}, undefined, {
4228+
getDirectory: () => directory,
4229+
getFreeDiskBytes: () => Number.MAX_SAFE_INTEGER,
4230+
claimFile: async (path) => {
4231+
const gate = deferred<void>()
4232+
claims.push({ path, gate })
4233+
await gate.promise
4234+
writeFileSync(path, '', { flag: 'wx' })
4235+
},
4236+
})
4237+
const firstContents = session.withBrowserScope(
4238+
'chat-first',
4239+
() => (session.ensureTab().view as unknown as MockView).webContents
4240+
)
4241+
const secondContents = session.withBrowserScope(
4242+
'chat-second',
4243+
() => (session.ensureTab().view as unknown as MockView).webContents
4244+
)
4245+
const first = mockDownloadItem({ filename: 'report.bin', totalBytes: 100 })
4246+
const second = mockDownloadItem({ filename: 'report.bin', totalBytes: 100 })
4247+
4248+
startMockDownload(firstContents, first)
4249+
await vi.waitFor(() => expect(claims).toHaveLength(1))
4250+
session.disposeBrowserScope('chat-first')
4251+
startMockDownload(secondContents, second)
4252+
await vi.waitFor(() => expect(claims).toHaveLength(2))
4253+
4254+
expect(claims[1].path).not.toBe(claims[0].path)
4255+
claims[0].gate.resolve()
4256+
claims[1].gate.resolve()
4257+
await vi.waitFor(() => expect(second.item.resume).toHaveBeenCalledOnce())
4258+
expect(second.item.cancel).not.toHaveBeenCalled()
4259+
await vi.waitFor(() => expect(existsSync(claims[0].path)).toBe(false))
4260+
})
4261+
42024262
it('does not let a cancelled allocation release another download path owner', async () => {
42034263
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
42044264
const firstPathProbe = deferred<boolean>()

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

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ export interface BrowserDownloadSettings {
144144
pathExists?: (path: string) => boolean | Promise<boolean>
145145
/** Overrides the move of a completed staging file to its final name. */
146146
moveFile?: (from: string, to: string) => Promise<void>
147+
/** Overrides the exclusive creation of a download's destination placeholder. */
148+
claimFile?: (path: string) => Promise<void>
147149
}
148150

149151
export interface AgentSessionEvents {
@@ -441,6 +443,11 @@ interface ActiveBrowserDownload {
441443
* program choosing a name sees it taken; the completed file replaces it.
442444
*/
443445
placeholderPath?: string
446+
/**
447+
* Set while the placeholder write is in flight. The name stays reserved in process until it
448+
* settles, so teardown cannot hand the name to a newer download that the write then beats.
449+
*/
450+
claimingDestination?: boolean
444451
/** Settles with the final destination, or null when allocation failed. */
445452
destination: Promise<string | null>
446453
/** Set once Electron reports the item done, so a late disk check never resumes or cancels it. */
@@ -716,7 +723,7 @@ function releaseActiveBrowserDownload(active: ActiveBrowserDownload): void {
716723
if (active.terminal) return
717724
active.terminal = true
718725
activeBrowserDownloads.delete(active)
719-
releaseActiveBrowserDownloadPath(active)
726+
if (!active.claimingDestination) releaseActiveBrowserDownloadPath(active)
720727
// Once Electron reports the item done, the move owns the placeholder until it settles.
721728
if (!active.finished) removeBrowserDownloadPlaceholder(active)
722729
}
@@ -756,12 +763,16 @@ function discardStagedBrowserDownload(active: ActiveBrowserDownload): void {
756763
removeBrowserDownloadPlaceholder(active)
757764
}
758765

766+
function createEmptyFileExclusively(path: string): Promise<void> {
767+
return writeFile(path, '', { flag: 'wx' })
768+
}
769+
759770
/** Claims the allocated destination with an empty file; throws if anything already holds it. */
760771
async function claimBrowserDownloadDestination(
761772
active: ActiveBrowserDownload,
762773
savePath: string
763774
): Promise<void> {
764-
await writeFile(savePath, '', { flag: 'wx' })
775+
await (browserDownloadSettings?.claimFile ?? createEmptyFileExclusively)(savePath)
765776
active.placeholderPath = savePath
766777
}
767778

@@ -1635,23 +1646,24 @@ function configureBrowserDownloads(ses: Session): void {
16351646
withBrowserScope(scopeId, persistBrowserSession)
16361647
logger.warn(message, { error: getErrorMessage(error), filename })
16371648
}
1638-
const stagingPath = join(directory, `.sim-download-${generateShortId()}`)
1649+
// Paused before the staging path is set, so a failure here leaves no file behind.
16391650
try {
1640-
item.setSavePath(stagingPath)
1651+
item.pause()
16411652
} catch (error) {
16421653
failDownloadSetup(
1643-
'Stopped: the download destination could not be prepared safely',
1644-
'Could not set the staging destination for an agent browser download',
1654+
'Stopped: the download could not be paused for a disk-space safety check',
1655+
'Agent browser download could not be paused for admission',
16451656
error
16461657
)
16471658
return
16481659
}
1660+
const stagingPath = join(directory, `.sim-download-${generateShortId()}`)
16491661
try {
1650-
item.pause()
1662+
item.setSavePath(stagingPath)
16511663
} catch (error) {
16521664
failDownloadSetup(
1653-
'Stopped: the download could not be paused for a disk-space safety check',
1654-
'Agent browser download could not be paused for admission',
1665+
'Stopped: the download destination could not be prepared safely',
1666+
'Could not set the staging destination for an agent browser download',
16551667
error
16561668
)
16571669
return
@@ -1768,11 +1780,17 @@ function configureBrowserDownloads(ses: Session): void {
17681780
publishActiveBrowserDownload(active)
17691781
return null
17701782
}
1771-
await claimBrowserDownloadDestination(active, savePath)
1772-
if (active.terminal || !activeBrowserDownloads.has(active)) {
1773-
removeBrowserDownloadPlaceholder(active)
1774-
return null
1783+
active.claimingDestination = true
1784+
try {
1785+
await claimBrowserDownloadDestination(active, savePath)
1786+
} finally {
1787+
active.claimingDestination = false
1788+
if (active.terminal || !activeBrowserDownloads.has(active)) {
1789+
removeBrowserDownloadPlaceholder(active)
1790+
releaseActiveBrowserDownloadPath(active, savePath)
1791+
}
17751792
}
1793+
if (active.terminal || !activeBrowserDownloads.has(active)) return null
17761794
checkBrowserDownloadDiskSpace(active, 'admission')
17771795
return savePath
17781796
})

0 commit comments

Comments
 (0)