fix(desktop): recover from macOS directory permission failures
This commit is contained in:
@@ -75,6 +75,7 @@ The composer compares normalized attachment MIME types with the selected model's
|
||||
- A mounted directory-store consumer pins that store for its lifetime. Eviction may dispose only unmounted directories, so optimistic actions and realtime events cannot move to a replacement store while visible React consumers remain subscribed to an older identity.
|
||||
- Reconfiguration and runtime switching invalidate stale generations. A stale completion must not publish state into the new runtime.
|
||||
- Failure is recorded as `failed`; it is not converted into a successful empty snapshot. Forced demand can retry failed or completed work.
|
||||
- A failed bootstrap is classified as `os-permission` only when the owning runtime filesystem API independently confirms `EPERM`/`EACCES` for that exact directory. OpenCode/proxy error text is never used as permission evidence. The scheduler retains the directory-scoped reason so local Desktop can offer native folder selection before a forced retry.
|
||||
|
||||
Bootstrap remains stale-while-revalidate: a directory store may paint persisted sessions immediately, but only a successful authoritative fetch may replace that cached list.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
setSyncPerformanceDiagnosticsEnabled,
|
||||
} from './performance-diagnostics';
|
||||
import { DIR_IDLE_TTL_MS } from './types';
|
||||
import { FilesystemError } from '@/lib/api/files-errors';
|
||||
|
||||
const deferred = () => {
|
||||
let resolve!: () => void;
|
||||
@@ -414,6 +415,40 @@ describe('ChildStoreManager directory bootstrap scheduler', () => {
|
||||
manager.disposeAll();
|
||||
});
|
||||
|
||||
test('records os-permission failures and clears them on forced retry', async () => {
|
||||
const manager = new ChildStoreManager();
|
||||
let denied = true;
|
||||
const cleanup = manager.configure({
|
||||
onBootstrap: () => {
|
||||
if (denied) {
|
||||
throw new FilesystemError('Access denied', { reason: 'os-permission', status: 403 });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
manager.requestBootstrap({ directory: '/protected', priority: 'selected', reason: 'current-directory' });
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
expect(manager.getBootstrapState('/protected')).toBe('failed');
|
||||
expect(manager.getBootstrapFailure('/protected')).toBe('os-permission');
|
||||
|
||||
denied = false;
|
||||
manager.requestBootstrap({
|
||||
directory: '/protected',
|
||||
priority: 'selected',
|
||||
reason: 'action-demand',
|
||||
force: true,
|
||||
});
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
expect(manager.getBootstrapState('/protected')).toBe('complete');
|
||||
expect(manager.getBootstrapFailure('/protected')).toBe(undefined);
|
||||
cleanup();
|
||||
manager.disposeAll();
|
||||
});
|
||||
|
||||
test('continues after a synchronous bootstrap failure', async () => {
|
||||
const manager = new ChildStoreManager();
|
||||
const started: string[] = [];
|
||||
|
||||
@@ -6,6 +6,7 @@ import { readDirCache, persistVcs, persistProjectMeta, persistIcon, persistSessi
|
||||
import { normalizePath } from "@/lib/pathNormalization"
|
||||
import { startSessionLoadPerformanceEvent } from "./session-load-performance"
|
||||
import { countSyncPerformance } from "./performance-diagnostics"
|
||||
import { isFilesystemError } from "@/lib/api/files-errors"
|
||||
|
||||
export type DirectoryStore = State & {
|
||||
/** Apply a partial state update */
|
||||
@@ -216,6 +217,7 @@ export type DirectoryBootstrapDemand = {
|
||||
}
|
||||
|
||||
export type DirectoryBootstrapState = "queued" | "running" | "complete" | "failed"
|
||||
export type DirectoryBootstrapFailureReason = "os-permission" | "generic"
|
||||
|
||||
export type DirectoryBootstrapContext = DirectoryBootstrapDemand & {
|
||||
generation: number
|
||||
@@ -296,6 +298,7 @@ export class ChildStoreManager {
|
||||
private readonly bootstrapQueue = new Map<string, QueuedBootstrap>()
|
||||
private readonly runningBootstraps = new Map<string, RunningBootstrap>()
|
||||
private readonly bootstrapStates = new Map<string, DirectoryBootstrapState>()
|
||||
private readonly bootstrapFailures = new Map<string, DirectoryBootstrapFailureReason>()
|
||||
|
||||
private onBootstrap?: (context: DirectoryBootstrapContext) => Promise<void> | void
|
||||
private onDispose?: (directory: string) => void
|
||||
@@ -479,6 +482,11 @@ export class ChildStoreManager {
|
||||
return normalizedDirectory ? this.bootstrapStates.get(normalizedDirectory) : undefined
|
||||
}
|
||||
|
||||
getBootstrapFailure(directory: string): DirectoryBootstrapFailureReason | undefined {
|
||||
const normalizedDirectory = normalizePath(directory)
|
||||
return normalizedDirectory ? this.bootstrapFailures.get(normalizedDirectory) : undefined
|
||||
}
|
||||
|
||||
subscribeBootstrap(listener: () => void): () => void {
|
||||
this.bootstrapSubscribers.add(listener)
|
||||
return () => this.bootstrapSubscribers.delete(listener)
|
||||
@@ -530,6 +538,7 @@ export class ChildStoreManager {
|
||||
if (demand.force) running.rerunRequested = true
|
||||
return false
|
||||
}
|
||||
this.bootstrapFailures.delete(directory)
|
||||
const existing = this.bootstrapQueue.get(directory)
|
||||
const next: QueuedBootstrap = existing
|
||||
? {
|
||||
@@ -603,14 +612,19 @@ export class ChildStoreManager {
|
||||
.then(() => {
|
||||
if (isCurrent()) {
|
||||
this.bootstrapStates.set(next.directory, "complete")
|
||||
this.bootstrapFailures.delete(next.directory)
|
||||
finishPerformanceEvent("complete")
|
||||
} else {
|
||||
finishPerformanceEvent("stale")
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
if (isCurrent()) {
|
||||
this.bootstrapStates.set(next.directory, "failed")
|
||||
this.bootstrapFailures.set(
|
||||
next.directory,
|
||||
isFilesystemError(error) && error.reason === "os-permission" ? "os-permission" : "generic",
|
||||
)
|
||||
finishPerformanceEvent("error")
|
||||
} else {
|
||||
finishPerformanceEvent("stale")
|
||||
@@ -658,6 +672,7 @@ export class ChildStoreManager {
|
||||
this.bootstrapQueue.delete(directory)
|
||||
this.manualBootstrapDemands.delete(directory)
|
||||
this.bootstrapStates.delete(directory)
|
||||
this.bootstrapFailures.delete(directory)
|
||||
for (const demands of this.bootstrapDemandsByOwner.values()) demands.delete(directory)
|
||||
this.children.delete(directory)
|
||||
this.notifyRegistrySubscribers()
|
||||
@@ -719,6 +734,7 @@ export class ChildStoreManager {
|
||||
this.bootstrapQueue.clear()
|
||||
this.runningBootstraps.clear()
|
||||
this.bootstrapStates.clear()
|
||||
this.bootstrapFailures.clear()
|
||||
this.bootstrapDemandsByOwner.clear()
|
||||
this.manualBootstrapDemands.clear()
|
||||
this.notifyBootstrapSubscribers()
|
||||
|
||||
@@ -68,6 +68,7 @@ import { getPermissionToastKey, showPermissionNeededToast } from "./permission-t
|
||||
import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-memory"
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch"
|
||||
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
|
||||
import { isFilesystemError } from "@/lib/api/files-errors"
|
||||
import { listGlobalSessionPages } from "@/stores/globalSessions"
|
||||
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
|
||||
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
|
||||
@@ -2068,7 +2069,21 @@ export function SyncProvider(props: {
|
||||
}
|
||||
|
||||
const result = await runBootstrap(0)
|
||||
if (result === "failed") throw new Error(`Directory bootstrap failed for ${directory}`)
|
||||
if (result === "failed") {
|
||||
// OpenCode can mask the underlying errno while initializing an
|
||||
// inaccessible workspace. Probe the exact directory through the
|
||||
// owning runtime filesystem API so only an authoritative local
|
||||
// EPERM/EACCES becomes an actionable grant-access failure.
|
||||
const files = getRegisteredRuntimeAPIs()?.files
|
||||
if (files) {
|
||||
try {
|
||||
await files.listDirectory(directory)
|
||||
} catch (error) {
|
||||
if (isFilesystemError(error) && error.reason === "os-permission") throw error
|
||||
}
|
||||
}
|
||||
throw new Error(`Directory bootstrap failed for ${directory}`)
|
||||
}
|
||||
|
||||
// Selecting a session whose directory this client had not indexed yet
|
||||
// routes it through the active directory as a documented guess. This is
|
||||
|
||||
Reference in New Issue
Block a user