feat(providers): dispatch git and filesystem providers by execution host

`const c = repo.connectionId; c ? sshProvider(c) : local()` overloads `null`
to mean both "resolved: local" and "could not resolve", so every path that
cannot determine the host silently runs remote work on the client (#11163).
It also cannot express a `runtime:` host at all.

Add a host-keyed dispatch whose input is an `ExecutionHostId` — never null —
with `local`, `ssh` and `runtime` as three symmetric entries, and which throws
on an id that names no host instead of degrading to this machine. `ssh` carries
`provider: null` for "remote, currently unreachable", which is now a different
answer from "local" rather than the same one.

`runtime:` is a distinct entry rather than a provider because main does not
execute runtime hosts at all: they are forwarded over the environment transport,
and a runtime row's `connectionId` names a target in the server's namespace.
Dialing it from this client's SSH table would trade a silent-local bug for a
silent-wrong-host one.

First migrations, both to rows resolved via `getRepoExecutionHostId`:
- repo-worktrees: an `executionHostId: 'ssh:*'`-only row no longer lists,
  root-matches, or strict-lists against a same-named local path.
- workspace-space-repo-scan: same for the size scan, and `isRemote` no longer
  contradicts the `executionHostId` emitted beside it.
This commit is contained in:
Neil
2026-09-02 17:22:21 -07:00
parent 36e139ed19
commit b031ec9dde
5 changed files with 400 additions and 49 deletions
@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
ExecutionHostNotDispatchableError,
requireFilesystemProviderForHost,
requireGitProviderForHost,
resolveFilesystemRouteForHost,
resolveGitRouteForHost,
UnresolvableExecutionHostError
} from './execution-host-provider-dispatch'
import { registerSshGitProvider, unregisterSshGitProvider } from './ssh-git-dispatch'
import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
} from './ssh-filesystem-dispatch'
const connectionId = 'host-dispatch-target'
const gitProvider = { listWorktrees: async () => [] } as never
const filesystemProvider = { readDir: async () => [] } as never
describe('execution host provider dispatch', () => {
afterEach(() => {
unregisterSshGitProvider(connectionId)
unregisterSshFilesystemProvider(connectionId)
})
it('routes `local` to the local entry rather than to a provider', () => {
expect(resolveGitRouteForHost('local')).toEqual({ kind: 'local', hostId: 'local' })
expect(resolveFilesystemRouteForHost('local')).toEqual({ kind: 'local', hostId: 'local' })
})
it('routes an ssh host to its registered provider', () => {
registerSshGitProvider(connectionId, gitProvider)
registerSshFilesystemProvider(connectionId, filesystemProvider)
expect(resolveGitRouteForHost(`ssh:${connectionId}`)).toEqual({
kind: 'ssh',
hostId: `ssh:${connectionId}`,
connectionId,
provider: gitProvider
})
expect(resolveFilesystemRouteForHost(`ssh:${connectionId}`)).toEqual({
kind: 'ssh',
hostId: `ssh:${connectionId}`,
connectionId,
provider: filesystemProvider
})
expect(requireGitProviderForHost(`ssh:${connectionId}`)).toBe(gitProvider)
expect(requireFilesystemProviderForHost(`ssh:${connectionId}`)).toBe(filesystemProvider)
})
it('answers `unreachable`, not `local`, for an ssh host with no registered provider', () => {
const route = resolveGitRouteForHost(`ssh:${connectionId}`)
// The distinction the old `connectionId ? ssh : local` shape could not spell.
expect(route.kind).toBe('ssh')
expect(route.kind === 'ssh' && route.provider).toBeNull()
expect(() => requireGitProviderForHost(`ssh:${connectionId}`)).toThrow(
/Remote connection dropped/
)
expect(() => requireFilesystemProviderForHost(`ssh:${connectionId}`)).toThrow(
/Remote connection dropped/
)
})
it('routes a runtime host to its own entry instead of collapsing it into local', () => {
expect(resolveGitRouteForHost('runtime:env-7')).toEqual({
kind: 'runtime',
hostId: 'runtime:env-7',
environmentId: 'env-7'
})
expect(resolveFilesystemRouteForHost('runtime:env-7')).toEqual({
kind: 'runtime',
hostId: 'runtime:env-7',
environmentId: 'env-7'
})
})
it('refuses to hand a runtime host to this process’s ssh table', () => {
// A runtime repo row carries the *server's* nested target id. Dialling it here would reach a
// same-named target in this client's namespace.
registerSshGitProvider(connectionId, gitProvider)
expect(() => requireGitProviderForHost('runtime:env-7')).toThrow(
ExecutionHostNotDispatchableError
)
expect(() => requireFilesystemProviderForHost('runtime:env-7')).toThrow(
ExecutionHostNotDispatchableError
)
})
it('refuses to serve a local host from the remote-only accessor', () => {
expect(() => requireGitProviderForHost('local')).toThrow(ExecutionHostNotDispatchableError)
expect(() => requireFilesystemProviderForHost('local')).toThrow(
ExecutionHostNotDispatchableError
)
})
it.each([null, undefined, '', 'nonsense', 'ssh:', 'runtime:', 'ssh:a|b'])(
'throws instead of answering local for the unresolvable host %p',
(hostId) => {
expect(() => resolveGitRouteForHost(hostId)).toThrow(UnresolvableExecutionHostError)
expect(() => resolveFilesystemRouteForHost(hostId)).toThrow(UnresolvableExecutionHostError)
}
)
})
@@ -0,0 +1,155 @@
/**
* Host-keyed provider dispatch: one entry per execution host kind, with `local` among them.
*
* The incumbent spelling across main is `const c = repo.connectionId; c ? sshProvider(c) : local()`,
* where `null` means *both* "resolved: this is local" and "could not resolve". Every path that
* cannot determine the host therefore answers "local" and runs remote work on the client — the
* #11163 defect class, which has produced a reproduced cross-host leak (an `ssh:` worktree
* resolving to another target) and near-misses where a transcript that exists only on a remote host
* would have been read locally. The shape also cannot express a `runtime:` host at all.
*
* This module removes that spelling. Its input is an `ExecutionHostId`, which is never null, and an
* id that names no host throws instead of degrading. `getRepoExecutionHostId` /
* `getWorktreeExecutionHostId` / `resolveWorktreeExecutionHost` are the resolution layer that feeds
* it; the last one already answers `unresolved` as a distinct verdict rather than "local".
*
* Why a route union rather than a uniform `getGitProviderForHost(): IGitProvider`, which is the
* VS Code shape (`registerProvider(Schemas.file, …)` symmetric with `Schemas.vscodeRemote`, and
* `ENOPRO` when nothing matches). Two properties of this process, not style preferences:
*
* - `local` git and filesystem work is free functions taking per-worktree execution options
* (`wslDistro`, `sharedLinkPaths`, admission tier), not an `IGitProvider`. There is no local
* provider object to register, and a stateless one would silently drop WSL routing.
* - `runtime:<env>` is not executed in this process *at all*. It is forwarded over the
* environment's transport (`runtimeEnvironments:call`) and the receiving server normalizes it to
* its own `local`. A repo row on a runtime host carries the server's *nested* SSH target in
* `connectionId`; that id is addressable only as the pair (environmentId, targetId). Handing it
* to this client's SSH table would dial a same-named target in the wrong namespace — turning a
* silent-local bug into a silent-wrong-host bug. `host-repo-catalog-snapshot` and
* `host-qualified-worktree-listing` already reject runtime hosts for the same reason.
*
* So the answer is Zed's shape — an enum on the owner (`Local { fs }` vs `Remote { … }`) — and the
* three kinds are symmetric variants of it. Callers switch exhaustively, so `runtime` can no longer
* collapse into `local` by omission.
*
* Note the deliberate second distinction inside the `ssh` variant: `provider: null` means "this host
* is remote and currently unreachable", which is not the same answer as "this host is local" and can
* no longer be spelled the same way. That mirrors the `live` / `unverifiable` / `exited` rule in
* docs/reference/ssh-execution-boundary.md — loss of contact is never evidence of locality.
*/
import {
parseExecutionHostId,
type ExecutionHostId,
type LOCAL_EXECUTION_HOST_ID,
type ParsedExecutionHost
} from '../../shared/execution-host'
import { getSshGitProvider, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from './ssh-git-dispatch'
import {
getSshFilesystemProvider,
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
} from './ssh-filesystem-dispatch'
import type { IFilesystemProvider, IGitProvider } from './types'
/** An id that names no execution host. Never degrade to local — that is the whole defect class. */
export class UnresolvableExecutionHostError extends Error {
constructor(readonly hostId: string | null | undefined) {
super(
`Cannot route work: ${JSON.stringify(hostId ?? null)} names no execution host. ` +
'Refusing to fall back to this machine.'
)
this.name = 'UnresolvableExecutionHostError'
}
}
/** Asking this process for a host it does not execute is a routing mistake, not a fallback. */
export class ExecutionHostNotDispatchableError extends Error {
constructor(readonly hostId: ExecutionHostId) {
super(`Execution host ${hostId} is not dispatched by this process.`)
this.name = 'ExecutionHostNotDispatchableError'
}
}
type LocalRoute = { kind: 'local'; hostId: typeof LOCAL_EXECUTION_HOST_ID }
type RuntimeRoute = { kind: 'runtime'; hostId: `runtime:${string}`; environmentId: string }
type SshRoute<TProvider> = {
kind: 'ssh'
hostId: `ssh:${string}`
connectionId: string
/** `null` is "remote, currently unreachable" — never "local". */
provider: TProvider | null
}
export type ExecutionHostGitRoute = LocalRoute | RuntimeRoute | SshRoute<IGitProvider>
export type ExecutionHostFilesystemRoute = LocalRoute | RuntimeRoute | SshRoute<IFilesystemProvider>
// Takes an unvalidated string rather than `ExecutionHostId`: validating is the point, and host
// ids also arrive from persistence and IPC where the compiler cannot vouch for them.
function parseRoutableHost(hostId: string | null | undefined): ParsedExecutionHost {
const parsed = parseExecutionHostId(hostId)
if (!parsed) {
throw new UnresolvableExecutionHostError(hostId)
}
return parsed
}
export function resolveGitRouteForHost(hostId: string | null | undefined): ExecutionHostGitRoute {
const parsed = parseRoutableHost(hostId)
switch (parsed.kind) {
case 'local':
return { kind: 'local', hostId: parsed.id }
case 'ssh':
return {
kind: 'ssh',
hostId: parsed.id,
connectionId: parsed.targetId,
provider: getSshGitProvider(parsed.targetId) ?? null
}
case 'runtime':
return { kind: 'runtime', hostId: parsed.id, environmentId: parsed.environmentId }
}
}
export function resolveFilesystemRouteForHost(
hostId: string | null | undefined
): ExecutionHostFilesystemRoute {
const parsed = parseRoutableHost(hostId)
switch (parsed.kind) {
case 'local':
return { kind: 'local', hostId: parsed.id }
case 'ssh':
return {
kind: 'ssh',
hostId: parsed.id,
connectionId: parsed.targetId,
provider: getSshFilesystemProvider(parsed.targetId) ?? null
}
case 'runtime':
return { kind: 'runtime', hostId: parsed.id, environmentId: parsed.environmentId }
}
}
/** For call sites that are structurally remote-only: local and runtime are both routing errors. */
export function requireGitProviderForHost(hostId: string | null | undefined): IGitProvider {
const route = resolveGitRouteForHost(hostId)
if (route.kind !== 'ssh') {
throw new ExecutionHostNotDispatchableError(route.hostId)
}
if (!route.provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return route.provider
}
export function requireFilesystemProviderForHost(
hostId: string | null | undefined
): IFilesystemProvider {
const route = resolveFilesystemRouteForHost(hostId)
if (route.kind !== 'ssh') {
throw new ExecutionHostNotDispatchableError(route.hostId)
}
if (!route.provider) {
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
}
return route.provider
}
+60 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { listWorktreeGraphMock, listWorktreesMock, listWorktreesStrictMock } = vi.hoisted(() => ({
listWorktreeGraphMock: vi.fn(),
@@ -19,6 +19,8 @@ import {
listRepoWorktreeGraph,
listRepoWorktrees
} from './repo-worktrees'
import { registerSshGitProvider, unregisterSshGitProvider } from './providers/ssh-git-dispatch'
import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability'
describe('repo-worktrees', () => {
beforeEach(() => {
@@ -196,6 +198,63 @@ describe('repo-worktrees', () => {
expect(listWorktreesStrictMock).not.toHaveBeenCalled()
})
// #11163: a row may spell its owner only as `executionHostId`. Reading `connectionId` answers
// "local" for it and runs the listing against a same-named path on this machine.
describe('rows that spell their owner only as executionHostId', () => {
const sshOnlyRepo = {
id: 'repo-1',
path: '/srv/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
kind: 'git' as const,
executionHostId: 'ssh:host-a' as const
}
afterEach(() => {
unregisterSshGitProvider('host-a')
unregisterSshGitProvider('nested-target')
})
it('never lists an ssh-owned row with local git', async () => {
await expect(listRepoWorktrees(sshOnlyRepo)).rejects.toThrow(WorktreeCatalogUnavailableError)
expect(listWorktreesMock).not.toHaveBeenCalled()
})
it('lists an ssh-owned row through its registered provider', async () => {
const listWorktrees = vi.fn().mockResolvedValue([{ path: '/srv/repo' }])
registerSshGitProvider('host-a', { listWorktrees } as never)
await expect(listRepoWorktrees(sshOnlyRepo)).resolves.toEqual([{ path: '/srv/repo' }])
expect(listWorktrees).toHaveBeenCalledWith('/srv/repo')
expect(listWorktreesMock).not.toHaveBeenCalled()
})
it('keeps an ssh-owned root out of the local repo-root match', () => {
expect(isRepoRoot([sshOnlyRepo], '/srv/repo')).toBe(false)
})
it('rejects strict local listing for an ssh-owned row', async () => {
await expect(listLocalRepoWorktreesStrict(sshOnlyRepo)).rejects.toThrow('remote repository')
expect(listWorktreesStrictMock).not.toHaveBeenCalled()
})
it('refuses to answer a runtime-owned row from a same-named local target', async () => {
const listWorktrees = vi.fn().mockResolvedValue([{ path: '/wrong/host' }])
registerSshGitProvider('nested-target', { listWorktrees } as never)
await expect(
listRepoWorktrees({
...sshOnlyRepo,
executionHostId: 'runtime:env-7',
connectionId: 'nested-target'
})
).rejects.toThrow(WorktreeCatalogUnavailableError)
expect(listWorktrees).not.toHaveBeenCalled()
expect(listWorktreesMock).not.toHaveBeenCalled()
})
})
it('treats Windows repo root casing differences as the same local root', () => {
const repos = [
{
+29 -11
View File
@@ -2,7 +2,8 @@ import type { Repo } from '../shared/repo-types'
import type { GitWorktreeInfo } from '../shared/worktree/types'
import { listWorktreeGraph, listWorktrees, listWorktreesStrict } from './git/worktree'
import { isFolderRepo } from '../shared/repo-kind'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
import { resolveGitRouteForHost } from './providers/execution-host-provider-dispatch'
import { areWorktreePathsEqual } from './ipc/worktree-logic'
import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability'
@@ -16,8 +17,12 @@ function hasLocalRepoWorktreeListOptions(options: LocalRepoWorktreeListOptions |
}
export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean {
// Why: `!repo.connectionId` matched a remote path against a local one for a row that spells its
// owner only as `executionHostId: 'ssh:<target>'`. Resolve the host instead of reading one field.
return repos.some(
(repo) => !repo.connectionId && areWorktreePathsEqual(repo.path, resolvedTarget)
(repo) =>
getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID &&
areWorktreePathsEqual(repo.path, resolvedTarget)
)
}
@@ -41,17 +46,24 @@ export async function listRepoWorktrees(
if (isFolderRepo(repo)) {
return [createFolderWorktree(repo)]
}
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
const route = resolveGitRouteForHost(getRepoExecutionHostId(repo))
if (route.kind === 'runtime') {
// A runtime row's `connectionId` names a target in the *server's* namespace, not one this
// client may dial. Reading it here would answer from a same-named local target.
throw new WorktreeCatalogUnavailableError(
`Worktree catalog unavailable for ${repo.path}: host ${route.hostId} is not reachable from this process.`
)
}
if (route.kind === 'ssh') {
// Why: runtime worktree resolution can run before SSH providers have reattached during startup.
// Never fall back to local git against a server path, and never report the unreachable host as an
// empty catalog (#14004) — callers treat a resolved listing as authoritative.
if (!provider) {
if (!route.provider) {
throw new WorktreeCatalogUnavailableError(
`Worktree catalog unavailable for ${repo.path}: SSH connection "${repo.connectionId}" is not connected.`
`Worktree catalog unavailable for ${repo.path}: SSH connection "${route.connectionId}" is not connected.`
)
}
return await provider.listWorktrees(repo.path)
return await route.provider.listWorktrees(repo.path)
}
return hasLocalRepoWorktreeListOptions(options)
? await listWorktrees(repo.path, options)
@@ -72,9 +84,15 @@ export async function listRepoWorktreeGraph(
if (isFolderRepo(repo)) {
return [createFolderWorktree(repo)]
}
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
return provider ? await provider.listWorktrees(repo.path) : []
const route = resolveGitRouteForHost(getRepoExecutionHostId(repo))
// An unreachable remote host answers `[]` here, unlike listRepoWorktrees above, which throws.
// Preserved as-is: this call site's callers treat the graph as best-effort. The inconsistency is
// real but is a separate behavior decision from resolving the host correctly.
if (route.kind === 'runtime') {
return []
}
if (route.kind === 'ssh') {
return route.provider ? await route.provider.listWorktrees(repo.path) : []
}
return hasLocalRepoWorktreeListOptions(options)
? await listWorktreeGraph(repo.path, options)
@@ -85,7 +103,7 @@ export async function listLocalRepoWorktreesStrict(
repo: Repo,
options?: LocalRepoWorktreeListOptions
): Promise<GitWorktreeInfo[]> {
if (repo.connectionId) {
if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) {
throw new Error('Cannot list worktrees for a remote repository')
}
if (isFolderRepo(repo)) {
+51 -37
View File
@@ -9,11 +9,13 @@ import type {
WorkspaceSpaceWorktree
} from '../shared/workspace-space-types'
import { mapWithConcurrency } from '../shared/map-with-concurrency'
import { getRepoExecutionHostId } from '../shared/execution-host'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
import { readWorktreeMetaForHost } from './persistence/host-qualified-worktree-meta'
import { getRepoOwnedWorktreeMeta } from './worktree-metadata-ownership'
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import {
resolveFilesystemRouteForHost,
resolveGitRouteForHost
} from './providers/execution-host-provider-dispatch'
import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees'
import { mergeWorktree } from './ipc/worktree-logic'
import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options'
@@ -89,16 +91,25 @@ async function listWorktreesForSpaceScan(
if (isFolderRepo(repo)) {
return { ok: true, worktrees: [createFolderWorktree(repo)] }
}
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
if (!provider) {
// Why: the raw `connectionId` field answers "local" for a row that spells its owner only as
// `executionHostId: 'ssh:<target>'`, which sizes a same-named path on this machine instead.
const route = resolveGitRouteForHost(getRepoExecutionHostId(repo))
if (route.kind === 'runtime') {
return {
ok: false,
status: 'unavailable',
error: `Host ${route.hostId} is not reachable from this process.`
}
}
if (route.kind === 'ssh') {
if (!route.provider) {
return {
ok: false,
status: 'unavailable',
error: `SSH connection "${repo.connectionId}" is not connected.`
error: `SSH connection "${route.connectionId}" is not connected.`
}
}
const worktrees = await provider.listWorktrees(repo.path, { signal })
const worktrees = await route.provider.listWorktrees(repo.path, { signal })
throwIfWorkspaceSpaceScanAborted(signal)
return { ok: true, worktrees }
}
@@ -175,7 +186,7 @@ export async function scanWorkspaceSpaceRepo(args: {
executionHostId: getRepoExecutionHostId(repo),
displayName: repo.displayName,
path: repo.path,
isRemote: Boolean(repo.connectionId),
isRemote: getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID,
worktreeCount: 0,
scannedWorktreeCount: 0,
unavailableWorktreeCount: 1,
@@ -193,7 +204,7 @@ export async function scanWorkspaceSpaceRepo(args: {
{ totalWorktreeCount: progress.totalWorktreeCount + worktrees.length },
options.onProgress
)
const remoteProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : undefined
const filesystemRoute = resolveFilesystemRouteForHost(getRepoExecutionHostId(repo))
const rows = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => {
throwIfWorkspaceSpaceScanAborted(options.signal)
reportProgress(
@@ -204,33 +215,36 @@ export async function scanWorkspaceSpaceRepo(args: {
},
options.onProgress
)
const row = repo.connectionId
? remoteProvider
? await scanRemoteWorkspaceSpaceWorktree(
repo,
worktree,
scannedAt,
remoteProvider,
limiters.remoteFallbackTraversal,
options.signal
const row =
filesystemRoute.kind !== 'local'
? filesystemRoute.kind === 'ssh' && filesystemRoute.provider
? await scanRemoteWorkspaceSpaceWorktree(
repo,
worktree,
scannedAt,
filesystemRoute.provider,
limiters.remoteFallbackTraversal,
options.signal
)
: createUnavailableWorkspaceSpaceRow(
repo,
worktree,
scannedAt,
'unavailable',
filesystemRoute.kind === 'ssh'
? `SSH filesystem for "${filesystemRoute.connectionId}" is not connected.`
: `Host ${filesystemRoute.hostId} is not reachable from this process.`
)
: await limiters.localWorktree(() =>
scanLocalWorkspaceSpaceWorktree(
repo,
worktree,
scannedAt,
args.readLocalDuDepthOne,
args.normalizeLocalDuPath,
options.signal
)
)
: createUnavailableWorkspaceSpaceRow(
repo,
worktree,
scannedAt,
'unavailable',
`SSH filesystem for "${repo.connectionId}" is not connected.`
)
: await limiters.localWorktree(() =>
scanLocalWorkspaceSpaceWorktree(
repo,
worktree,
scannedAt,
args.readLocalDuDepthOne,
args.normalizeLocalDuPath,
options.signal
)
)
reportProgress(
progress,
{
@@ -265,7 +279,7 @@ export async function scanWorkspaceSpaceRepo(args: {
executionHostId: getRepoExecutionHostId(repo),
displayName: repo.displayName,
path: repo.path,
isRemote: Boolean(repo.connectionId),
isRemote: getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID,
worktreeCount: rows.length,
...summary,
error: null