fix(host,client): abort superseded listings on the wire; keep the native swap resolvable

Supersession (newer navigation, path editing, closing, unmount) now
aborts the in-flight listing's request instead of only discarding its
result: the browser mints an AbortController per listing, the signal
rides the workspace face (IWorkspaces.listDirectory gains an optional
signal) onto the fetch carrier, and the Host scan stops with it (817's
cancellation chain). apps/cli keeps both picker packages as dependencies
so the documented one-row cordis.yml swap to the native backend resolves
at boot.
This commit is contained in:
creatixchu
2026-07-29 06:19:18 +08:00
parent 463893947b
commit 53240f4664
9 changed files with 77 additions and 28 deletions

View File

@@ -49,6 +49,7 @@
"@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^",

View File

@@ -40,9 +40,10 @@ export interface IWorkspaces {
/** /**
* List one directory level through the Host's `browse` capability. * List one directory level through the Host's `browse` capability.
* @param path - absolute directory to list; absent lists the Host home directory. * @param path - absolute directory to list; absent lists the Host home directory.
* @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
* @returns the level's listing with breadcrumb ancestry. * @returns the level's listing with breadcrumb ancestry.
*/ */
listDirectory(path?: string): Promise<DirectoryListing> listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing>
/** /**
* Create one child directory through the Host's `browse` capability. * Create one child directory through the Host's `browse` capability.
* @param path - absolute existing parent directory. * @param path - absolute existing parent directory.

View File

@@ -195,10 +195,11 @@ export class WorkspacesService implements IWorkspaces {
/** /**
* List one directory level through the Host's `browse` capability. * List one directory level through the Host's `browse` capability.
* @param path - absolute directory to list; absent lists the Host home directory. * @param path - absolute directory to list; absent lists the Host home directory.
* @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
* @returns the level's listing with breadcrumb ancestry. * @returns the level's listing with breadcrumb ancestry.
*/ */
async listDirectory(path?: string): Promise<DirectoryListing> { async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
const response = await this.api.host.listDirectory(path === undefined ? {} : { path }) const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal)
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
return response.result.value return response.result.value
} }

View File

@@ -115,7 +115,7 @@ export class TestWorkspaces implements IWorkspaces {
* @param path - absolute directory to list; absent lists the home level. * @param path - absolute directory to list; absent lists the home level.
* @returns the level's listing. * @returns the level's listing.
*/ */
async listDirectory(path?: string): Promise<DirectoryListing> { async listDirectory(path?: string, _signal?: AbortSignal): Promise<DirectoryListing> {
this.calls.push({ method: 'listDirectory', args: [path] }) this.calls.push({ method: 'listDirectory', args: [path] })
const stub = this.stubs.get('listDirectory') const stub = this.stubs.get('listDirectory')
if (stub !== undefined) return await (stub(path) as Promise<DirectoryListing>) if (stub !== undefined) return await (stub(path) as Promise<DirectoryListing>)

View File

@@ -27,8 +27,8 @@ import css from './DirectoryBrowser.module.css'
export interface DirectoryBrowserProps { export interface DirectoryBrowserProps {
/** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */ /** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */
open: boolean open: boolean
/** List one directory level (absent path = the Host home directory). */ /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan on the wire. */
listDirectory: (path?: string) => Promise<DirectoryListing> listDirectory: (path?: string, signal?: AbortSignal) => Promise<DirectoryListing>
/** Create one child directory under an existing parent. */ /** Create one child directory under an existing parent. */
createDirectory: (path: string, name: string) => Promise<string> createDirectory: (path: string, name: string) => Promise<string>
/** The operator confirmed a directory (the selection, else the listed level). */ /** The operator confirmed a directory (the selection, else the listed level). */
@@ -115,6 +115,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const [creatingFolder, setCreatingFolder] = useState(false) const [creatingFolder, setCreatingFolder] = useState(false)
const [createError, setCreateError] = useState<string | null>(null) const [createError, setCreateError] = useState<string | null>(null)
const requestSeq = useRef(0) const requestSeq = useRef(0)
// The in-flight listing's controller: superseding intent aborts the wire
// request too — the Host stops scanning — instead of only discarding the
// eventual result while the scan keeps consuming host resources.
const scanController = useRef<AbortController | null>(null)
// Bumped on every open/close edge: settlements from a previous open (a // Bumped on every open/close edge: settlements from a previous open (a
// pending creation included) must never mutate a reopened dialog. // pending creation included) must never mutate a reopened dialog.
const openGeneration = useRef(0) const openGeneration = useRef(0)
@@ -130,18 +134,34 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
useEffect(() => () => { useEffect(() => () => {
requestSeq.current += 1 requestSeq.current += 1
openGeneration.current += 1 openGeneration.current += 1
scanController.current?.abort()
}, []) }, [])
const compositionGuard = { const compositionGuard = {
onCompositionStart: () => { composingRef.current = true }, onCompositionStart: () => { composingRef.current = true },
onCompositionEnd: () => { composingRef.current = false }, onCompositionEnd: () => { composingRef.current = false },
} }
/** Newer intent wins: invalidate the pending listing's settlement AND abort its wire request. */
const supersede = useCallback((): number => {
scanController.current?.abort()
scanController.current = null
return ++requestSeq.current
}, [])
/** Launch one listing under a fresh controller so a later supersession can abort it. */
const launchListing = useCallback((path: string | undefined): { seq: number; scan: Promise<DirectoryListing> } => {
const seq = supersede()
const controller = new AbortController()
scanController.current = controller
return { seq, scan: listDirectory(path, controller.signal) }
}, [supersede, listDirectory])
/** Replace the whole view with one freshly listed level (no selection). */ /** Replace the whole view with one freshly listed level (no selection). */
const navigate = useCallback((path?: string) => { const navigate = useCallback((path?: string) => {
const seq = ++requestSeq.current const { seq, scan } = launchListing(path)
setLoading(true) setLoading(true)
setError(null) setError(null)
listDirectory(path).then((next) => { scan.then((next) => {
if (seq !== requestSeq.current) return if (seq !== requestSeq.current) return
setParent(next) setParent(next)
setSelected(null) setSelected(null)
@@ -153,16 +173,16 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
setLoading(false) setLoading(false)
setError(failureText(reason)) setError(failureText(reason))
}) })
}, [listDirectory]) }, [launchListing])
/** Select a row of the listed level and preview its children on the right. */ /** Select a row of the listed level and preview its children on the right. */
const select = useCallback((entry: DirectoryEntry) => { const select = useCallback((entry: DirectoryEntry) => {
const seq = ++requestSeq.current const { seq, scan } = launchListing(entry.path)
setSelected(entry) setSelected(entry)
setChild(null) setChild(null)
setLoading(true) setLoading(true)
setError(null) setError(null)
listDirectory(entry.path).then((next) => { scan.then((next) => {
if (seq !== requestSeq.current) return if (seq !== requestSeq.current) return
setChild(next) setChild(next)
setLoading(false) setLoading(false)
@@ -174,7 +194,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// breadcrumb still names the level: fall back to the single pane. // breadcrumb still names the level: fall back to the single pane.
setSelected(null) setSelected(null)
}) })
}, [listDirectory]) }, [launchListing])
/** A right-column pick advances the view one level: child becomes the level. */ /** A right-column pick advances the view one level: child becomes the level. */
const advance = useCallback((entry: DirectoryEntry) => { const advance = useCallback((entry: DirectoryEntry) => {
@@ -196,12 +216,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
navigate() navigate()
return return
} }
requestSeq.current += 1 supersede()
setError(null) setError(null)
setPathDraft(null) setPathDraft(null)
setFolderDraft(null) setFolderDraft(null)
setCreateError(null) setCreateError(null)
}, [open, navigate]) }, [open, navigate, supersede])
/** The folder a create or Open acts on: the selection, else the listed level. */ /** The folder a create or Open acts on: the selection, else the listed level. */
const targetPath = selected?.path ?? parent?.path ?? null const targetPath = selected?.path ?? parent?.path ?? null
@@ -227,9 +247,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
setFolderDraft(null) setFolderDraft(null)
// Land like a right-column pick (figma 802:57446 → 813:23278 flow): the // Land like a right-column pick (figma 802:57446 → 813:23278 flow): the
// create target becomes the listed level and the new folder its selection. // create target becomes the listed level and the new folder its selection.
const seq = ++requestSeq.current const { seq, scan } = launchListing(targetPath)
setLoading(true) setLoading(true)
listDirectory(targetPath).then((level) => { scan.then((level) => {
/* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */
if (seq !== requestSeq.current) return if (seq !== requestSeq.current) return
setParent(level) setParent(level)
@@ -324,7 +344,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// Opening the editor supersedes any pending listing: a // Opening the editor supersedes any pending listing: a
// settlement landing before the first keystroke would // settlement landing before the first keystroke would
// otherwise close the editor via navigate's draft reset. // otherwise close the editor via navigate's draft reset.
requestSeq.current += 1 supersede()
setLoading(false) setLoading(false)
setPathDraft(selected?.path ?? parent?.path ?? '') setPathDraft(selected?.path ?? parent?.path ?? '')
}} }}
@@ -342,7 +362,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// Editing the draft supersedes any in-flight navigation: // Editing the draft supersedes any in-flight navigation:
// its completion must neither clear the newer text nor // its completion must neither clear the newer text nor
// repopulate the view with the older path. // repopulate the view with the older path.
requestSeq.current += 1 supersede()
setLoading(false) setLoading(false)
setPathDraft(event.target.value) setPathDraft(event.target.value)
}} }}
@@ -361,7 +381,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// launched: its late success must not jump to the // launched: its late success must not jump to the
// cancelled path, so the pending request is superseded // cancelled path, so the pending request is superseded
// and the view leaves the loading state. // and the view leaves the loading state.
requestSeq.current += 1 supersede()
setLoading(false) setLoading(false)
setPathDraft(null) setPathDraft(null)
setError(null) setError(null)

View File

@@ -13,8 +13,8 @@ import { DirectoryBrowser } from './DirectoryBrowser.tsx'
/** Injected face: the browse wire calls and copy the dialog drives (bound in apply's closure). */ /** Injected face: the browse wire calls and copy the dialog drives (bound in apply's closure). */
export interface BrowseFlowInjected { export interface BrowseFlowInjected {
/** List one directory level (absent path = the Host home directory). */ /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan. */
listDirectory: (path?: string) => Promise<DirectoryListing> listDirectory: (path?: string, signal?: AbortSignal) => Promise<DirectoryListing>
/** Create one child directory under an existing parent. */ /** Create one child directory under an existing parent. */
createDirectory: (path: string, name: string) => Promise<string> createDirectory: (path: string, name: string) => Promise<string>
/** Localized dialog copy (this package's namespace). */ /** Localized dialog copy (this package's namespace). */

View File

@@ -72,7 +72,7 @@ export function apply(ctx: ClientContext): void {
}, 'directory-picker-browse: dialog dictionaries') }, 'directory-picker-browse: dialog dictionaries')
const injected = (): BrowseFlowInjected => ({ const injected = (): BrowseFlowInjected => ({
listDirectory: path => ctx.workspaces.listDirectory(path), listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name), createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name),
t: ctx.locale.bind(LOCALE_NS), t: ctx.locale.bind(LOCALE_NS),
}) })

View File

@@ -95,7 +95,7 @@ describe('DirectoryBrowser', () => {
it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => { it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => {
const b = mount() const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
expect(b.listDirectory).toHaveBeenCalledWith(undefined) expect(b.listDirectory).toHaveBeenCalledWith(undefined, expect.any(AbortSignal))
expect(columns()).toHaveLength(1) expect(columns()).toHaveLength(1)
expect(screen.getByRole('listitem').textContent).toBe('Documents') expect(screen.getByRole('listitem').textContent).toBe('Documents')
expect(screen.queryByText('.config')).toBeNull() expect(screen.queryByText('.config')).toBeNull()
@@ -113,7 +113,7 @@ describe('DirectoryBrowser', () => {
expect(selectedRow.textContent).toBe('Documents') expect(selectedRow.textContent).toBe('Documents')
expect(rowButton(selectedRow).getAttribute('aria-current')).toBe('true') expect(rowButton(selectedRow).getAttribute('aria-current')).toBe('true')
expect(within(preview!).getByRole('listitem').textContent).toBe('harness') expect(within(preview!).getByRole('listitem').textContent).toBe('harness')
expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS) expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS, expect.any(AbortSignal))
expect(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' })).toBeTruthy() expect(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' })).toBeTruthy()
}) })
@@ -130,6 +130,29 @@ describe('DirectoryBrowser', () => {
expect(rowButton(selectedRow).getAttribute('aria-current')).toBe('true') expect(rowButton(selectedRow).getAttribute('aria-current')).toBe('true')
}) })
it('aborts a superseded listing on the wire, and the in-flight one on close', async () => {
const signals: (AbortSignal | undefined)[] = []
const gates: (() => void)[] = []
const listDirectory = vi.fn((path?: string, signal?: AbortSignal) => {
signals.push(signal)
if (signals.length === 1) return Promise.resolve(listingFor(path))
// Later listings hang until released: supersession must abort them
// on the wire, not merely discard their eventual results.
return new Promise<DirectoryListing>((resolve) => { gates.push(() => { resolve(listingFor(path)) }) })
})
const b = mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(rowButton(screen.getByRole('listitem')))
expect(signals).toHaveLength(2)
// A crumb jump supersedes the hanging preview: its request aborts.
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
expect(signals[1]?.aborted).toBe(true)
expect(signals[2]?.aborted).toBe(false)
// Closing the dialog aborts the still-pending navigation too.
b.view.rerender(<DirectoryBrowser {...b.props} listDirectory={listDirectory} open={false} />)
expect(signals[2]?.aborted).toBe(true)
})
it('jumps back through a crumb into a fresh single-column level', async () => { it('jumps back through a crumb into a fresh single-column level', async () => {
mount() mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
@@ -195,7 +218,7 @@ describe('DirectoryBrowser', () => {
// rows nor status behind. // rows nor status behind.
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
expect(listDirectory).toHaveBeenCalledTimes(2) expect(listDirectory).toHaveBeenCalledTimes(2)
expect(listDirectory).toHaveBeenLastCalledWith(undefined) expect(listDirectory).toHaveBeenLastCalledWith(undefined, expect.any(AbortSignal))
}) })
it('passes the entered path to the Host untrimmed (trim only gates blank drafts)', async () => { it('passes the entered path to the Host untrimmed (trim only gates blank drafts)', async () => {
@@ -207,7 +230,7 @@ describe('DirectoryBrowser', () => {
fireEvent.change(input, { target: { value: `${DOCS} ` } }) fireEvent.change(input, { target: { value: `${DOCS} ` } })
fireEvent.keyDown(input, { key: 'Enter' }) fireEvent.keyDown(input, { key: 'Enter' })
// A trailing space may name a real directory; trimming would list its sibling. // A trailing space may name a real directory; trimming would list its sibling.
await waitFor(() => { expect(listDirectory).toHaveBeenLastCalledWith(`${DOCS} `) }) await waitFor(() => { expect(listDirectory).toHaveBeenLastCalledWith(`${DOCS} `, expect.any(AbortSignal)) })
}) })
it('surfaces an unreadable target as an alert and keeps the edit open for correction', async () => { it('surfaces an unreadable target as an alert and keeps the edit open for correction', async () => {
@@ -347,7 +370,7 @@ describe('DirectoryBrowser', () => {
expect(b.listDirectory.mock.calls.length).toBe(listCalls) expect(b.listDirectory.mock.calls.length).toBe(listCalls)
fireEvent.compositionEnd(pathInput) fireEvent.compositionEnd(pathInput)
fireEvent.keyDown(pathInput, { key: 'Enter' }) fireEvent.keyDown(pathInput, { key: 'Enter' })
await waitFor(() => { expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS) }) await waitFor(() => { expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS, expect.any(AbortSignal)) })
// Create dialog: same guard. // Create dialog: same guard.
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
const nameInput = screen.getByLabelText('browser.folderName') const nameInput = screen.getByLabelText('browser.folderName')
@@ -764,6 +787,6 @@ describe('DirectoryBrowser', () => {
b.view.rerender(<DirectoryBrowser {...b.props} open />) b.view.rerender(<DirectoryBrowser {...b.props} open />)
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
expect(columns()).toHaveLength(1) expect(columns()).toHaveLength(1)
expect(b.listDirectory).toHaveBeenLastCalledWith(undefined) expect(b.listDirectory).toHaveBeenLastCalledWith(undefined, expect.any(AbortSignal))
}) })
}) })

3
pnpm-lock.yaml generated
View File

@@ -215,6 +215,9 @@ importers:
'@deepseek-ai/dsh-host-directory-picker-browse': '@deepseek-ai/dsh-host-directory-picker-browse':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/host/directory-picker-browse version: link:../../packages/host/directory-picker-browse
'@deepseek-ai/dsh-host-directory-picker-native':
specifier: workspace:^
version: link:../../packages/host/directory-picker-native
'@deepseek-ai/dsh-host-webserver': '@deepseek-ai/dsh-host-webserver':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/host/webserver version: link:../../packages/host/webserver