UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

3,376 lines • 163 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { keepVersions } from "../../cache/file/io.js";
import { drainBudget } from "../../cache/file/mixin.js";
import { ListingCheckStore } from "../../cache/index/ram.js";
import { KeyLock } from "../../cache/lock.js";
import { dispatchStat, dotRefusal, hiddenWalk, readHitADir, walkSpelling, } from "../../commands/builtin/utils/paths.js";
import { callerSignal, currentCaller, effectivePathMode, explaining, getCurrentSession, hiddenRefusal, liveCallers, runWithCaller, sessionAccess, sessionVisibility, } from "../../context/session_context.js";
import { eacces, ebusy, eexist, einval, eisdir, eloop, enoent, enotdir, enotempty, enotsup, erofs, exdev, isEnoent, isEnotdir, isFsError, isMissError, isMissingOp, isMissingPath, noMount, noXattr, walkDeclined, walkRefusal, } from "../../errors/fs.js";
import {} from "../../errors/types.js";
import { liveEgress, runWithEgress } from "../../io/gateway.js";
import { runWithMemory } from "../../io/memory.js";
import { closeQuietly, ensureStream } from "../../io/stream.js";
import { IOResult, PipeStream } from "../../io/types.js";
import { activeRecords, commandRecords, finishRecord, lostPaths, record, recordIndex, runWithRecording, startOp, } from "../../observe/context.js";
import { RETRACT_FINGERPRINT_OPS, WRITE_FINGERPRINT_OPS } from "../../observe/record.js";
import { Boundary } from "../../policy/boundary.js";
import { walkBound } from "../../policy/builtin/output_cap.js";
import { METADATA_OPS } from "../../policy/constants.js";
import { PolicyError } from "../../policy/errors.js";
import { Policies, PolicyDenied } from "../../policy/index.js";
import { encodeText } from "../../shell/bytes.js";
import { CapacityState, DEFAULT_READ_TTL, FileChangeKind, FileStat, FileType, MountMode, PathSpec, VFSName, WritePolicy, } from "../../types.js";
import { abortable } from "../../utils/abort.js";
import { asyncContextIsolatesTasks } from "../../utils/async_context.js";
import { runWithSystemClock, systemNow } from "../../utils/clock.js";
import { getExtension } from "../../utils/filetype.js";
import { hiddenUnder, leadsHidden, moveReveals, pathVisible, visibleLinks, } from "../../utils/hidden.js";
import { mountKey, underPath } from "../../utils/key_prefix.js";
import { CycleError, endsAtDot, norm, parent, posixNormpath } from "../../utils/path.js";
import { sliceWindow } from "../../utils/ranges.js";
import { removeRemnants, visibleBelow } from "../../utils/remnants.js";
import { normDir, ownerPrefix, rstripSlash } from "../../utils/slash.js";
import { compareCodePoints } from "../../utils/sort.js";
import { ident } from "../../utils/stat_view.js";
import { declared } from "../../vfs/call.js";
import { WRITE_EFFECTS } from "../../vfs/constants.js";
import { Effect, Target } from "../../vfs/types.js";
import { mergeReaddir, namespaceListing, namespaceStat } from "../../view/namespace_view.js";
import { mergeOverlayStat } from "../mount/namespace/overlay.js";
import { Reconciler } from "../reconcile.js";
import { CALLS, COPY_OPS, DESTINATION_OPS, DIRECTORY_OPEN_OPS, DISPATCH_READ_OPS, DISPATCH_WRITE_OPS, ENTRY_CREATE_OPS, FILE_CREATE_OPS, HIDDEN_CREATE_OPS, HIDE_FILTERED_OPS, LINK_ENTRY_OPS, MISS_OPS, NAMESPACE_TABLE_OPS, NATIVE_WALK_OPS, NO_FOLLOW_OPS, POLICY_WRITE_OPS, READ_AHEAD_BYTES, READ_SHORTCUT_OPS, READ_STAND_IN_OPS, SERIAL_WRITE_OPS, SETATTR_KEYS, STAMP_WRITE_OPS, XATTR_OPS, } from "./constants.js";
import { sees } from "./reader.js";
import { LentPayload, memoryAnswered, primed, served } from "./stream.js";
/**
 * Whether a completed write op was an append of no bytes. That is an open for
 * appending (`true >> f`): it may create the file, but it leaves an existing
 * one's times as they were. Mirrors Python's `_appends_nothing`.
 */
function appendsNothing(name, args) {
    const data = args[0];
    return name === 'append' && data instanceof Uint8Array && data.byteLength === 0;
}
/**
 * Drop listing entries the bound session hides.
 *
 * Entry shapes vary by backend (bare names, trailing-slash names, full
 * paths), so each is keyed by its final segment against the listed
 * directory, the same normalization `mergeReaddir` dedups by.
 */
function visibleEntries(entries, parent, vis = sessionVisibility()) {
    const base = rstripSlash(parent);
    return entries.filter((e) => {
        const trimmed = rstripSlash(e);
        const name = trimmed.slice(trimmed.lastIndexOf('/') + 1);
        return pathVisible(vis, `${base}/${name}`);
    });
}
/**
 * Whether a walk guard's path is a directory: a file or a missing name has
 * nothing below it to walk. Mirrors Python's `_is_directory`.
 */
async function isDirectory(mount, path) {
    try {
        const found = (await mount.call('stat', path.virtual));
        return found?.type === FileType.DIRECTORY;
    }
    catch (err) {
        if (!isMissingPath(err) && !isEnotdir(err))
            throw err;
        console.debug(`walk guard at ${path.virtual}: ${String(err)}`);
        return false;
    }
}
/**
 * A one-call read's answer without the entries the session hides: what the
 * walk the read stands in for would see, since every call it makes reads a
 * hidden path as absent. A `read_tree` answer keeps the entries left visible,
 * keyed below `path`; `files_containing` keeps the files left visible;
 * `search` keeps the hits whose path is visible. Mirrors Python's
 * `_visible_answer`.
 */
function visibleAnswer(name, vis, path, result) {
    if (name === 'read_tree') {
        const base = rstripSlash(path.virtual);
        return new Map([...result].filter(([key]) => pathVisible(vis, `${base}/${key}`)));
    }
    if (name === 'files_containing') {
        return result.filter((hit) => pathVisible(vis, hit.virtual));
    }
    return result.filter(([hit]) => pathVisible(vis, hit));
}
/**
 * Whether a mount may refuse a command that reads every file under a
 * directory: its VFS defines `beforeFullWalk`, or the command walks it
 * under a `walkLimit` (`limit`). Mirrors Python's `_guards_walks`.
 */
function guardsWalks(vfs, limit) {
    return vfs.supports('before_full_walk') || limit >= 0;
}
/**
 * Whether a read-ahead fetches `path`, asked of the mount's index alone: a
 * file its listing sizes within READ_AHEAD_BYTES, and where the store reads a
 * batch, also a file the listing does not size. Mirrors Python's
 * `_fits_read_ahead`.
 */
function fitsReadAhead(entry, batched) {
    if (entry !== null && entry.type === FileType.DIRECTORY)
        return false;
    const size = entry?.size ?? null;
    return size === null ? batched : size <= READ_AHEAD_BYTES;
}
/**
 * The bytes a batch read answered for `virtual`, null when it left the file
 * out. Mirrors Python's `_answered`.
 */
async function answered(batch, virtual) {
    return (await batch).get(virtual) ?? null;
}
/**
 * The last of a write's records that one of `ops` made at `virtual`. Mirrors
 * Python's `_record_of`.
 */
function recordOf(records, ops, virtual) {
    return [...records].reverse().find((rec) => ops.has(rec.op) && rec.path === virtual);
}
/**
 * Whether a backend listing holds the final name of `virtual`. Compared on
 * the final segment, because backends disagree on entry shape: bare names,
 * a trailing slash to mark a directory, or full paths. The same
 * normalization `mergeReaddir` dedupes on. Mirrors Python's `_lists`.
 */
function lists(listing, virtual) {
    const trimmed = rstripSlash(virtual);
    const name = trimmed.slice(trimmed.lastIndexOf('/') + 1);
    return listing.some((entry) => {
        const segments = rstripSlash(entry).split('/');
        return segments[segments.length - 1] === name;
    });
}
// The id of the session this dispatcher serves, empty for the unbound host
// view; the same binding the hides and modes above read.
function sessionId() {
    return getCurrentSession()?.sessionId ?? '';
}
/**
 * The `issuer` kwarg lifted off an op, with the kwargs it leaves behind.
 *
 * A caller that stamps its ops (a profile policy's bridge) does so as
 * an argument, and the dispatcher consumes it here: it reaches every gate the
 * dispatch clears as `VfsContext.issuer`, probes and cascades included,
 * and is never forwarded to a backend, which has no such argument.
 */
function takeIssuer(kwargs) {
    const issuer = kwargs?.issuer;
    if (typeof issuer !== 'symbol')
        return [undefined, kwargs];
    const rest = { ...kwargs };
    delete rest.issuer;
    return [issuer, rest];
}
/** The cache facts of the mount that owns a path, uncacheable without one. */
function factsOf(mount) {
    if (mount === null || mount.retiring || !mount.vfs.cachesReads) {
        return { cacheable: false, ttl: DEFAULT_READ_TTL };
    }
    return {
        cacheable: true,
        ttl: mount.read.ttl,
        keepsVersions: mount.write === WritePolicy.CONDITIONAL,
    };
}
/**
 * Answer a mkdir on a read-only region the way the filesystem would. A
 * read-only filesystem refuses only a create it would really make, so the
 * answer is whatever the create runs into first, walking the components from
 * the mount root: a missing one is refused with EROFS, a file in the chain is
 * ENOTDIR, an existing leaf is EEXIST, and `mkdir -p` of a directory that is
 * already there succeeds. The blamed path is the first component that would
 * have been made, as GNU's `mkdir -p` names it (`'/ro/n'` for `/ro/n/m`).
 * Pinned against GNU coreutils 9.7 on a read-only tmpfs. Mirrors Python's
 * `_mkdir_on_read_only`.
 */
async function mkdirOnReadOnly(stat, prefix, mode, path, parents, access) {
    const base = rstripSlash(prefix);
    const leaf = rstripSlash(path.virtual) || '/';
    if (leaf !== base && !leaf.startsWith(base + '/')) {
        throw erofs(path.virtual, `mount ${prefix} is read-only`);
    }
    const parts = leaf
        .slice(base.length)
        .split('/')
        .filter((part) => part !== '');
    const chain = parts.map((_, index) => PathSpec.fromStrPath(`${base}/${parts.slice(0, index + 1).join('/')}`));
    for (const [index, component] of chain.entries()) {
        let row;
        try {
            row = await stat(component);
        }
        catch (err) {
            if (!isEnoent(err))
                throw err;
            if (!parents && index < chain.length - 1)
                throw enoent(path.virtual);
            const blame = chain
                .slice(index)
                .find((spec) => effectivePathMode(spec.virtual, prefix, mode, access) === MountMode.READ) ?? path;
            throw erofs(blame.virtual, `mount ${prefix} is read-only`);
        }
        if (row.type !== FileType.DIRECTORY) {
            if (index === chain.length - 1)
                throw eexist(path.virtual);
            throw enotdir(parents ? component.virtual : path.virtual);
        }
    }
    if (!parents)
        throw eexist(path.virtual);
}
/**
 * The directories above `path` a `mkdir -p` makes, outermost first: each
 * missing name between the path and the nearest one that exists on its
 * mount. Asked before the create, since after it a directory it made and
 * one that was there look alike. A name that answers anything but ENOENT
 * (a file in the chain) ends the walk, and the create names it. Mirrors
 * Python's `_made_above`.
 */
async function madeAbove(stat, prefix, path) {
    const base = rstripSlash(prefix);
    const made = [];
    let node = parent(rstripSlash(path.virtual));
    while (node.startsWith(base + '/')) {
        const spec = PathSpec.fromStrPath(node);
        try {
            await stat(spec);
        }
        catch (err) {
            if (isEnoent(err)) {
                made.push(spec);
                node = parent(node);
                continue;
            }
            console.debug(`mkdir -p walk stops at ${node}: ${String(err)}`);
        }
        break;
    }
    return made.reverse();
}
/** Ask a command's gate once about each distinct path an op reaches. */
function judge(gate, ...paths) {
    const specs = paths.filter((p) => p instanceof PathSpec);
    for (const virtual of new Set(specs.map((p) => p.virtual)))
        gate.check(virtual);
}
/**
 * Whether the running line's newest word on `virtual` is that a change it
 * made took the path's version (a copy, a move, a removal). Bytes cached
 * before that change are not served: a later step of the same op can still
 * be running, and the path is evicted only once the op ends. A read or write
 * since stamps a newer record. Mirrors Python's `_retracted`.
 */
function retracted(virtual) {
    const records = activeRecords();
    if (records === undefined)
        return false;
    const rec = recordIndex(records).newestVersion(virtual);
    return rec !== null && RETRACT_FINGERPRINT_OPS.has(rec.op);
}
/**
 * A custom function's other path arguments, by position or keyword; a
 * rename's or a copy's destination is walked on its own. Mirrors Python's
 * `_operands`.
 */
function operands(name, args, kwargs) {
    if (DESTINATION_OPS.has(name))
        return [];
    const found = [];
    (args ?? []).forEach((value, at) => {
        if (value instanceof PathSpec)
            found.push([at, value]);
    });
    for (const [key, value] of Object.entries(kwargs ?? {})) {
        if (value instanceof PathSpec)
            found.push([key, value]);
    }
    return found;
}
/** The byte window a read asked for, whole file when it asked none. */
function readWindow(kwargs) {
    return [
        typeof kwargs?.offset === 'number' ? kwargs.offset : 0,
        typeof kwargs?.size === 'number' ? kwargs.size : null,
    ];
}
/** A read's kwargs with its range dropped: the whole file. */
function wholeRead(kwargs) {
    return Object.fromEntries(Object.entries(kwargs).filter(([key]) => key !== 'offset' && key !== 'size'));
}
/**
 * A stat row with the length the file cache holds, when the backend named
 * none. An API mount cannot size a file without fetching it, so once a read
 * kept its bytes, their length is the size, for every caller of the
 * dispatcher (a command, `ws.vfs`, FUSE, a runtime's `fs.stat`). The length
 * alone, read without revalidating: it serves no content. Mirrors Python's
 * `_sized`.
 */
async function sized(mount, path, row) {
    const manager = mount.cacheManager;
    if (!(row instanceof FileStat) ||
        row.type !== FileType.FILE ||
        row.size !== null ||
        manager === null)
        return row;
    const [size] = await manager.cachedSizes([path]);
    return size == null ? row : row.with({ size });
}
/**
 * A subtree read with each row sized as `sized` sizes a stat, so a walk
 * answered from it reads the sizes its stats would, in one ask of the file
 * cache. Mirrors Python's `_sized_tree`.
 */
async function sizedTree(mount, path, tree) {
    const manager = mount.cacheManager;
    const unsized = [...tree].filter(([, row]) => row.type === FileType.FILE && row.size === null);
    if (manager === null || unsized.length === 0)
        return tree;
    const base = rstripSlash(path.virtual);
    const sizes = await manager.cachedSizes(unsized.map(([rel]) => PathSpec.fromStrPath(`${base}/${rel}`)));
    const out = new Map(tree);
    unsized.forEach(([rel, row], i) => {
        const size = sizes[i];
        if (size != null)
            out.set(rel, row.with({ size }));
    });
    return out;
}
/** The dispatcher's link follow of one path, the final name too (`last`) or
 * only the names above it, with a loop thrown as ELOOP rather than the
 * namespace's CycleError. */
function followOrLoop(namespace, path, last, spelled = path.virtual) {
    try {
        return last ? namespace.follow(spelled) : namespace.followParent(spelled);
    }
    catch (err) {
        if (err instanceof CycleError)
            throw eloop(path.virtual);
        throw err;
    }
}
/**
 * A directory a glob walk lists for a pattern (`PathSpec.dir` shaped), as the
 * mount at `prefix` lists it: with the pattern, which a backend whose listing
 * is a window moves the window by. Null for any other path; a glob word left
 * as typed names no directory. Mirrors Python's MountEntry._scope.
 */
function globScope(path, prefix) {
    const folder = rstripSlash(path.virtual) || '/';
    if (path.pattern === null || folder !== (rstripSlash(path.directory) || '/'))
        return null;
    return new PathSpec({
        virtual: folder,
        directory: folder,
        vfsPath: mountKey(folder, rstripSlash(prefix)),
        pattern: path.pattern,
        resolved: false,
    });
}
/**
 * A listing's names spelled under the directory the caller asked for.
 * Listing a link to a directory lists the directory it reaches, and readdir
 * names its children under the path it was handed, as a walk through the
 * link spells them. A caller that handed the directory the link reaches, its
 * typed spelling riding along, asked for that one. Mirrors Python's
 * `_spelled_under`.
 */
function spelledUnder(call, entries) {
    const reached = rstripSlash(call.path.virtual);
    if (call.walked === null || rstripSlash(call.typed.virtual) === reached)
        return entries;
    const asked = rstripSlash(call.walked.virtual);
    return entries.map((entry) => entry.startsWith(`${reached}/`) ? asked + entry.slice(reached.length) : entry);
}
/**
 * Whether a lookup is of a path the running command's line names. Under
 * `read: fresh` such a path is checked against the backend the first time the
 * command looks it up (`Reconciler.reconcileRead`), whatever it asks first
 * (`ls` lists its operand before it stats it): a metadata command reads no
 * bytes, so the cache gate never checks its operand, and a copy the backend
 * replaced would stay cached. The answer is kept for the command, so its later
 * lookups and the cache gate reuse it. The entries a walk reaches are left to
 * their listings. A lookup meets no rule (`METADATA_OPS`), so the command's
 * gate rides on its caller rather than on the call. Mirrors Python's `_names`.
 */
function names(call, caller) {
    return ((call.name === 'stat' || call.name === 'readdir' || call.name === 'exists') &&
        !call.fresh &&
        caller?.ruleGate?.judged.has(rstripSlash(call.path.virtual) || '/') === true);
}
/**
 * What the freshness probe of the running command got from the backend for a
 * stat's path, null when it asked nothing. Under `read: fresh` the probe
 * already asked the backend about the command's operand; asking again resolves
 * through listings the command has not re-checked. A stat that asks for what
 * the backend holds now (`fresh`) is the probe's own question, so it is asked.
 * Mirrors Python's `_probed`.
 */
function probed(call, mount) {
    const manager = mount.cacheManager;
    if (call.name !== 'stat' || call.fresh || manager === null)
        return null;
    return manager.probedStat(call.path);
}
/**
 * The virtual path each item of a custom function's answer names, null when
 * the answer is not a list of paths or of records with a `path`. Mirrors
 * Python's `_answer_paths`.
 */
function answerPaths(result) {
    if (!Array.isArray(result))
        return null;
    const named = [];
    for (const item of result) {
        const path = item instanceof PathSpec
            ? item
            : typeof item === 'object' && item !== null
                ? item.path
                : undefined;
        if (!(path instanceof PathSpec))
            return null;
        named.push(path.virtual);
    }
    return named;
}
export class Dispatcher {
    namespace;
    cache;
    policies;
    // The snapshot drift queue rides along because this is the one dispatcher:
    // a strict restore's pending fingerprint checks must run before ANY
    // op can touch a mount, and FUSE and `ws.vfs` reach here
    // without passing Workspace.dispatch.
    drift;
    // So does the workspace's write admission, which holds a write while a
    // capture reads.
    admitWrite;
    // And the approval ledger, which a path rule that asks is put to where
    // no line is running.
    decisions;
    access;
    // Who makes the op, when the entry point said so; null reads the
    // async context's, which on the fallback storage folds every live one.
    caller = null;
    state = { writers: new KeyLock() };
    static stores = new WeakMap();
    static storeCount = 0;
    reconciler;
    constructor(namespace, cache, policies, drift, admitWrite, decisions) {
        this.namespace = namespace;
        this.cache = cache;
        this.policies = policies ?? new Policies();
        this.drift = drift ?? null;
        this.admitWrite = admitWrite ?? null;
        this.decisions = decisions ?? null;
        this.reconciler = new Reconciler(cache, namespace);
    }
    forSession(session) {
        const bound = new Dispatcher(this.namespace, this.cache, this.policies, this.drift ?? undefined, this.admitWrite ?? undefined, this.decisions ?? undefined);
        bound.access = sessionAccess(session);
        bound.caller = this.caller;
        bound.state = this.state;
        return bound;
    }
    /**
     * This dispatcher run as `caller`: its checks read that caller's
     * session, line, dry run and call, not the async context's. A session
     * a `forSession` view already bound stays its own. Mirrors Python's
     * `caller` keyword on Dispatcher.dispatch.
     */
    forCaller(caller) {
        const bound = new Dispatcher(this.namespace, this.cache, this.policies, this.drift ?? undefined, this.admitWrite ?? undefined, this.decisions ?? undefined);
        // Without task isolation a caller read off the async context may be
        // another task's, so its session counts only where tasks are isolated;
        // elsewhere the checks keep folding every live session.
        bound.access =
            this.access ??
                (caller.session === null || !asyncContextIsolatesTasks
                    ? undefined
                    : sessionAccess(caller.session));
        bound.caller = caller;
        bound.state = this.state;
        return bound;
    }
    sessionId() {
        return this.access?.sessionId ?? sessionId();
    }
    /** The dry run the op is part of: the caller's, else the async context's. */
    explaining() {
        return this.caller === null ? explaining() : this.caller.explaining;
    }
    /**
     * The namespace's own answer for a path no backend serves.
     *
     * Child mounts and symlinks are structure the dispatcher owns, so a
     * directory that exists only because a mount or link sits below it
     * still lists and stats. Null for any other op, or when the
     * namespace knows nothing at `virtual`.
     */
    namespaceResult(name, virtual) {
        const vis = sessionVisibility(this.access);
        if (name === 'readdir') {
            return namespaceListing(vis, this.namespace.mountPrefixes(), this.namespace, virtual);
        }
        if (name === 'stat') {
            return namespaceStat(vis, this.namespace.mountPrefixes(), this.namespace, virtual);
        }
        return null;
    }
    dispatch = async (name, path, args, kwargs, report, caller) => {
        try {
            if (caller !== undefined && caller !== this.caller) {
                const view = this.forCaller(caller);
                const run = () => view.dispatch(name, path, args, kwargs, report);
                // What runs below the dispatcher (a policy, a backend's record)
                // reads the caller the dispatcher judges by, all but its rule gate:
                // a policy's own read below is no call of the command's. Without
                // task isolation that binding would reach other tasks' ops, so there
                // only the dispatcher's own checks read it.
                return await (asyncContextIsolatesTasks
                    ? runWithCaller({ ...caller, ruleGate: null }, run)
                    : run());
            }
            // Only forSession and forCaller know the owner. An unbound browser
            // call must keep the conservative live-session checks rather than
            // borrow the newest binding, which can belong to a wider concurrent
            // session.
            // Under the workspace's max_memory, network and system clock, which
            // FUSE and ws.vfs calls reach only here.
            const run = () => runWithEgress(this.namespace.network, this.policies, () => runWithMemory(this.namespace.memory, () => runWithSystemClock(this.namespace.systemClock, () => name === 'read' &&
                kwargs?.warm !== true &&
                kwargs?.admit_only !== true &&
                !('retained' in (kwargs ?? {}))
                ? this.readRefusingDirs(path, args, kwargs, report)
                : this.dispatchAdmitted(name, path, args, kwargs, report))), this.access?.sessionId ?? this.caller?.session?.sessionId ?? null, true);
            if (this.admitWrite === null || !POLICY_WRITE_OPS.has(name))
                return await run();
            return await this.admitWrite(run);
        }
        finally {
            const data = args?.[0] ?? kwargs?.data;
            if ((name === 'write' || name === 'append' || name === 'pwrite') &&
                data !== null &&
                typeof data === 'object' &&
                Symbol.asyncIterator in data) {
                await closeQuietly(data);
            }
        }
    };
    /** The observation owner known before this call can suspend. */
    readCaller() {
        const caller = this.caller ?? currentCaller();
        if (this.caller === null &&
            !asyncContextIsolatesTasks &&
            liveCallers().some((held) => (held.session !== null || held.write?.context != null) &&
                (held.session?.versions ?? null) !== (caller.session?.versions ?? null)))
            return undefined;
        return caller;
    }
    dispatchAdmitted = async (name, path, args, kwargs, report) => {
        const egressOwner = liveEgress().at(-1) ?? null;
        const caller = this.readCaller();
        // The caller's own mark on the op, lifted before any gate fires so
        // each one is told whose op it judges.
        const [issuer, stripped] = takeIssuer(kwargs);
        kwargs = stripped;
        // The dispatcher's own keywords: a read answered as it is pulled, a read of
        // what the backend holds now, past the file cache, a read of what the file
        // cache alone holds, and a stat of what the backend holds now, past the
        // mount's index.
        const admitOnly = (name === 'write' || name === 'stat') && kwargs?.admit_only === true;
        if (admitOnly) {
            kwargs = { ...kwargs };
            delete kwargs.admit_only;
        }
        const retained = kwargs?.retained;
        if ('retained' in (kwargs ?? {}) &&
            !((name === 'read' && retained instanceof Uint8Array) ||
                (name === 'stat' && retained instanceof FileStat))) {
            throw new TypeError('retained is only valid for read bytes or a stat row');
        }
        if (retained !== undefined) {
            kwargs = { ...kwargs };
            delete kwargs.retained;
        }
        const { stream, direct, warm, fresh, ...own } = (kwargs ?? {});
        if (name === 'read' && (stream !== undefined || direct !== undefined || warm !== undefined))
            kwargs = own;
        if (name === 'stat' && fresh !== undefined)
            kwargs = own;
        if (name === 'read_many') {
            const { paths } = (kwargs ?? {});
            await this.readMany([path, ...(paths ?? [])], issuer, caller);
            return [null, new IOResult()];
        }
        const { scopes, ...searched } = (kwargs ?? {});
        if (name === 'search' && scopes !== undefined) {
            kwargs = searched;
            if (scopes.length > 0) {
                const hits = await this.searchTogether([path, ...scopes], args, searched, report, issuer, caller);
                return [hits, new IOResult()];
            }
        }
        await this.prepare();
        // With no walk guard on any mount, no path reaches one to ask.
        if (name === 'before_full_walk' &&
            !this.namespace.mountPrefixes().some((p) => {
                const mount = this.namespace.mountFor(p);
                return guardsWalks(mount.vfs, this.walkBoundOf(mount));
            })) {
            return [null, new IOResult()];
        }
        // The command's gate judges its own calls; a metadata call passes, as
        // its guards let stat pass, unless it only asks admission, and a walk
        // guard reads nothing: the rule judges each read it guards. A warm read
        // is judged in the cache stage, held or not: its answer stands in for the
        // read (a search's ruled-out file is read as empty), so it meets the rule
        // the read would.
        const isWarm = name === 'read' && warm === true;
        const gate = (METADATA_OPS.has(name) && !admitOnly) || name === 'before_full_walk' || isWarm
            ? null
            : (this.caller?.ruleGate ?? null);
        const call = await this.walk(name, path, args, kwargs, gate, report, issuer, caller, name === 'stat' && fresh === true);
        call.egress = egressOwner;
        call.stream = name === 'read' && stream === true;
        call.direct = name === 'read' && direct === true;
        call.warm = isWarm;
        call.fresh = name === 'stat' && fresh === true;
        await this.refuseRename(call);
        if (report)
            report.path = call.path.virtual;
        if (retained !== undefined) {
            return [await this.judgeHeld(call, retained), new IOResult()];
        }
        if (admitOnly) {
            this.follow(call);
            if (name === 'stat') {
                const serving = this.namespace.tryMountFor(call.path.virtual);
                if (report && serving !== null) {
                    report.store = `${String(this.storeId(serving.vfs))}:${mountKey(call.path.virtual, rstripSlash(serving.prefix))}`;
                }
                memoryAnswered(report);
                return [null, new IOResult()];
            }
            const owner = this.namespace.tryMountFor(call.path.virtual);
            if (owner === null)
                throw enoent(call.path);
            const writeCall = CALLS.get('write');
            if (writeCall === undefined)
                throw new Error('missing write call definition');
            await this.admit(call, this.boundary(owner), writeCall);
            memoryAnswered(report);
            return [null, new IOResult()];
        }
        if (this.tableAnswers(name, call.path.virtual, call.kwargs)) {
            return [
                this.onDevice(name, call.path.virtual, await this.tableCall(name, call.path, call.args ?? [], call.kwargs ?? {}, report, issuer)),
                new IOResult(),
            ];
        }
        this.follow(call);
        if (report)
            report.path = call.path.virtual;
        await this.walkOperands(call);
        if (XATTR_OPS.has(name)) {
            return [
                await this.answerXattr(name, call.path, call.kwargs ?? {}, report, issuer),
                new IOResult(),
            ];
        }
        if (name === 'statfs')
            return [await this.statfs(call.path, issuer), new IOResult()];
        const owner = this.namespace.tryMountFor(call.path.virtual);
        // A mount with no walk guard has nothing to refuse, so a command asks
        // every mount it walks and only a guard costs a call.
        if (name === 'before_full_walk' &&
            (owner === null || !guardsWalks(owner.vfs, this.walkBoundOf(owner)))) {
            return [null, new IOResult()];
        }
        // The cache stage answers first where the file cache decides: a call
        // standing in for a read gives way to the read for a file it holds,
        // which is judged, and a warm read answers whether it holds one, judged
        // as the read it stands in for. A content shortcut also gives way when a
        // postVfs policy would judge its answer: a count or a checksum carries
        // none of the bytes it would judge.
        if ((READ_SHORTCUT_OPS.has(name) &&
            (await this.policies.wantsFor('postVfs', this.sessionId()))) ||
            (READ_STAND_IN_OPS.has(name) && (await this.held(call, owner)))) {
            memoryAnswered(report);
            return [null, new IOResult()];
        }
        if (call.warm) {
            const held = await this.held(call, owner);
            const ruleGate = this.caller?.ruleGate ?? null;
            if (ruleGate !== null)
                judge(ruleGate, call.typed, call.walked, call.path);
            memoryAnswered(report);
            return [held ? true : null, new IOResult()];
        }
        // mkdir(2) looks its name up first, so on a read-only region that lookup
        // answers for the mode, after every other policy has spoken. A dry run
        // never looks, so it explains the mode's refusal.
        const looksUp = owner !== null &&
            name === 'mkdir' &&
            this.explaining() === null &&
            effectivePathMode(call.path.virtual, owner.prefix, owner.mode, this.access) === MountMode.READ;
        const boundary = looksUp
            ? new Boundary(this.policies, owner.prefix, undefined, this.sessionId(), this.decisions, this.access, this.caller ?? undefined)
            : this.boundary(owner);
        if (owner !== null) {
            const across = this.copyTarget(call, owner);
            if (across === null)
                await this.refuseCrossMount(call, owner);
            if (await this.declines(call)) {
                if (across !== null)
                    throw exdev(call.path);
                if (name === 'search' || name === 'files_containing')
                    return [null, new IOResult()];
                throw walkDeclined(owner.vfs.name, name, call.path);
            }
            const mark = declared(owner.vfs.constructor, name) ?? CALLS.get(name) ?? null;
            if (mark === null)
                throw enotsup(owner.vfs.name, name, call.path);
            // A content shortcut stands in for a read and is admitted as one, as a
            // warm read is, so a policy that judges reads judges it; the backend
            // still answers the shortcut.
            const admitted = READ_SHORTCUT_OPS.has(name)
                ? (declared(owner.vfs.constructor, 'read') ?? CALLS.get('read') ?? mark)
                : mark;
            await this.admit(call, boundary, admitted);
            if (looksUp) {
                await mkdirOnReadOnly(dispatchStat(this.dispatch), owner.prefix, owner.mode, call.path, call.kwargs?.parents === true, this.access);
                await boundary.complete(mark, call.path, call.write, null, call.issuer);
                return [null, new IOResult()];
            }
            if (across !== null) {
                await this.copyAcross(call, owner, across);
                await boundary.complete(mark, call.path, call.write, null, call.issuer);
                return [null, new IOResult()];
            }
        }
        let resolved;
        try {
            resolved = await this.namespace.resolve(call.path.virtual, false);
        }
        catch (err) {
            return [
                this.onDevice(name, call.path.virtual, await this.answerUnmounted(call, err)),
                new IOResult(),
            ];
        }
        const [vfs, resolvedScope, mode] = resolved;
        // resolve() above already threw for a path outside every mount, so
        // this lookup cannot miss.
        const mount = this.namespace.mountFor(call.path.virtual);
        const scope = (name === 'readdir' ? globScope(call.path, mount.prefix) : null) ?? resolvedScope;
        if (mount !== owner)
            throw ebusy(call.path.virtual);
        if (name === 'rmdir' &&
            this.namespace
                .linkStatsBelow(call.path.virtual)
                .some(([link]) => sees(call.vis, this.namespace, link))) {
            throw enotempty(call.path.virtual);
        }
        const mark = declared(vfs.constructor, name) ?? CALLS.get(name) ?? null;
        if (mark === null)
            throw enotsup(vfs.name, name, call.path);
        await mount.ensureReady();
        if (mount.write === WritePolicy.CONDITIONAL &&
            (DISPATCH_READ_OPS.has(name) || READ_SHORTCUT_OPS.has(name)) &&
            call.caller === undefined)
            throw enotsup('workspace', 'conditional read (overlapping sessions)', call.path);
        const cached = await this.serveCached(call, mount, vfs, boundary, mark);
        if (cached !== null) {
            return [cached, new IOResult()];
        }
        const settles = (DISPATCH_WRITE_OPS.has(name) || (call.write && !POLICY_WRITE_OPS.has(name))) &&
            !SERIAL_WRITE_OPS.has(name);
        const made = name === 'mkdir' && call.kwargs?.parents === true
            ? await madeAbove(dispatchStat(this.dispatch), mount.prefix, call.path)
            : [];
        let ran;
        try {
            const [answer, records] = await commandRecords(async (mine) => [
                await (call.fresh
                    ? this.freshCall(call, mount, vfs, scope, mode)
                    : this.callBackend(call, mount, vfs, scope, mode)),
                mine,
            ]);
            ran = answer;
            if (call.readRecords.length === 0)
                call.readRecords = records;
        }
        catch (err) {
            if (isEnoent(err) && MISS_OPS.has(name))
                mount.cacheManager?.observeMiss(call.path, call.caller);
            if (settles) {
                await this.evictFailedWrite(name, call.path, call.dst, operands(name, call.args, call.kwargs), lostPaths(activeRecords()));
            }
            try {
                await this.dateMade(made, true);
            }
            catch (cleanup) {
                console.debug(`timing after a failed ${name}: ${String(cleanup)}`);
            }
            throw err;
        }
        const [answer, dst, fullArgs] = ran;
        let result = this.filter(call, answer);
        if (settles) {
            await this.settleWrite(name, call.path, dst, fullArgs, operands(name, call.args, call.kwargs), answer);
        }
        await this.dateMade(made, false);
        if (!CALLS.has(name))
            result = this.customAnswer(call, vfs.name, result);
        // The transfer already happened, so a limit changes what the caller
        // receives, not what the backend moved; the report above already
        // carries the moved count.
        result = await boundary.complete(mark, call.path, call.write, result, call.issuer);
        if (DISPATCH_READ_OPS.has(name) || READ_SHORTCUT_OPS.has(name)) {
            if (DISPATCH_READ_OPS.has(name) &&
                call.stream &&
                mount.write === WritePolicy.CONDITIONAL &&
                result instanceof Uint8Array)
                result = ensureStream(result);
            if (result !== null && typeof result === 'object' && Symbol.asyncIterator in result) {
                if (DISPATCH_READ_OPS.has(name) && !(result instanceof PipeStream)) {
                    const observed = this.observeStream(call, mount, result);
                    await observed.next();
                    result = observed;
                }
            }
            else
                this.observeReads(call, mount, result instanceof Uint8Array && result.byteLength > 0);
        }
        return [result, new IOResult()];
    };
    /** Publish only this admitted read's records for its captured caller. */
    observeReads(call, mount, delivered) {
        const [offset, size] = readWindow(call.kwargs);
        for (const rec of call.readRecords)
            if (rec.op === 'read' &&
                rec.path === call.path.virtual &&
                ((offset === 0 && size === null && rec.complete) || (delivered && rec.fingerprint !== null)))
                mount.cacheManager?.observe(rec, call.caller);
    }
    /** Publish delivered chunks and normal empty completion, retaining stream cleanup. */
    async *observeStream(call, mount, source) {
        let delivered = false;
        try {
            yield new Uint8Array();
            for await (const chunk of source) {
                delivered ||= chunk.byteLength > 0;
                this.observeReads(call, mount, delivered);
                yield chunk;
            }
            this.observeReads(call, mount, delivered);
        }
        finally {
            await closeQuietly(source);
        }
    }
    /**
     * A read that refuses a directory with GNU's EISDIR, at the path it was
     * handed.
     *
     * A store answers a read of a directory in its own voice: ENOENT from a
     * keyed store, whose directory is a set of keys rather than an object, an
     * opaque error from sftp, no bytes from some drivers. So a read that fails
     * or answers no bytes asks whether its path is a directory (`readHitADir`),
     * and any other answer stands. A stream is judged as it is pulled; a live
     * pipe is no directory. Mirrors Python's Dispatcher._read_refusing_dirs.
     */
    async readRefusingDirs(path, args, kwargs, report) {
        let answer;
        try {
            answer = await this.dispatchAdmitted('read', path, args, kwargs, report);
        }
        catch (err) {
            if (await this.readHitADir(path, err))
                throw eisdir(path);
            throw err;
        }
        const [data, io] = answer;
        if (data instanceof PipeStream)
            return answer;
        if (data !== null && typeof data === 'object' && Symbol.asyncIterator in data) {
            const drained = this.drainRefusingDirs(path, data);
            await drained.next();
            return [drained, io];
        }
        if ((data?.byteLength ?? 0) === 0 &&
            (await this.readHitADir(path, null))) {
            throw eisdir(path);
        }
        return answer;
    }
    /**
     * A streamed read that refuses a directory once it fails or ends with no
     * bytes. Run to its empty first step before it is handed on, as `primed`
     * runs a stream, so a caller that closes it before pulling still closes the
     * read. Mirrors Python's Dispatcher._drain_refusing_dirs.
     */
    async *drainRefusingDirs(path, source) {
        let empty = true;
        try {
            yield new Uint8Array();
            for await (const chunk of source) {
                empty = empty && chunk.byteLength === 0;
                yield chunk;
            }
        }
        catch (err) {
            if (await this.readHitADir(path, err))
                throw eisdir(path);
            throw err;
        }
        finally {
            await closeQuietly(source);
        }
        if (empty && (await this.readHitADir(path, null)))
            throw eisdir(path);
    }
    /**
     * Whether a read of `path` that failed or answered no bytes reached a
     * directory, probed as this dispatcher's caller. The probes are the
     * dispatcher's own, no call of the command's, so its path rules do not
     * judge them; the caller's hides do. A policy's refusal is the dispatcher's
     * own answer rather than a store's, so it stands, and a dry run asks the
     * gates only, so it probes nothing. Mirrors Python's
     * Dispatcher._read_hit_a_dir.
     */
    async readHitADir(path, err) {
        if (this.explaining() !== null || err instanceof PolicyDenied || err instanceof PolicyError) {
            return false;
        }
        // A name the caller cannot see, or one whose walk crosses hidden space,
        // stays missing: probing past it, to its link's target, would tell that
        // it is there.
        if (!sees(sessionVisibility(this.access), this.namespace, path.virtual))
            return false;
        const prober = this.caller === null ? undefined : { ...this.caller, ruleGate: null };
        // A read follows a final link, so the kind is its target's, as GNU's cat
        // calls a link to a directory one; the error keeps the name as typed.
        let followed;
        try {
            followed = this.namespace.follow(path.virtual);
        }
        catch (e) {
            if (e instanceof CycleError)
                return false;
            throw e;
        }
        return readHitADir((p) => this.lstat(prober, p), (p) => this.listing(prober, p), followed === path.virtual ? path : PathSpec.fromStrPath(followed), err);
    }
    /** The entry at `path` itself, as lstat answers; throws when nothing is there. */
    async lstat(caller, path) {
        const [row] = await this.dispatch('stat', path, [], { nofollow: true }, undefined, caller);
        if (!(row instanceof FileStat))
            throw enoent(path);
        return row;
    }
    /** The entries of the directory at `path`. */
    async listing(caller, path) {
        const [entries] = await this.dispatch('readdir', path, [], {}, undefined, caller);
        return [...entries];
    }
    /**
     * Whether the file cache holds a copy of the call's path, asked of the store
     * without fetching the bytes or checking the copy against the backend: a
     * read of a held file is checked as it is served. Mirrors Python's
     * Dispatcher._held.
     */
    async held(call, mount) {
        const manager = mount?.cacheManager ?? null;
        return manager !== null && (await manager.isCached(call.path));
    }
    /**
     * Rank several scopes as one query, as a store's `searchMany` does. One
     * call answers for them all where one mount serves every scope and its VFS
     * ranks a batch, past the same checks each scope's own search meets: each
     * is walked and followed, a path rule or a coded preVfs policy reaching
     * below any of them declines the batch (null), and every hit the session
     * hides is dropped. Otherwise, and while a postVfs policy speaks for the
     * session (it judges each scope's answer on its own), each scope is
     * searched on its own and the hits joined in scope order. Mirrors Python's
     * Dispatcher._search_together.
     */
    async searchTogether(scopes, args, kwargs, report, issuer, caller) {
        await this.prepare();
        const gate = this.caller?.ruleGate ?? null;
        const calls = [];
        for (const typed of scopes) {
            const call = await this.walk('search', typed, args, { ...kwargs }, gate, report, issuer, caller);
            this.follow(call);
            calls.push(call);
        }
        const first = calls[0];
        const owner = first === undefined ? null : this.namespace.tryMountFor(first.path.virtual);
        if (first === undefined ||
            owner === null ||
            !owner.vfs.supports('searchMany') ||
            calls.some((call) => this.namespace.tryMountFor(call.path.virtual) !== owner) ||
            (await this.policies.wantsFor('postVfs', this.sessionId()))) {
            const hits = [];
            for (const typed of scopes) {
                const [answer] = await this.dispatchAdmitted('search', typed, args, issuer === undefined ? kwargs : { ...kwargs, issuer }, report);
                if (answer === null)
                    return null;
                for (const item of answer)
                    hits.push(item);
            }
            return hits;
        }
        for (const call of calls) {
            if (await this.declines(call))
                return null;
        }
        const mark = declared(owner.vfs.constructor, 'search') ?? CALLS.get('search') ?? null;
        if (mark === null)
            throw enotsup(owner.vfs.name, 'search', first.path);
        const boundary = this.boundary(owner);
        for (const call of calls)
            await this.admit(call, boundary, mark);
        await owner.ensureReady();
        const result = await owner.searchMany(calls.map((call) => call.path.virtual), args?.[0]);
        served(report, result);
        const visible = result === null ? null : result.filter(([hit]) => pathVisible(first.vis, hit));
        return (await boundary.complete(mark, first.path, false, visible, issuer));
    }
    /**
     * Keep the small files `typed` names in the read cache, for the reads a
     * command is about to make of them; nothing is answered. Each read that
     * follows still meets every check of its own and is served what this kept,
     * so nothing is fetched where a check could refuse that read before its
     * backend is asked: nothing in a dry run or while a coded preVfs policy
     * speaks for the session (the rule a one-call walk meets in `declines`),
     * and no path the session hides or a path rule reaches. A file already
     * kept, one a renderer answers, one pinned to a revision and one on a mount
     * that keeps no reads or holds its data locally (read as fast when asked)
     * is passed over. A store that reads a batch
     * (`readMany`) is asked once for the rest of its files; any other is asked
     * for each file its listing sizes within READ_AHEAD_BYTES. A failure is
     * logged and dropped: the read that follows meets it itself. Mirrors
     * Python's Dispatcher._read_many.
     */
    async readMany(typed, issuer, caller) {
        if (this.explaining() !== null || (await this.policies.wantsFor('preVfs', this.sessionId()))) {
            return;
        }
        await this.prepare();
        const gate = this.caller?.ruleGate ?? null;
        const batches = new Map();
        for (const path of typed) {
            let call;
            try {
                call = await this.walk('read', path, [], {}, null, undefined, issuer, caller);
                this.follow(call);
            }
            catch (err) {
                console.debug(`read-ahead passes over ${path.virtual}: ${String(err)}`);
                continue;
            }
            const mount = this.namespace.tryMountFor(call.path.virtual);
            const manager = mount === null ? null : this.filler(call, mount, mount.vfs);
            if (mount === null ||
                manager === null ||
                mount.retiring ||
                mount.vfs.local ||
                mount.revisions.has(call.path.virtual) ||
                (gate !== null && (gate.scopes(path.virtual) || gate.scopes(call.path.virtual)))) {
                continue;
            }
            const batch = batches.get(mount.prefix) ?? [mount, manager, []];
            batch[2].push(call.path);
            batches.set(mount.prefix, batch);
        }
        const warm = async () => {
            for (const [mount, manager, paths] of batches.values()) {
                await mount.ensureReady();
                const batched = mount.vfs.supports('readMany');
                const entries = await mount.index.getMany(paths.map((p) => p.virtual));
                const wanted = [];
                for (const p of paths) {
                    if (fitsReadAhead(entries.get(p.virtual) ?? null, batched) &&
                        !(await manager.isCached(p))) {
                        wanted.push(p);
                    }
                }
                if (wanted.length === 0)
                    continue;
                let settle = () => undefined;
                const answers = new Promise((resolve) => {
                    settle = resolve;
                });
                // Each fill takes its generation, and the place its records start,
                // before the one request goes out: a write landing while it runs
                // keeps none of it.
                const fills = wanted.map((p) => {
                    const filetype = getExtension(p.virtual);
                    return manager.fill(p, batched
                        ? () => answered(answers, p.virtual)
                        : () => mount.call('read', p.virtual, [], filetype === null ? {} : { filetype }, this.access));
                });
                if (batched) {
                    settle(mount.readMany(wanted.map((p) => p.virtual), READ_AHEAD_BYTES));
                }
                for (const outcome of await Promise.allSettled(fills)) {
                    if (outcome.status === 'rejected') {
                        console.debug(`read-ahead dropped: ${String(outcome.reason)}`);
                    }
                }
            }
        };
        await runWithCaller({ ...currentCaller(), versionReads: false }, async () => {
            if (activeRecords() === undefined)
                await runWithRecording(warm, null, this.namespace.clock);
            else
                await warm();
        });
    }
    /**
     * Load the namespace and run what a snapshot restore left owed.
     *
     * Pending fingerprint checks from a strict snapshot restore run before
     * the op can touch a mount, whichever surface called: FUSE and `ws.vfs`
     * come straight here, so a drain that lived any higher would let a
     * first write clobber drifted state. drain() clears pending before it
     * stats, so its own probes cannot recurse into it. A dry run leaves them
     * pending, its policies' reads included: the check is no policy's
     * answer, and the op that does run still owes it. Mirrors Python's
     * Dispatcher._prepare.
     */
    async prepare() {
        await this.namespace.ensureLoaded();
        if (this.drift?.pending === true && this.explaining() === null) {
            // Resolve backend IDs afresh without consulting the restored index:
            // the check's own throwaway store, which a backend may answer by
            // asking for the one path.
            await this.drift.drain(this.namespace, async (p) => {
                const [stat] = await this.dispatch('stat', PathSpec.fromStrPath(p), [], {
                    index: new ListingCheckStore(),
                });
                return stat;
            });
        }
    }
    /**
     * Walk the op's path, as the kernel does before a call sees it.
     *
     * Hidden paths answer before anything else can: the typed path is
     * checked so a link inside hidden space cannot be followed out of it,
     * the followed path is re-checked (`follow`) so a visible link cannot
     * lead in, and a rename's or a copy's destination is a create. Every link
     * above the
     * final name is then followed, whatever the op does with the name:
     * command dispatch walks the operands it classifies, and this is the
     * same walk for every other caller (a relative word ln resolves itself,
     * `ws.vfs`, a runtime's os.symlink), so a link made, read or
     * removed under a linked directory lands in the directory the link
     * names. Mirrors Python's Dispatcher._walk.
     */
    async walk(name, path, args, kwargs, ruleGate, report, issuer, caller, fresh = false) {
        const vis = sessionVisibility(this.access);
        const links = this.namespace.symlinkTargets();
        const hidden = !pathVisible(vis, path.virtual);
        if (hidden || leadsHidden(vis, path.virtual, links)) {
            // An op that follows a link into hidden space lands there, so it
            // answers as a create under a hidden directory does.
            const follows = !NO_FOLLOW_OPS.has(name) && kwargs?.nofollow !== true;
            throw hiddenRefusal(vis, path.virtual, HIDDEN_CREATE_OPS.has(name) && (hidden || !follows), links);
        }
        let dstArg = DESTINATION_OPS.has(name) ? args?.[0] : undefined;
        if (dstArg instanceof PathSpec) {
            const dstHidden = !pathVisible(vis, dstArg.virtual);
            if (dstHidden || leadsHidden(vis, dstArg.virtual, links)) {
                throw hiddenRefusal(vis, dstArg.virtual, dstHidden || !COPY_OPS.has(name), links);
            }
        }
        // An operand the walk already refused (the empty name, a link loop)
        // names nothing an op can reach, whatever `virtual` says.
        for (const walkedArg of [path, dstArg]) {
            if (walkedArg instanceof PathSpec && walkedArg.walkError !== null) {
                throw walkRefusal(walkedArg);
            }
        }
        // A `.` or `..` resolves against the directory it sits in, so every
        // name in front of one has to be a directory: `virtual` simplified the
        // dots away and reaches `f` through a missing `nope/..`, the typed
        // spelling (`dotted`) does not. A trailing slash is part of that
        // spelling: `x/` must be a directory, so a create of one is EISDIR before
        // anything is looked up, as GNU tee and truncate answer `missing/` and
        // `f/`. Behind a dot (`x/./`) the walk through the dot answers first,
        // ENOENT or ENOTDIR, as GNU does. Deliberate divergence: under a parent
        // that is itself absent GNU reports the parent first (ENOENT); the
        // spelling is refused here without a round trip, so that corner reads
        // EISDIR.
        const slashed = FILE_CREATE_OPS.has(name) &&
            (path.dotted?.endsWith('/') === true || path.rawPath.endsWith('/'));
        if (slashed && !endsAtDot(path.rawPath === '' ? (path.dotted ?? '') : path.rawPath))
            throw eisdir(path);
        const renamed = dstArg instanceof PathSpec ? dstArg : null;
        if (path.dotted !== null || (renamed !== null && renamed.dotted !== null)) {
            const walkStat = dispatchStat(fresh
                ? (op, p, a, k, r) => this.dispatch(op, p, a, { ...k, fresh: true }, r)
                : this.dispatch);
            const follow = (virtual) => this.namespace.follow(virtual);
            const visible = (virtual) => pathVisible(vis, virtual);
            const refusal = hiddenWalk(path, follow, visible) ??
                (renamed !== null ? hiddenWalk(renamed, follow, visible) : null) ??
                (await dotRefusal(walkStat, path, follow, ENTRY_CREATE_OPS.has(name))) ??
                (renamed !== null ? await dotRefusal(walkStat, renamed, follow) : null);
            if (refusal !== null)
                throw refusal;
        }
        if (slashed)
            throw eisdir(path);
        const [typed, typedDst] = [path, dstArg];
        path = this.walked(path, HIDDEN_CREATE_OPS.has(name));
        if (dstArg instanceof PathSpec) {
            dstArg = this.walked(dstArg, true);
            args = [dstArg, ...(args ?? []).slice(1)];
        }
        // The command's gate judges each spelling, as handed in and as walked,
        // once both walks have answered for hidden space: here for an op on the
        // name itself and for a destination, in `follow` for the rest.
        if ((typed.virtual.endsWith('/') || typed.rawPath.endsWith('/')) && kwargs !== undefined) {
            // A trailing slash names the directory a link at the final name
            // reaches, as path resolution reads `link/`.
            const rest = { ...kwargs };
            delete rest.nofollow;
            kwargs = rest;
        }
        const noFollow = NO_FOLLOW_OPS.has(name) || kwargs?.nofollow === true;
        if (ruleGate !== null && noFollow)
            judge(ruleGate, typed, path, typedDst, dstArg);
        else if (ruleGate !== null)
            judge(ruleGate, typedDst, dstArg);
        return {
            name,
            path,
            typed,
            dst: dstArg instanceof PathSpec ? dstArg : null,
            args,
            kwargs,
            vis,
            ruleGate,
            report,
            issuer,
            noFollow,
            write: POLICY_WRITE_OPS.has(name),
            stream: false,
            direct: false,
            warm: false,
            fresh: false,
            walked: null,
            caller,
            egress: null,
            readRecords: [],
        };
    }
    /**
     * Refuse a rename the namespace forbids before any backend runs.
     *
     * A rename re-anchors everything below its source while the hides stay
     * where they are written, so hidden content would land at paths the
     * session can see. Destroying hidden content is silent (rmR, the remnant
     * rmdir); relocating it into view is refused. Only a directory has
     * anything below it to re-anchor, so a file source passes. rename(2)
     * also replaces a destination directory only when it is empty, and the
     * node table is half of what empty means here: a link is invisible to
     * every backend, so a destination the backend reads as empty can still
     * hold one. Left to the backend the rename succeeded and the purge then
     * deleted the link with it, losing namespace state silently where POSIX
     * promises ENOTEMPTY. Mirrors Python's Dispatcher._refuse_rename.
     */
    async refuseRename(call) {
        const dst = call.dst;
        if (call.name !== 'rename' || dst === null)
            return;
        if (moveReveals(call.vis, call.path.virtual, dst.virtual) &&
            (await this.movedSourceIsDir(call.path, call.issuer))) {
            throw eacces(call.path.virtual);
        }
        if (this.namespace.linkStatsBelow(dst.virtual).length > 0)
            throw enotempty(dst.virtual);
    }
    /**
     * Follow the final name, unless the op acts on the name itself.
     *
     * `nofollow` is the caller's AT_SYMLINK_NOFOLLOW: an op that acts on a
     * link entry itself (chown -h writing the link's own attrs) keeps the
     * typed path. Consumed here, never forwarded. A copy writes its
     * destination as a write does, through a link at the final name; a
     * rename moves the name itself. A copy whose two ends then name one file
     * is refused: a backend that replaces its destination would delete the
     * source first. Mirrors Python's Dispatcher._follow.
     */
    follow(call) {
        const nofollow = call.kwargs?.nofollow === true;
        if (call.kwargs !== undefined && 'nofollow' in call.kwargs) {
            const rest = { ...call.kwargs };
            delete rest.nofollow;
            call.kwargs = rest;
        }
        const walked = call.path;
        if (COPY_OPS.has(call.name) && call.dst !== null && !nofollow) {
            const dst = PathSpec.fromStrPath(followOrLoop(this.namespace, call.dst, true));
            if (dst.virtual !== call.dst.virtual) {
                if (!pathVisible(call.vis, dst.virtual))
                    throw hiddenRefusal(call.vis, dst.virtual, true);
                if (call.ruleGate !== null)
                    judge(call.ruleGate, dst);
                call.dst = dst;
                call.args = [dst, ...(call.args ?? []).slice(1)];
            }
        }
        if (nofollow && DIRECTORY_OPEN_OPS.has(call.name) && this.namespace.isLink(call.path.virtual)) {
            throw enotdir(call.path.virtual);
        }
        if (!NO_FOLLOW_OPS.has(call.name) && !nofollow) {
            const followed = followOrLoop(this.namespace, call.path, true);
            if (followed !== call.path.virtual) {
                call.walked = call.path;
                call.path = PathSpec.fromStrPath(followed);
                if (!pathVisible(call.vis, call.path.virtual)) {
                    throw hiddenRefusal(call.vis, call.path.virtual, HIDDEN_CREATE_OPS.has(call.name));
                }
            }
        }
        if (call.ruleGate !== null && !call.noFollow)
            judge(call.ruleGate, call.typed, walked, call.path);
        if (COPY_OPS.has(call.name) && call.dst?.virtual === call.path.virtual)
            throw einval(call.dst);
    }
    /**
     * Walk and follow each other path argument the way the path is: the same
     * hides, spelling checks and rule gate apply, and the followed spelling
     * replaces it. Mirrors Python's Dispatcher._walk_operands.
     */
    async walkOperands(call) {
        for (const [at, typed] of operands(call.name, call.args, call.kwargs)) {
            const other = await this.walk(call.name, typed, [], {}, call.ruleGate, undefined, call.issuer, call.caller);
            this.follow(other);
            const landed = other.path;
            if (typeof at === 'number') {
                call.args = (call.args ?? []).map((value, i) => (i === at ? landed : value));
            }
            else {
                call.kwargs = { ...call.kwargs, [at]: landed };
            }
        }
    }
    /**
     * The mount a copy's destination is on, when that is another mount whose
     * store may copy from this one's; null otherwise. Mirrors Python's
     * Dispatcher._copy_target.
     */
    copyTarget(call, owner) {
        if (call.name !== 'copy' || call.dst === null)
            return null;
        const other = this.namespace.tryMountFor(call.dst.virtual);
        if (other === null || other === owner || !other.vfs.supports('copyFrom'))
            return null;
        return other;
    }
    /**
     * Copy a file onto another mount, store to store.
     *
     * Admission judged the source as a read and the destination as a create
     * under its own mount's mode, as for a copy within one mount. The
     * destination's store copies from the source's where it can (one bucket to
     * another under one set of credentials); otherwise it answers EXDEV, as a
     * copy between two mounts always did, and the caller moves the bytes
     * itself. The destination's name is held, and its caches settled, on its
     * own mount; a copy past its timeout keeps the name until the store
     * answers. Mirrors Python's Dispatcher._copy_across.
     */
    async copyAcross(call, owner, other) {
        const dst = call.dst;
        if (dst === null)
            throw exdev(call.path);
        await owner.ensureReady();
        await other.ensureReady();
        if (!(await other.canCopyFrom(owner, call.path.virtual)))
            throw exdev(call.path);
        const key = `${String(this.storeId(other.vfs))}:${mountKey(dst.virtual, rstripSlash(other.prefix))}`;
        const line = activeRecords();
        const owners = this.owners([dst]);
        try {
            await this.holdWrite([key], `copy ${call.path.virtual}`, (onCall) => other.copyFrom(owner, call.path.virtual, dst.virtual, onCall), async () => {
                await this.settleWrite('copy', call.path, dst, [dst], []);
            }, async () => {
                await this.evictFailedWrite('copy', call.path, dst, [], lostPaths(line), owners);
            }, owners);
        }
        catch (err) {
            await this.evictFailedWrite('copy', call.path, dst, [], lostPaths(line));
            throw err;
        }
    }
    /**
     * Answer EXDEV for a rename or a copy between two mounts.
     *
     * A mount is a filesystem boundary: rename(2) moves a name within one and
     * answers EXDEV across two, before any permission is weighed, so `mv`
     * falls back to copy and unlink instead of the source's backend taking
     * the destination for one of its keys; a backend copies only within
     * itself, the same way. It resolves both parent directories first, so a
     * missing one is ENOENT (ENOTDIR through a file) ahead of EXDEV. Mirrors
     * Python's Dispatcher._refuse_cross_mount.
     */
    async refuseCrossMount(call, owner) {
        // A function runs on one backend, so every path it is handed must be
        // on the mount that serves it.
        for (const [, other] of operands(call.name, call.args, call.kwargs)) {
            if (this.namespace.tryMountFor(other.virtual) !== owner)
                throw exdev(other);
        }
        const dst = call.dst;
        if (dst === null || this.namespace.tryMountFor(dst.virtual) === owner)
            return;
        // A copy between two stores that cannot make it is the caller's cue to
        // move the bytes, whose read and write word any missing parent
        // themselves, so it asks the backends nothing.
        if (call.name === 'copy')
            throw exdev(call.path);
        throw ((await this.parentRefusal(call.path, call.issuer)) ??
            (await this.parentRefusal(dst, call.issuer)) ??
            exdev(call.path));
    }
    /**
     * Whether a one-call walk gives way to the caller's own walk.
     *
     * A backend's find, du, search, tree copy or tree removal answers for
     * every entry under its path in one call, past the checks each entry would
     * meet on its own. Path rules and file policies decline that shortcut so
     * the caller walks through calls that are each judged, including inside
     * commands. A path rule declines no `files_containing` on its own: its
     * answer only rules files out of a walk that still
     * lists every one, and each file it rules out meets the rule as a warm read,
     * so one the rule refuses reports `Permission denied` as its read would. A
     * hide declines the copies and the removal, which would reach hidden
     * entries; a read in HIDE_FILTERED_OPS
     * runs and `filter` drops what the session hides from its answer. A file
     * copy is declined the same way: the bytes it moves never pass through a
     * read. A tree copy is declined too when a link stands below its
     * destination: the backend writes each child name as it is, where the
     * walk's copies follow the link. Mirrors Python's Dispatcher._declines.
     */
    async declines(call) {
        if (!NATIVE_WALK_OPS.has(call.name))
            return false;
        if (call.name === 'dir_copy' &&
            call.dst !== null &&
            this.namespace.linkStatsBelow(call.dst.virtual).length > 0) {
            return true;
        }
        const paths = call.dst === null ? [call.path] : [call.path, call.dst];
        if (!HIDE_FILTERED_OPS.has(call.name) && paths.some((p) => hiddenUnder(call.vis, p.virtual))) {
            return true;
        }
        if (call.ruleGate !== null &&
            call.name !== 'files_containing' &&
            paths.some((p) => call.ruleGate?.scopes(p.virtual) === true))
            return true;
        // A file copy names both its ends, and admission judges each, the source
        // as a read. Only a postVfs hook needs the bytes, and then the caller moves
        // them through a read and a write, so the bytes it judges are the bytes
        // that land.
        if (call.name === 'copy')
            return this.policies.wantsFor('postVfs', this.sessionId());
        // Inside a line the gate above answered for the profile's path rules,
        // scoped to these paths; only a coded hook is left to ask.
        return ((await this.policies.wantsFor('preVfs', this.sessionId(), call.ruleGate !== null)) ||
            ((call.name === 'dir_copy' || call.name === 'files_containing') &&
                (await this.policies.wantsFor('postVfs', this.sessionId()))));
    }
    /**
     * Run admission for an op on a mounted path.
     *
     * Admission policies fire at the dispatcher, before the warm-cache early
     * return: a cached read must be refused exactly like a cold one, or the
     * cache becomes a policy bypass. This dispatcher is the one dispatcher in
     * TypeScript: shell internals, programmatic access, `ws.vfs`, and
     * FUSE all end up here. A rename's or a copy's destination is a create
     * there: it passes the same gate as the source, so a path rule holds
     * against moving or copying into a protected scope (or onto the directory
     * that holds one) the way it holds against writing there, under the mode
     * of the mount that owns it. A copy only reads its source. Mirrors
     * Python's Dispatcher._admit.
     */
    async admit(call, boundary, mark) {
        // A function the VFS declares a write is judged as one, whatever its
        // name: the POSIX names are known here, a custom one only to the VFS
        // that defines it.
        call.write = call.write || WRITE_EFFECTS.includes(mark.effect);
        const gate = call.ruleGate === null ? {} : { gate: call.ruleGate };
        // A file copy reads its source, whatever the backend does with it, so the
        // source is admitted as the read a policy judges reads by.
        const owner = call.name === 'copy' ? this.namespace.tryMountFor(call.path.virtual) : null;
        const source = owner === null ? mark : (declared(owner.vfs.constructor, 'read') ?? CALLS.get('read') ?? mark);
        await boundary.admit(source, call.path, call.write && !COPY_OPS.has(call.name), { final: call.dst === null, ...gate }, call.issuer);
        if (call.dst !== null) {
            await this.boundary(this.namespace.tryMountFor(call.dst.virtual)).admit({ ...mark, creates: true }, call.dst, true, gate, call.issuer);
        }
        for (const [, other] of operands(call.name, call.args, call.kwargs)) {
            await boundary.admit(mark, other, call.write, gate, call.issuer);
        }
    }
    /**
     * Answer an op on a path no mount serves.
     *
     * The namespace may still know a directory there (a deeper mount, a
     * link). No mount means no cache to keep straight and no owning prefix
     * (the gates see ''), but admission still fires: a policy that bounds
     * readdir or stat by path must cover the synthetic answer too. A real but
     * ungranted mount is the same case: a granted mount below it already put
     * this path's name in a listing, so walking down to the grant must
     * answer, and the merged names are session-filtered individually, so
     * nothing of the mount's own content leaks. A setattr with no owning
     * mount lands in the overlay (a link above every mount still takes
     * chown -h), gated exactly like the mounted overlay write; an ungranted
     * mount is not that case and keeps the canonical denial. Mirrors Python's
     * Dispatcher._answer_unmounted.
     */
    async answerUnmounted(call, err) {
        const bare = this.boundary(null);
        const mark = CALLS.get(call.name);
        if (mark === undefined)
            throw enotsup('namespace', call.name, call.path);
        if (call.name === 'setattr' && isMissingPath(err)) {
            await bare.admit(mark, call.path, true, {}, call.issuer);
            const stored = await this.overlaySetattr(call.path, call.kwargs ?? {});
            memoryAnswered(call.report);
            await bare.complete(mark, call.path, true, stored, call.issuer);
            return stored;
        }
        let fallback = isMissingPath(err) ? this.namespaceResult(call.name, call.path.virtual) : null;
        if (fallback === null)
            throw err;
        if (call.name === 'readdir' && Array.isArray(fallback)) {
            fallback = visibleEntries(fallback, call.path.virtual, sessionVisibility(this.access));
        }
        await bare.admit(mark, call.path, call.write, {}, call.issuer);
        // A synthetic namespace answer (a directory that exists only because a
        // mount or a link sits below it) contacts nothing, so attributing it to
        // the mount that lexically owns the path would invent a network op
        // against that backend. Stamped before the gate and the cap, so
        // whatever they throw cannot erase it.
        memoryAnswered(call.report);
        return bare.complete(mark, call.path, call.write, fallback, call.issuer);
    }
    /**
     * Whether a read is answered as it is pulled. A whole read of stored bytes
     * streams as the VFS's `read` answers it, a stream as it is pulled and bytes
     * as one chunk. A window, a rendering, a VFS with no `read` of its own and a
     * postVfs policy that may read the result each get the whole bytes instead,
     * which are a stream of one chunk, including inside commands and runtimes. Mirrors
     * Python's Dispatcher._streams.
     */
    async streams(call, mount, vfs) {
        const [offset, size] = readWindow(call.kwargs);
        if (!call.stream ||
            offset !== 0 ||
            size !== null ||
            !vfs.supports('read') ||
            this.rendersRead(call, mount)) {
            return false;
        }
        // A live pipe answers what its writer has sent, and a bounded reader
        // leaves the rest for the next: collecting it whole would wait for the
        // writer to close. Its bytes come from the line's own writer, whose reads
        // met the gates already.
        if (mount.holdsLivePipe(call.path.virtual, this.access))
            return true;
        return !(await this.policies.wantsFor('postVfs', this.sessionId()));
    }
    /**
     * Open a streamed read, its first chunk pulled before it returns. A cold
     * read fills the cache as it is pulled. A live pipe's read is neither
     * pulled nor cached: it answers as a `PipeStream`. Mirrors Python's
     * Dispatcher._open_stream.
     */
    async openStream(call, mount, scope, filler) {
        const [source, records] = await commandRecords((mine) => Promise.resolve([mount.readStream(scope, this.access), mine]));
        call.readRecords = records;
        const pipe = mount.livePipe(scope.virtual, this.access);
        if (pipe !== null) {
            const live = new PipeStream(await primed(source, call.report, false), call.path, pipe);
            pipe.readers.add(new WeakRef(live));
            return live;
        }
        const stream = filler === null
            ? source
            : filler.fillStream(call.path, source, records, () => !this.rendersRead(call, mount));
        return await primed(stream, call.report);
    }
    /** Whether a filetype renderer answers this read on `vfs`; asked each
     * time, since a renderer can land while the read runs. */
    rendersRead(call, mount) {
        const requested = call.kwargs?.filetype;
        if (requested === undefined)
            return mount.renders(getExtension(call.path.virtual));
        return mount.renders(typeof requested === 'string' ? requested : null);
    }
    /**
     * Judge bytes or a row its caller already holds, as a cache hit. A runtime
     * descriptor and a kernel mount's handle keep what they fetched; each later
     * read or stat of it meets the hides, the rules and the file policies in
     * force now, through the boundary a fetch and the file cache answer
     * through, and nothing is fetched. The bytes were recorded when they were
     * fetched, so this is not. Mirrors Python's Dispatcher._judge_held.
     */
    async judgeHeld(call, held) {
        this.follow(call);
        const owner = this.namespace.tryMountFor(call.path.virtual);
        const mark = (owner === null ? undefined : declared(owner.vfs.constructor, call.name)) ??
            CALLS.get(call.name);
        if (mark === undefined)
            throw new Error(`missing ${call.name} call definition`);
        const boundary = this.boundary(owner);
        await this.admit(call, boundary, mark);
        memoryAnswered(call.report, held instanceof Uint8Array ? held.byteLength : 0);
        return boundary.complete(mark, call.path, call.write, held, call.issuer);
    }
    /**
     * Answer a read from the file cache, or null to read the backend.
     *
     * The file cache holds what commands read, keyed on the path alone: the
     * stored bytes, which are the rendering for a VFS with no `read` of its
     * own. A read through a filetype renderer (whoever registered it) asks
     * for a different value under the same key, so it is neither served
     * from that cache nor kept in it, and neither is a direct read
     * (`direct: true`), which asks for what the backend holds now: a
     * follow's poll for bytes the cached copy cannot have yet. The cache
     * holds the whole object, so a ranged read is answered by slicing it,
     * never by handing back the whole file: the window is what the caller
     * asked for instead of the file, and git reads pack indexes this way.
     * sliceWindow is the same helper the ranged read op falls back to, so
     * warm and cold agree.
     * Mirrors Python's Dispatcher._serve_cached.
     */
    async serveCached(call, mount, vfs, boundary, mark) {
        if (call.direct || !vfs.cachesReads || !DISPATCH_READ_OPS.has(call.name))
            return null;
        const held = await this.cache.getVersioned(call.path.virtual);
        const cached = held?.[0] ?? null;
        if (cached === null ||
            retracted(call.path.virtual) ||
            !(await this.reconciler.mayServeCached(mount, call.path.virtual)) ||
            this.rendersRead(call, mount) ||
            mount.retiring ||
            this.namespace.tryMountFor(call.path.virtual) !== mount) {
            return null;
        }
        mount.refuseKeywords(call.name, call.kwargs ?? {});
        const [offset, size] = readWindow(call.kwargs);
        const window = sliceWindow(cached, offset, size);
        // Nothing crossed the network, and neither a gate nor a hard cap leaves
        // the caller able to tell: without the stamp a refused warm read is
        // recorded against the backend and counted as traffic that never
        // happened.
        memoryAnswered(call.report, window.byteLength);
        const result = (await boundary.complete(mark, call.path, call.write, window, call.issuer));
        if (mount.write === WritePolicy.CONDITIONAL) {
            call.readRecords = [
                finishRecord('read', call.path.virtual, 'ram', window.byteLength, startOp(), {
                    fingerprint: held?.[1] ?? null,
                }),
            ];
            if (call.stream) {
                const observed = this.observeStream(call, mount, ensureStream(result));
                await observed.next();
                return observed;
            }
            this.observeReads(call, mount, result.byteLength > 0);
        }
        return result;
    }
    /**
     * The cache manager a cold read fills, or null to keep nothing.
     *
     * A cold read keeps the whole file it fetched for the next reader,
     * through the mount's own manager, the one a command's read fills: a
     * write racing the fetch retires its generation, so the bytes it read
     * are not kept. A ranged read comes from the store only where the store
     * can serve one; elsewhere the read op would fetch the whole file and
     * slice it for every range, so the whole file is read once, kept, and
     * each range sliced from it. The op is resolved only once the mount is
     * ready, so a renderer can land after this check; the fill asks again
     * before it keeps anything. Mirrors Python's Dispatcher._filler.
     */
    filler(call, mount, vfs) {
        const [offset, size] = readWindow(call.kwargs);
        const whole = offset === 0 && size === null;
        return vfs.cachesReads &&
            !call.direct &&
            DISPATCH_READ_OPS.has(call.name) &&
            size !== 0 &&
            (whole || !mount.readsRanges(call.path.virtual)) &&
            !this.rendersRead(call, mount)
            ? mount.cacheManager
            : null;
    }
    /**
     * The running command's `walkLimit` on `mount` (walkBound): its entry,
     * its declared limit and the session's profile over the mount's and the
     * workspace's. Mirrors Python's Dispatcher._walk_bound.
     */
    walkBoundOf(mount) {
        const caller = this.caller ?? currentCaller();
        return walkBound(mount, caller.command, caller.declared, this.namespace.commandLimits, caller.session?.commandLimits ?? {});
    }
    /**
     * The files a walk guard's path holds, once the command's own `walkLimit`
     * lets the walk run: past it the walk is refused before any backend rule
     * is asked. Mirrors Python's Dispatcher._full_walk_files.
     */
    async fullWalkFiles(mount, vfs, call) {
        const limit = this.walkBoundOf(mount);
        const files = await this.visibleFiles(mount, vfs, call, limit);
        if (limit >= 0 && files !== null && files > limit) {
            throw new Error(`more than ${String(limit)} files in scope (walk_limit); narrow the path`);
        }
        return files;
    }
    /**
     * How many files the session sees under a walk guard's path, counted off
     * the mount's `readTree` with every hidden file dropped, and every file a
     * nested mount covers, so a guard's refusal never counts what the command
     * would not read. Without one, or where
     * it cannot read the whole tree, a mount with a `walkLimit` is walked
     * listing by listing, hidden entries skipped, until one file past the
     * limit; a folder below the path it cannot open counts nothing, as the
     * command reports it itself. null otherwise. Mirrors Python's
     * Dispatcher._visible_files.
     */
    async visibleFiles(mount, vfs, call, limit) {
        const root = call.path.virtual;
        const own = `${rstripSlash(mount.prefix)}/`;
        const covered = this.namespace
            .mountPrefixes()
            .map((p) => `${rstripSlash(p)}/`)
            .filter((p) => p !== own && p.startsWith(own));
        const counted = (virtual) => pathVisible(call.vis, virtual) &&
            !covered.some((p) => `${rstripSlash(virtual)}/`.startsWith(p));
        if (vfs.supports('read_tree')) {
            let found;
            try {
                found = (await mount.call('read_tree', root, [], limit < 0 ? {} : { limit: limit + 1 }));
            }
            catch (err) {
                if (!isFsError(err))
                    throw err;
                console.debug(`walk count of ${root} lists instead: ${String(err)}`);
                found = null;
            }
            if (found !== null) {
                const base = rstripSlash(root);
                let files = 0;
                for (const [key, st] of found) {
                    if (st.type === FileType.FILE && counted(`${base}/${key}`))
                        files += 1;
                }
                return files;
            }
        }
        if (limit < 0)
            return null;
        let count = 0;
        const pending = [[root, false]];
        const seen = new Map();
        for (let next = pending.pop(); next !== undefined && count <= limit; next = pending.pop()) {
            const [directory, whole] = next;
            const asked = whole ? null : limit - count + 1;
            let entries;
            try {
                entries = (await mount.call('readdir', directory, [], asked === null ? {} : { limit: asked }));
            }
            catch (err) {
                if (directory === root || !isFsError(err))
                    throw err;
                console.debug(`walk count skips ${directory}: ${String(err)}`);
                continue;
            }
            // Maybe cut: listed in full once what is below it is counted, if the
            // count is still short then.
            if (asked !== null && entries.length >= asked)
                pending.push([directory, true]);
            const known = seen.get(directory) ?? new Set();
            seen.set(directory, known);
            for (const entry of entries) {
                const child = rstripSlash(entry);
                if (known.has(child))
                    continue;
                known.add(child);
                if (!counted(child))
                    continue;
                let row;
                try {
                    row = (await mount.call('stat', child));
                }
                catch (err) {
                    if (!isFsError(err))
                        throw err;
                    console.debug(`walk count skips ${child}: ${String(err)}`);
                    continue;
                }
                if (row.type === FileType.DIRECTORY)
                    pending.push([child, false]);
                else if (row.type === FileType.FILE) {
                    count += 1;
                    if (count > limit)
                        break;
                }
            }
        }
        return count;
    }
    /**
     * Run a fresh stat under its path's writer hold, so no local write to the
     * path lands between the backend's answer and its reconcile; the reconcile
     * runs to its end under the hold. Mirrors Python's Dispatcher._fresh_call.
     */
    async freshCall(call, mount, vfs, scope, mode) {
        const key = `${String(this.storeId(vfs))}:${mountKey(call.path.virtual, rstripSlash(mount.prefix))}`;
        return await this.state.writers.withLock(key, () => this.callBackend(call, mount, vfs, scope, mode));
    }
    /**
     * Run the op on its mount and stamp what it moved.
     *
     * Returns the op's answer with the rename destination and the arguments
     * the backend received, which the write's settling reads. Mirrors
     * Python's Dispatcher._call.
     */
    async callBackend(call, mount, vfs, scope, mode) {
        const { name, path: p, kwargs, report } = call;
        const mountPrefix = mount.prefix;
        const filler = this.filler(call, mount, vfs);
        const filetype = getExtension(p.virtual);
        const [readOffset, readSize] = readWindow(kwargs);
        const whole = readOffset === 0 && readSize === null;
        // Ops registered under a rendered filetype (gdocs/gsheets/gslides/
        // gmail reads) resolve by the path's extension; Python reaches them
        // because its dispatcher routes through Mount.call, which
        // stamps the filetype. Stamp it here the same way.
        // Every path argument beside the path (a rename's destination, a
        // custom function's other paths) is addressed against the same mount.
        const prefix = rstripSlash(mountPrefix);
        const keyed = (value) => value instanceof PathSpec
            ? new PathSpec({
                virtual: value.virtual,
                directory: value.virtual.slice(0, value.virtual.lastIndexOf('/')) || '/',
                vfsPath: mountKey(value.virtual, prefix),
            })
            : value;
        const fullKwargs = {
            ...Object.fromEntries(Object.entries(kwargs ?? {}).map(([key, v]) => [key, keyed(v)])),
            ...(kwargs?.index === undefined ? this.indexKwargs(mount) : {}),
            ...(call.fresh ? { index: new ListingCheckStore({ hints: mount.index }) } : {}),
            ...(filetype !== null && kwargs?.filetype === undefined ? { filetype } : {}),
        };
        // A listing cut at a walk's limit is filtered after the backend answers,
        // so under a hide it could come back short of the limit and read as the
        // whole folder: the walk then lists in full.
        if (DIRECTORY_OPEN_OPS.has(name) && hiddenUnder(call.vis, p.virtual))
            delete fullKwargs.limit;
        const dst = DESTINATION_OPS.has(name) && call.args?.[0] instanceof PathSpec ? call.args[0] : null;
        const fullArgs = (call.args ?? []).map(keyed);
        // A fill keeps the token its backend records with the read; a read
        // outside a line (FUSE, ws.vfs) records into a scope of its own for that.
        const recorded = (fill) => filler !== null && activeRecords() === undefined
            ? runWithRecording(fill, null, this.namespace.clock).then(([value]) => value)
            : fill();
        let result;
        try {
            if (await this.streams(call, mount, vfs)) {
                return [await recorded(() => this.openStream(call, mount, scope, filler)), dst, fullArgs];
            }
            const run = (opKwargs, onCall) => mount.use(() => mount.framed(() => {
                const pending = Promise.resolve(name === 'setattr'
                    ? this.applySetattr(mount, scope, p, opKwargs)
                    : mount.callKeyed(name, scope, fullArgs, opKwargs, this.access));
                onCall?.(pending);
                return pending;
            }, call.egress));
            if (filler !== null) {
                const kept = await recorded(() => filler.fill(p, () => run(wholeRead(fullKwargs)), () => !this.rendersRead(call, mount)));
                result =
                    whole || !(kept instanceof Uint8Array) ? kept : sliceWindow(kept, readOffset, readSize);
            }
            else if (SERIAL_WRITE_OPS.has(name)) {
                // Held per VFS object and mount-relative name, so writes to one name
                // of one backend run one at a time; a rename or a copy holds both of
                // its names, taken in one order so two between the same pair cannot
                // deadlock. What the write
                // changes beside the store (caches, the node table's links and
                // attributes) changes under the same hold: a chain of renames
                // finishing out of order would move one name's attributes onto
                // another.
                // A copy meeting a live pipe holds no name: the pipe's other end may
                // need it (`cp f >(cat > f)`).
                const meetsPipe = dst !== null &&
                    [p.virtual, dst.virtual].some((path) => mount.livePipe(path, this.access) !== null);
                const keys = meetsPipe
                    ? []
                    : [...new Set([p.virtual, ...(dst !== null ? [dst.virtual] : [])])]
                        .map((virtual) => `${String(this.storeId(vfs))}:${mountKey(virtual, prefix)}`)
                        .sort(compareCodePoints);
                let mine = [];
                let line;
                let before = null;
                let payload = null;
                const others = operands(name, call.args, kwargs);
                const owners = this.owners([
                    p,
                    ...(dst !== null ? [dst] : []),
                    ...others.map(([, other]) => other),
                ]);
                const own = async (onCall, holds) => {
                    line = activeRecords();
                    before = await this.cachedBefore(call, mount);
                    if ((name === 'write' || name === 'append' || name === 'pwrite') &&
                        !(fullArgs[0] instanceof Uint8Array)) {
                        const budget = factsOf(mount).cacheable ? drainBudget(this.cache) : 0;
                        payload = new LentPayload(fullArgs[0], holds, budget);
                        fullArgs[0] = payload.chunks();
                        await this.invalidateStream(mount, p);
                    }
                    try {
                        return await commandRecords((records) => {
                            mine = records;
                            return run(fullKwargs, onCall);
                        });
                    }
                    finally {
                        if (payload !== null)
                            await this.invalidateStream(mount, p);
                    }
                };
                try {
                    result = await this.holdWrite(keys, `${name} ${p.virtual}`, (onCall, holds) => activeRecords() === undefined
                        ? runWithRecording(() => own(onCall, holds), null, this.namespace.clock).then(([value]) => value)
                        : own(onCall, holds), async (value) => {
                        served(report, value, payload?.moved ?? null);
                        await this.settleWrite(name, p, dst, fullArgs, others, value);
                        await this.keepWritten(call, mount, payload === null ? fullArgs[0] : payload.whole(), mine, payload === null || payload.moved > 0 ? before : null);
                    }, async () => {
                        await this.evictFailedWrite(name, p, dst, others, lostPaths(line), owners);
                    }, owners);
                }
                catch (err) {
                    await this.evictFailedWrite(name, p, dst, others, lostPaths(line));
                    throw err;
                }
            }
            else if (name === 'before_full_walk' && !(await isDirectory(mount, p))) {
                result = null;
            }
            else if (name === 'before_full_walk' && !vfs.supports(name)) {
                await this.fullWalkFiles(mount, vfs, call);
                result = null;
            }
            else {
                if (name === 'before_full_walk')
                    fullKwargs.files = await this.fullWalkFiles(mount, vfs, call);
                if (names(call, this.caller))
                    await this.reconciler.reconcileRead(mount, p.virtual);
                const answered = probed(call, mount);
                result = answered ?? (await run(fullKwargs));
                if (call.fresh) {
                    await this.reconciler.onFreshStat(mount, p.virtual, result instanceof FileStat ? result : null);
                }
                if (name === 'stat')
                    result = await sized(mount, p, result);
                else if (name === 'read_tree' && result instanceof Map) {
                    result = await sizedTree(mount, p, result);
                }
            }
        }
        catch (err) {
            const code = err.code;
            if (name === 'rmdir' && (code === 'ENOTEMPTY' || code === 'EEXIST')) {
                await this.rmdirRemnants(vfs, scope, mountPrefix, mode, err, call.ruleGate, call.issuer);
                result = null;
            }
            else {
                const fallback = isMissingPath(err) || isEnotdir(err) ? this.namespaceResult(name, p.virtual) : null;
                if (fallback === null) {
                    if (call.fresh && (isMissingPath(err) || isEnotdir(err))) {
                        await this.reconciler.onFreshStat(mount, p.virtual, null);
                    }
                    else {
                        await this.reconciler.onEnoent(mount, name, p.virtual, err);
                    }
                    throw err;
                }
                result = fallback;
                memoryAnswered(report);
            }
        }
        // The op ran, whatever invalidation, the post gate, or an output
        // cap do next: stamped here so a failure in any of them cannot
        // erase a transfer the backend already made.
        served(report, result);
        return [result, dst, fullArgs];
    }
    /**
     * Keep a whole write's bytes for the next read, under its name's hold.
     *
     * The write's own record labels them with the token the backend
     * answered, so a `fresh` mount does not refetch what it just wrote; a
     * record moving another length than was sent keeps nothing, and so does a
     * name another mount took while the write ran, read under the cache's lock.
     * The bytes kept are a copy, so a caller reusing its buffer cannot change
     * them. Mirrors Python's Dispatcher._keep_written.
     */
    /**
     * The cached copy an append may extend, read under its name's hold before
     * the append runs: its bytes and their token. Mirrors Python's
     * Dispatcher._cached_before.
     */
    async cachedBefore(call, mount) {
        if (call.name !== 'append' || !factsOf(mount).cacheable)
            return null;
        const kept = await this.cache.get(call.path.virtual);
        if (kept === null)
            return null;
        return [kept, (await this.cache.fingerprints([call.path.virtual]))[0] ?? null];
    }
    /** Drop content without losing the conditional-write baseline. */
    async invalidateStream(mount, path) {
        const manager = mount.cacheManager;
        if (manager === null)
            return;
        const version = await manager.readVersion(path);
        await manager.invalidate(path, FileChangeKind.UPDATE);
        if (version !== null)
            await manager.keepVersion(path, version);
    }
    /**
     * Keep a write's bytes for the next read, under its name's hold. The
     * write's own record labels them with the token the backend answered, so
     * a `fresh` mount does not refetch what it just wrote; a record moving
     * another length than was sent keeps nothing. An append extends the cached
     * copy only where the file is proven to have been that copy: the backend
     * rewrote it whole, its read saw the copy's token, and its write moved the
     * copy's length plus the appended bytes. A store with no tokens, or one
     * that appends by itself, proves nothing, so the copy stays dropped. An
     * append of nothing that wrote nothing (`cmd >> f` opening `f`) leaves the
     * copy as it was; one that made the file anew drops it, and so does a
     * streamed one, whose name another writer could take while it waited.
     * Mirrors Python's Dispatcher._keep_written.
     */
    async keepWritten(call, mount, sent, records, before) {
        if ((call.name !== 'write' && call.name !== 'append') || !factsOf(mount).cacheable) {
            return;
        }
        const record = recordOf(records, WRITE_FINGERPRINT_OPS, call.path.virtual);
        let fingerprint = record?.fingerprint ?? null;
        if (!(sent instanceof Uint8Array)) {
            if (fingerprint !== null)
                await mount.cacheManager?.keepVersion(call.path, fingerprint);
            return;
        }
        let data = sent.slice();
        if (call.name === 'append') {
            if (before === null)
                return;
            const [kept, token] = before;
            const seen = recordOf(records, new Set(['read']), call.path.virtual)?.fingerprint ?? '';
            if (sent.byteLength === 0 && record === undefined) {
                data = kept;
                fingerprint = token;
            }
            else if (record?.bytes !== kept.byteLength + sent.byteLength ||
                seen === '' ||
                seen !== token) {
                return;
            }
            else {
                data = new Uint8Array(kept.byteLength + sent.byteLength);
                data.set(kept);
                data.set(sent, kept.byteLength);
            }
        }
        else if (record !== undefined && record.bytes !== sent.byteLength) {
            await this.cache.remove(call.path.virtual);
            return;
        }
        await mount.cacheManager?.keepWritten(call.path, data, fingerprint);
    }
    /**
     * Merge the namespace into a backend answer and drop hidden names.
     *
     * A listing gains the child mounts and links the namespace holds below
     * it and loses every name the session hides; a stat gains the attribute
     * overlay recorded at its path. Mirrors Python's Dispatcher._filter.
     */
    filter(call, result) {
        if (call.name === 'readdir' && Array.isArray(result)) {
            return spelledUnder(call, visibleEntries(mergeReaddir(call.vis, result, this.namespace.mountPrefixes(), this.namespace, call.path.virtual), call.path.virtual, call.vis));
        }
        if (call.name === 'stat' && result instanceof FileStat) {
            const name = rstripSlash(call.path.virtual).split('/').pop() ?? '';
            // A mount's root answers as the backend's own root; in the namespace
            // it is the name it is mounted at.
            const row = result.name === '/' && name !== '' ? result.with({ name }) : result;
            return this.onDevice(call.name, call.path.virtual, mergeOverlayStat(this.namespace.metaFor(call.path.virtual), row));
        }
        if (call.name === 'read_tree' && result instanceof Map) {
            result = this.onDevice(call.name, call.path.virtual, this.overlaid(call.path, this.withLinks(call.path, result, call.vis)));
        }
        // A tree read keys its entries below the path; a search names each hit
        // itself, and a store can rank a hit from outside the scope (a whole
        // table, a filter it ignores), so every hit is checked.
        if (HIDE_FILTERED_OPS.has(call.name) &&
            result !== null &&
            result !== undefined &&
            (call.name !== 'read_tree' || hiddenUnder(call.vis, call.path.virtual))) {
            return visibleAnswer(call.name, call.vis, call.path, result);
        }
        return result;
    }
    /**
     * A stat, or a subtree's stats, on the device of the mount serving
     * `virtual`. The device is the id a runtime reports as a guest's st_dev,
     * so `stat %d` and `fs.stat` name one device, and a walk tells a mount
     * boundary by it. A path no mount serves is on the namespace's own, the
     * one a root mount would have. Anything but a stat or a subtree's stats
     * passes unchanged.
     */
    /**
     * A custom function's answer as the session may see it. A list of paths,
     * or of records with a `path`, loses what the session hides, as a
     * `files_containing` answer does. Any other answer cannot be told apart
     * from what it names, so it is declined while the session hides anything
     * under the call's path. No answer reveals nothing. Mirrors Python's
     * Dispatcher._custom_answer.
     */
    customAnswer(call, vfs, result) {
        if (result === null || result === undefined)
            return result;
        const named = answerPaths(result);
        if (named !== null) {
            return result.filter((_, i) => sees(call.vis, this.namespace, named[i] ?? ''));
        }
        if (hiddenUnder(call.vis, call.path.virtual))
            throw walkDeclined(vfs, call.name, call.path);
        return result;
    }
    onDevice(name, virtual, result) {
        if (name !== 'stat' && name !== 'read_tree')
            return result;
        if (!(result instanceof FileStat) && !(result instanceof Map))
            return result;
        const mount = this.namespace.tryMountFor(virtual);
        const dev = ident(mount === null ? '/' : mount.prefix);
        if (result instanceof FileStat)
            return result.with({ dev });
        const stamped = new Map();
        for (const [key, row] of result) {
            stamped.set(key, row instanceof FileStat ? row.with({ dev }) : row);
        }
        return stamped;
    }
    /**
     * A subtree read with the links the namespace holds below `path` on the
     * same mount, each as the link it is. No backend stores a link, so a walk
     * that reads the subtree in one call meets them only here, as a listing
     * meets them in `readdir`; a link that leads where the session cannot see
     * is left out, as a listing leaves it out. A link shadows what the backend
     * holds at its name and below it, as a stat of the name answers the link.
     * Mirrors Python's Dispatcher._with_links.
     */
    /** A subtree read with the attributes the overlay holds for each entry, the
     * ones a stat of that entry carries. */
    overlaid(path, tree) {
        const base = rstripSlash(path.virtual);
        const merged = new Map();
        for (const [rel, row] of tree) {
            merged.set(rel, mergeOverlayStat(this.namespace.metaFor(`${base}/${rel}`), row));
        }
        return merged;
    }
    withLinks(path, tree, vis) {
        const base = rstripSlash(path.virtual);
        const owner = this.namespace.tryMountFor(path.virtual);
        const links = this.namespace.symlinkTargets();
        const merged = new Map(tree);
        const candidates = this.namespace
            .linkStatsBelow(path.virtual)
            .filter(([virtual]) => this.namespace.tryMountFor(virtual) === owner);
        const visible = visibleLinks(vis, candidates.map(([virtual]) => virtual), links);
        for (const [index, [virtual, row]] of candidates.entries()) {
            if (!visible[index])
                continue;
            const rel = virtual.slice(base.length + 1);
            if (merged.has(rel)) {
                for (const key of [...merged.keys()])
                    if (key.startsWith(`${rel}/`))
                        merged.delete(key);
            }
            merged.set(rel, row);
        }
        return merged;
    }
    /**
     * Run one write with its names held, one writer at a time per name.
     *
     * The caller's abort covers only its wait for the hold: a writer the
     * abort finds queued lets go of any name it already holds and never
     * runs. One that comes after the abort (a stopped run landing what its
     * guest left open, within the stop's grace) waits its turn. One holding
     * its names runs to the end and the caller waits for it and for `after`
     * (the bookkeeping), since an abort cannot stop a store's call under way,
     * as Python's cancelled line is joined whole; an abort that lands during
     * that call still makes it late, since the line may have gone on past its
     * grace. `after` runs for a caller still waiting, and for a late call that
     * succeeded while every name is still on the mount that ran it (`owners`),
     * so a rename still moves its links; any other late call runs `landed`,
     * which drops what the landing made stale where its names still belong to
     * that mount (a read between the abort and the landing may have cached
     * the old bytes); a failure nobody waits for any more is reported.
     *
     * Args:
     *   keys: the names to hold, in the one order every writer takes them.
     *   label: the op and path, for a late failure's report.
     *   call: runs the op, handing over the store's own call once it starts.
     *   after: the bookkeeping, run while the caller still waits.
     *   landed: run once a call that outlived its timeout settles.
     *   owners: the mount each name was on when the call started.
     */
    async holdWrite(keys, label, call, after, landed, owners) {
        // The caller's abort (the line's deadline among its reasons) stops the
        // wait for the hold, so a write still queued never runs; a write made
        // after it is the caller's own choice and waits.
        const caller = callerSignal();
        const signal = caller?.aborted === true ? undefined : caller;
        const turn = { entered: false, abandoned: false, late: false, started: false, settled: false };
        let giveUp = () => undefined;
        const gaveUp = new Promise((resolve) => {
            giveUp = resolve;
        });
        let answer = Promise.resolve();
        let finished = Promise.resolve();
        const entered = new Promise((enter) => {
            const held = async (holds) => {
                if (turn.abandoned)
                    return;
                turn.entered = true;
                const started = {};
                answer = call((storeCall) => {
                    started.call = storeCall;
                    turn.started = true;
                    storeCall.then(() => {
                        turn.settled = true;
                    }, () => {
                        turn.settled = true;
                    });
                }, holds);
                // The store's own call once it started, else the run's answer: a
                // timeout answers the caller, not the store.
                const stored = answer.then(() => started.call ?? answer, () => started.call ?? answer);
                finished = stored.then(async (value) => {
                    if (turn.late && !this.stillOwned(owners))
                        await landed();
                    else
                        await after(value);
                }, async (err) => {
                    if (turn.late)
                        await landed();
                    throw err;
                });
                enter();
                await finished.catch((err) => {
                    if (turn.late)
                        console.warn(`${label} failed after its timeout: ${String(err)}`);
                });
            };
            void keys.reduceRight((inner, key) => (holds) => this.state.writers.withLock(key, (hold) => Promise.race([inner([...holds, hold]), gaveUp])), held)([]);
        });
        try {
            await abortable(entered, signal);
        }
        catch (err) {
            if (!turn.entered) {
                turn.abandoned = true;
                giveUp();
                throw err;
            }
        }
        const lateOnAbort = () => {
            if (turn.started && !turn.settled)
                turn.late = true;
        };
        signal?.addEventListener('abort', lateOnAbort, { once: true });
        let value;
        try {
            value = await answer;
        }
        catch (err) {
            lateOnAbort();
            throw err;
        }
        finally {
            signal?.removeEventListener('abort', lateOnAbort);
        }
        await finished;
        return value;
    }
    /**
     * What a write changes beside the store: the caches above the path, and
     * the node table's links and attributes at its names. Mirrors Python's
     * Dispatcher._settle_write.
     */
    /**
     * What a write changes beside the store: the caches above the path, and the
     * node table's links and attributes at its names. A copy reads its path, so
     * only its destination changes. A create that answers its new entry's stat
     * (`created`) has it kept where its parent's listing names it, in place of
     * dropping that listing for a re-list: the create is the one change to it,
     * and the next level of a nested create finds its parent there. Mirrors
     * Python's Dispatcher._settle_write.
     */
    async settleWrite(name, p, dst, args, others, created = null) {
        const opened = appendsNothing(name, args);
        const observed = STAMP_WRITE_OPS.has(name) && !opened ? systemNow() : null;
        const removes = name === 'unlink' || name === 'rmdir' || name === 'rm_r';
        const row = created instanceof FileStat ? created : null;
        // A copy reads its path, so only its destination changes: the copy is a
        // new file there, or a new tree, written now.
        if (COPY_OPS.has(name) && dst !== null) {
            const copied = systemNow();
            await this.changed(dst.virtual, FileChangeKind.CREATE, row, { observed: copied });
            if (name === 'dir_copy')
                await this.settleTreeCopy(p, dst, copied);
            return;
        }
        // rename(2) moves a file without touching its times, which the node
        // table carries to the new name below. rm_r takes everything below with
        // it: the cached bodies and listings, and the links and overlays only the
        // node table holds there.
        await this.changed(p.virtual, name === 'rm_r'
            ? FileChangeKind.UNKNOWN
            : removes
                ? FileChangeKind.DELETE
                : FileChangeKind.CREATE, name === 'rename' ? null : row, { observed, times: !opened && name !== 'rename' });
        for (const [, other] of others)
            await this.changed(other.virtual, FileChangeKind.UPDATE);
        if (name === 'rm_r')
            await this.namespace.purgeUnder(p.virtual);
        if (removes) {
            // The name no longer holds that file, so what was set on it
            // (overlay mode and owner, extended attributes) goes with it, as
            // the shell's rm already drops it: a file created there next
            // starts bare on every surface.
            await this.namespace.dropOverlay(p.virtual);
            if (name === 'rmdir') {
                // The link check ran before the backend was asked, so a visible
                // link below now was created since: it is younger than this
                // rmdir, lands after it in the serial order (a link synthesizes
                // its parents), and the purge taking the directory's hidden nodes
                // must not take it too.
                const arrived = new Set();
                const vis = sessionVisibility(this.access);
                for (const [link] of this.namespace.linkStatsBelow(p.virtual)) {
                    if (sees(vis, this.namespace, link))
                        arrived.add(link);
                }
                await this.namespace.purgeUnder(p.virtual, arrived);
            }
        }
        if (name === 'rename' && dst !== null) {
            // A rename re-anchors the whole subtree under its source, so what was
            // cached one level down under either name is stale, not just the two
            // paths and their parents: left there, a moved directory's old name
            // answered `stat` and `ls` from its cached children.
            for (const moved of [p, dst]) {
                await this.changed(moved.virtual, FileChangeKind.UNKNOWN, null, { times: false });
            }
            if (row !== null) {
                await this.changed(dst.virtual, FileChangeKind.CREATE, row, { times: false });
            }
            // rename(2) replaces the destination, so a node the table holds
            // at that name does not survive the move. A link left there
            // shadowed the file that had just landed: the listing showed the
            // new file, every read followed the old link, and the moved
            // content was reachable under no name at all.
            await this.namespace.unlink(dst.virtual);
            // The subtree moves with it, and only the node table can move the
            // part of it no backend holds: a link or an attr overlay below the
            // source is addressed by absolute path, so it would otherwise stay
            // behind at a name the rename has emptied. The destination's own
            // subtree is replaced first, as rename(2) replaces what it lands on.
            await this.namespace.purgeUnder(dst.virtual);
            // The node at the source itself is not part of the subtree below it,
            // so re-anchoring that subtree leaves it behind: the mode or ownership
            // a chmod recorded stayed at the emptied name, never reached the
            // landing, and was inherited by whatever was created at the old name
            // next. Shell mv compensates for this in its own prepare step; a verb
            // reaching the dispatcher directly, as git mv does, had nothing to.
            await this.namespace.rename(p.virtual, dst.virtual);
            await this.namespace.renameUnder(p.virtual, dst.virtual);
        }
    }
    /**
     * The mount serving each of a write's names as it starts, keyed by the
     * name, for the cleanup after a call that lands past its timeout.
     */
    /** Whether each name is still on the mount it was on in `owners`. */
    stillOwned(owners) {
        for (const [virtual, mount] of owners) {
            if (this.namespace.tryMountFor(rstripSlash(virtual) || '/') !== mount)
                return false;
        }
        return true;
    }
    owners(paths) {
        return new Map(paths.map((path) => [
            path.virtual,
            this.namespace.tryMountFor(rstripSlash(path.virtual) || '/'),
        ]));
    }
    /**
     * Drop what a write that threw may have made stale.
     *
     * A write that fails partway may already have changed the store (a
     * paginated delete, a copy that merged some children), so the caches at
     * and below every name it reaches go either way; the node table moves only
     * with a write that landed. The version the line held for each name is
     * kept, so the next write there stays conditioned on what the agent read,
     * and so is each version `lost` marks under them, which a refused
     * conditional write kept: a retry without a read is refused again.
     * Over-dropping costs a refetch. An eviction error is logged, not thrown,
     * so the caller still learns why the write failed. `owners`, for a call
     * that landed after its timeout, holds the mount each name was on when the
     * call started: a name another mount has taken since is that mount's now,
     * so nothing at or under it is touched. Mirrors Python's
     * Dispatcher._evict_failed_write, whose timed-out call is cancelled and
     * never lands late.
     */
    async evictFailedWrite(name, p, dst, others, lost, owners = null) {
        const names = [
            ...(COPY_OPS.has(name) ? [] : [p]),
            ...(dst !== null && DESTINATION_OPS.has(name) ? [dst] : []),
            ...others.map(([, other]) => other),
        ];
        const taken = (target, mount) => owners !== null && owners.get(target.virtual) !== mount;
        for (const target of names) {
            try {
                const path = rstripSlash(target.virtual) || '/';
                const mount = this.namespace.tryMountFor(path);
                if (mount === null || taken(target, mount))
                    continue;
                const manager = mount.cacheManager;
                const spec = PathSpec.fromStrPath(path);
                const version = manager === null ? null : await manager.readVersion(spec);
                await this.changed(path, FileChangeKind.UNKNOWN, null, { times: false });
                if (manager !== null && version !== null && version !== '') {
                    await manager.keepVersion(spec, version);
                }
            }
            catch (err) {
                console.debug(`evicting after a failed ${name}: ${String(err)}`);
            }
        }
        if (lost === null)
            return;
        for (const key of [...lost.marks.keys()]) {
            const version = lost.version(key);
            const target = names.find((written) => underPath(key, written.virtual));
            if (version === null || target === undefined)
                continue;
            try {
                const mount = this.namespace.tryMountFor(key);
                if (mount === null || taken(target, mount))
                    continue;
                await mount.cacheManager?.keepVersion(PathSpec.fromStrPath(key), version);
            }
            catch (err) {
                console.debug(`keeping ${key} after a refusal: ${String(err)}`);
            }
        }
    }
    /**
     * What a tree copy changed under `dst`: each entry it wrote. The copy
     * merges into what `dst` already held, so a file it left keeps its cached
     * copy and the version a conditional write needs; the files and
     * directories it wrote are the source's, listed after the copy, so an
     * empty directory it made drops its folder's listing too. Mirrors Python's
     * Dispatcher._settle_tree_copy.
     */
    async settleTreeCopy(src, dst, observed) {
        const mount = this.namespace.tryMountFor(src.virtual);
        if (mount === null)
            return;
        const base = rstripSlash(src.virtual);
        const landing = rstripSlash(dst.virtual);
        for (const virtual of await this.entriesBelow(mount, src.virtual)) {
            await this.changed(landing + virtual.slice(base.length), FileChangeKind.CREATE, null, {
                observed,
            });
        }
    }
    /**
     * Every file and directory at any depth under `root`, through the mount's
     * own listing.
     */
    async entriesBelow(mount, root) {
        const found = [];
        const pending = [root];
        for (let directory = pending.pop(); directory !== undefined; directory = pending.pop()) {
            const entries = (await mount.call('readdir', directory));
            for (const entry of entries) {
                const child = rstripSlash(entry);
                found.push(child);
                const row = (await mount.call('stat', child));
                if (row.type === FileType.DIRECTORY)
                    pending.push(child);
            }
        }
        return found;
    }
    /**
     * A number naming one store object, the same for every mount of it in every
     * workspace, as Python's id() of the store is.
     */
    storeId(vfs) {
        const known = Dispatcher.stores.get(vfs);
        if (known !== undefined)
            return known;
        Dispatcher.storeCount += 1;
        Dispatcher.stores.set(vfs, Dispatcher.storeCount);
        return Dispatcher.storeCount;
    }
    /**
     * Whether the node table answers this op instead of a backend.
     *
     * `symlink` and `readlink` always, because a link exists nowhere else.
     * The rest only when the path itself is a link, and then for the same
     * reason the create and the read are the dispatcher's: forwarding reaches a
     * backend that has never heard of the name. A no-follow stat is the
     * read half of that fact (lstat asks for the link's own row, which
     * only the table holds); a following stat never arrives here, since
     * the follow below rewrote it to the target. Mirrors Python's
     * Dispatcher._table_answers.
     */
    /**
     * The index kwargs normal dispatch stamps on every registered op, for
     * the dispatcher's own raw registry calls: an indexed backend cannot
     * resolve a nested path without it.
     */
    indexKwargs(mount) {
        const index = mount?.index;
        return index !== undefined ? { index } : {};
    }
    /** The policy boundary for an op on a path `mount` owns: the mount's
     * prefix and mode, or for a path above every mount an empty prefix and
     * full write, governed by `/` (`MountModePolicy`). Mirrors Python's
     * Dispatcher._boundary. */
    boundary(mount) {
        return new Boundary(this.policies, mount?.prefix ?? '', mount?.mode ?? MountMode.WRITE, this.sessionId(), this.decisions, this.access, this.caller ?? undefined);
    }
    /**
     * The dispatcher's own channel for internal walks: the TS twin of Python's
     * Mount.call plus the dispatcher-side duties around it. The
     * same mode fence, index stamping and mount-prefix context normal
     * dispatch applies, plus the boundary's admission and completion for
     * writes (Python's `_MountChannel` holds the same `Boundary`) and the
     * dispatcher's own write invalidation, because a raw mount call
     * runs outside the cache context dispatch establishes, so the cores'
     * invalidation cannot land. Invalidation runs even when the op
     * fails: a missing-path failure means the tree changed under the
     * walk, and the walk's own earlier listing is exactly the entry that
     * must not survive. Only the visibility filter stays off, which is
     * what lets a remnant walk see hidden entries. Every internal
     * backend call in this class routes through here; a bare
     * `callKeyed` outside dispatch is a bug.
     */
    async fencedCall(vfs, mountPrefix, mode, name, spec, issuer, kwargs = {}) {
        const mount = this.namespace.mountFor(spec.virtual);
        const mark = declared(vfs.constructor, name) ?? CALLS.get(name) ?? null;
        const write = mark !== null && WRITE_EFFECTS.includes(mark.effect);
        const boundary = new Boundary(this.policies, mountPrefix, mode, this.sessionId(), this.decisions, this.access, this.caller ?? undefined);
        if (mark !== null && write) {
            // The same pre-vfs admission a dispatched op answers, with the
            // walk's own child path: the gate that admitted the rmdir judged
            // the directory, not what the cascade found under it, and a
            // policy that protects one of those paths must refuse its
            // deletion exactly as it would refuse a first-class op. The
            // caller folds the denial into its original refusal, so a
            // policy's protection of a hidden path never surfaces as its own
            // denial.
            await boundary.admit(mark, spec, true, {}, issuer);
        }
        // The fence reruns backend ops outside `dispatch`, so the mount's frame
        // has to ride here as on the main path above, or a cascade read answers
        // from the wrong version of a revision-pinned mount. Python's twin gets
        // it from `Mount.call`.
        await mount.ensureReady();
        try {
            const result = await mount.use(() => mount.framed(() => mount.callKeyed(name, spec, [], {
                ...this.indexKwargs(mount),
                ...kwargs,
            }, this.access)));
            // A deletion is not completed through postVfs, which could only
            // refuse after the entry is gone and strand the cascade.
            return result;
        }
        finally {
            if (write) {
                await this.changed(spec.virtual, name === 'unlink' || name === 'rmdir' ? FileChangeKind.DELETE : FileChangeKind.UPDATE);
            }
        }
    }
    /**
     * Whether a rename's source stats as a directory.
     *
     * Only a directory can carry hidden content into view, so the reveal
     * refusal probes the source before it fires and lets a file rename
     * pass. An absent source moves nothing (the rename itself reports
     * it); a source the mount cannot classify fails toward refusal, the
     * same stance the pattern arm takes. Mirrors Python's
     * Dispatcher._moved_source_is_dir.
     */
    async movedSourceIsDir(path, issuer) {
        let resolved;
        try {
            resolved = await this.namespace.resolve(path.virtual, false);
        }
        catch {
            // No mount to ask; classification fails toward refusal.
            return true;
        }
        const [vfs, scope, mode] = resolved;
        let row;
        try {
            row = await this.fencedCall(vfs, this.namespace.mountFor(path.virtual).prefix, mode, 'stat', scope, issuer);
        }
        catch (err) {
            // An absent source moves nothing; the rename itself reports it.
            if (isMissingPath(err) || isEnotdir(err))
                return false;
            // Unanswerable classification fails toward refusal.
            return true;
        }
        return !(row instanceof FileStat) || row.type === FileType.DIRECTORY;
    }
    /**
     * Take a visibly-empty directory's hidden remnants with it.
     *
     * The backend refused the rmdir because entries remain, but when the
     * session's view of the directory is empty the refusal would leak
     * that something invisible exists. A session's mutation may destroy
     * what it cannot see, never learn of it, so the remnants go with the
     * directory through the shared removeRemnants walk; a visible child,
     * or any cascade failure (a mode-protected entry, a visible entry
     * appearing mid-walk), re-raises the backend's refusal. Emptiness is
     * the dispatcher's own readdir pipeline: backend entries merged with the
     * namespace's children (nested mounts, links) and judged by
     * visibility, so a visible child no backend can see keeps the
     * refusal instead of reporting a successful rmdir while the mounted
     * child remains. The namespace's own hidden nodes under the subtree
     * (links, attr overlays) are purged with it, so the removed tree
     * cannot resurface from the node table once the hide lifts.
     */
    async rmdirRemnants(vfs, path, mountPrefix, mode, refusal, ruleGate, issuer) {
        const vis = sessionVisibility(this.access);
        if (!hiddenUnder(vis, path.virtual))
            throw refusal;
        let entries;
        try {
            entries = await this.fencedCall(vfs, mountPrefix, mode, 'readdir', path, issuer);
        }
        catch {
            // A backend that cannot list (or later, remove) the remnants
            // keeps the original refusal: the dispatcher has no way to take them.
            throw refusal;
        }
        if (!Array.isArray(entries))
            throw refusal;
        const names = entries.map(String);
        const merged = mergeReaddir(vis, names, this.namespace.mountPrefixes(), this.namespace, path.virtual);
        const visible = (virtual) => pathVisible(vis, virtual);
        if (names.length === 0 || visibleBelow(path.virtual, merged, visible))
            throw refusal;
        const channel = {
            readdir: async (at) => {
                const listed = await this.fencedCall(vfs, mountPrefix, mode, 'readdir', at, issuer);
                return Array.isArray(listed) ? listed.map(String) : [];
            },
            stat: (at) => this.fencedCall(vfs, mountPrefix, mode, 'stat', at, issuer),
            // The command's path rules judge each deletion too: the gate that
            // admitted the rmdir judged the directory, not what is under it.
            unlink: async (at) => {
                ruleGate?.check(at.virtual);
                await this.fencedCall(vfs, mountPrefix, mode, 'unlink', at, issuer);
            },
            rmdir: async (at) => {
                ruleGate?.check(at.virtual);
                await this.fencedCall(vfs, mountPrefix, mode, 'rmdir', at, issuer);
            },
        };
        try {
            await removeRemnants(channel, visible, path);
        }
        catch {
            throw refusal;
        }
        // The namespace's own nodes under the subtree go with it: a hidden
        // link is invisible to every backend, so the walk above cannot
        // take it, and left in the table it would resurface the removed
        // tree the moment the hide lifts (a link synthesizes its
        // ancestors). Classification proved every link below is hidden --
        // a visible one contributes its child segment to the merged
        // listing above -- so this is the walk's own revalidate-then-
        // destroy applied to the name plane: a link that became visible
        // mid-cascade keeps the refusal like any visible remnant, and the
        // purge also drops the attr overlays of paths the cascade just
        // destroyed, as `rm` does.
        const base = rstripSlash(path.virtual) + '/';
        for (const link of this.namespace.symlinkTargets().keys()) {
            if (link.startsWith(base) && sees(vis, this.namespace, link))
                throw refusal;
        }
        await this.namespace.purgeUnder(path.virtual);
    }
    /**
     * `path` with the links above its final name followed.
     *
     * The walked path answers to the session's hides as the typed one did,
     * the rule the follow of the final name applies too: a visible link must
     * not lead into hidden space. Throws `eloop` when a link above the name
     * loops (ELOOP), as the coded error every caller's per-operand catch
     * words. Mirrors Python's Dispatcher._walked.
     */
    walked(path, create) {
        const spelled = walkSpelling(path, (p) => this.namespace.follow(p));
        let walked = followOrLoop(this.namespace, path, false, spelled);
        if (spelled !== path.virtual)
            walked = posixNormpath(walked);
        if (walked === path.virtual)
            return path;
        const vis = sessionVisibility(this.access);
        if (!pathVisible(vis, walked))
            throw hiddenRefusal(vis, walked, create);
        return PathSpec.fromStrPath(walked);
    }
    tableAnswers(name, virtual, kwargs) {
        if (NAMESPACE_TABLE_OPS.has(name))
            return true;
        if (!LINK_ENTRY_OPS.has(name))
            return false;
        if (name === 'stat' && kwargs?.nofollow !== true)
            return false;
        return this.namespace.isLink(virtual);
    }
    /**
     * Answer a node-table op at the dispatcher itself, gated like a backend.
     *
     * A symlink is namespace state with no backend behind it, so the dispatcher
     * owns every verb that names one. Admission still fires exactly as for
     * a backend write: the link's turf is the longest mount prefix above it
     * (the same ownership rule the link read filter uses), session grants
     * and both gates run, and the write leaves an OpRecord — a scoped
     * kernel mount refuses exactly like a scoped shell. The turf's mode
     * gates the write too (`MountModePolicy` at the `Boundary`), so a
     * read-only mount or grant answers EROFS for a link exactly as for a
     * file; a link above every mount is bare namespace structure, gated
     * with an empty prefix and governed by `/`. A rename's
     * destination is judged on its own turf, since the endpoints need not
     * share one. Also answers the `unlink`, `rename` and no-follow
     * `stat` of a path the node table holds a link for. Mirrors Python's
     * Dispatcher._namespace_table_op.
     */
    async tableCall(name, path, args, kwargs, report, issuer) {
        const timer = startOp();
        const mount = this.namespace.tryMountFor(path.virtual);
        const boundary = this.boundary(mount);
        const write = POLICY_WRITE_OPS.has(name);
        const mark = CALLS.get(name);
        if (mark === undefined)
            throw enotsup('namespace', name, path);
        await boundary.admit(mark, path, write, { final: name !== 'rename' }, issuer);
        let target;
        let result = null;
        if (name === 'unlink') {
            target = this.namespace.readlink(path.virtual) ?? '';
            await this.namespace.unlink(path.virtual);
        }
        else if (name === 'rename') {
            target = this.namespace.readlink(path.virtual) ?? '';
            const dst = args[0];
            if (!(dst instanceof PathSpec))
                throw new Error('rename op requires dst');
            // The destination is a create there, gated like the source and on
            // its own turf, the way the backend path gates both ends of a
            // rename. It is then replaced as rename(2) replaces it: any node
            // the table holds at that name (a link, an attr overlay) goes.
            const dstMount = this.namespace.tryMountFor(dst.virtual);
            await this.boundary(dstMount).admit({ ...mark, creates: true }, dst, true, {}, issuer);
            // The name the link moves to must have a directory above it, as for
            // a new link: the table alone would file it under an absent parent
            // and synthesize the directories above it.
            const refusal = await this.parentRefusal(dst, issuer);
            if (refusal !== null)
                throw refusal;
            if (!this.namespace.isLink(dst.virtual)) {
                const kind = await this.entryType(dst.virtual, issuer);
                if (kind === FileType.DIRECTORY)
                    throw eisdir(dst);
                if (kind !== null)
                    await this.dispatch('unlink', dst, [], issuer === undefined ? {} : { issuer });
            }
            await this.namespace.unlink(dst.virtual);
            await this.namespace.rename(path.virtual, dst.virtual);
        }
        else if (name === 'symlink') {
            target = String(kwargs.target);
            // symlink(2) refuses an occupied name and a name its parent cannot
            // hold, and the dispatcher is the only place that can tell: the node table
            // sees a link, and a probe sees what a backend holds. Left unchecked,
            // the new node shadowed live data (the bytes stayed, the name read as
            // a link), could bury a mount root, which is the one name a
            // deployment configured, and under an absent parent was an orphan
            // that invented the directories above it.
            const refusal = await this.symlinkRefusal(path, issuer);
            if (refusal !== null)
                throw refusal;
            await this.namespace.symlink(path.virtual, target, systemNow());
        }
        else if (name === 'stat') {
            const row = this.namespace.linkStatAt(path.virtual);
            if (row === null)
                throw enoent(path);
            target = this.namespace.readlink(path.virtual) ?? '';
            result = row;
        }
        else {
            const found = this.namespace.readlink(path.virtual);
            if (found === null)
                throw await this.readlinkMiss(path, issuer);
            target = found;
            result = found;
        }
        record(name, path.virtual, VFSName.RAM, encodeText(target).byteLength, timer);
        memoryAnswered(report);
        return (await boundary.complete(mark, path, write, result, issuer));
    }
    /**
     * The error a readlink of something that is not a link answers.
     *
     * readlink(2) splits the two misses and callers read them differently:
     * a path that is there but is not a link is EINVAL, and one that is
     * not there at all is ENOENT, which is the code a guest's
     * `except FileNotFoundError` catches. The node table only knows the
     * first half, so absence is probed here and only here, on the failure
     * path, where one extra round trip buys the right errno. Mirrors
     * Python's Dispatcher._readlink_miss.
     */
    async readlinkMiss(path, issuer) {
        const [present] = await this.occupancy(path, issuer);
        return present ? einval(path) : enoent(path);
    }
    /**
     * Whether anything at all is at `path`, and the parent's listing.
     *
     * Four channels, asked in the order of what they prove. The namespace
     * goes first: a link, and a directory that exists only because a
     * mount or a link sits below it, are structure no backend can see,
     * and a mount root is the deployment's own configuration. Then the
     * backend's row, which settles a file. A directory row settles
     * nothing, because an API tree synthesizes its directories: a
     * postgres schema lists `tables/` and `views/` before anything has
     * asked whether that schema is there, and a grouping mount stats
     * every path under a live collection as a directory. So a directory
     * is proven the way the hierarchy kit itself proves one, by appearing
     * in its parent's listing, which is also the only way a prefix store
     * can answer for a directory that is nothing but a set of keys.
     * Cannot reuse `resolvePathStat`: that dispatches, and the dispatcher is
     * what dispatch is inside of.
     *
     * The parent's listing comes back beside the answer, null when no probe
     * reached it or it gave none, because a listing with entries in it also
     * proves the parent a directory: a create in a directory that holds
     * anything costs no round trip beyond this one. Mirrors Python's
     * Dispatcher._occupancy.
     */
    async occupancy(path, issuer) {
        if (this.namespace.isLink(path.virtual))
            return [true, null];
        const prefixes = this.namespace.mountPrefixes();
        if (namespaceStat(sessionVisibility(this.access), prefixes, this.namespace, path.virtual) !== null) {
            return [true, null];
        }
        const mount = this.namespace.tryMountFor(path.virtual);
        // Only "no mount serves this path" is the absence being probed for.
        if (mount === null)
            return [false, null];
        const resolved = await this.namespace.resolve(path.virtual, false);
        if (normDir(mount.prefix) === normDir(path.virtual))
            return [true, null];
        let listing;
        try {
            const row = (await this.probeRead('stat', resolved, issuer));
            if (row !== null && row.type !== FileType.DIRECTORY)
                return [true, null];
            listing = await this.parentListing(path.virtual, issuer);
        }
        catch (err) {
            if (!(err instanceof PolicyDenied) && !(err instanceof PolicyError))
                throw err;
            // A channel that refuses to answer is not evidence of absence.
            // Reporting "present" keeps the answer at the EINVAL every miss
            // gave before the split, which asserts nothing the policy is
            // withholding; reporting absence would assert a fact the dispatcher was
            // not allowed to check.
            return [true, null];
        }
        return [listing !== null && lists(listing, path.virtual), listing];
    }
    /**
     * What symlink(2) answers instead of making a link at `path`.
     *
     * Null when the link can be made. The name must be free (EEXIST) and
     * its parent a directory (`parentRefusal`), both read off the probes
     * `occupancy` makes: a parent whose listing answered with entries is a
     * directory, so only an empty or silent parent is walked, which is the
     * failure path nearly always. Mirrors Python's
     * Dispatcher._symlink_refusal.
     */
    async symlinkRefusal(path, issuer) {
        const [present, listing] = await this.occupancy(path, issuer);
        if (present)
            return eexist(path);
        if (listing !== null && listing.length > 0)
            return null;
        return this.parentRefusal(path, issuer);
    }
    /**
     * The errno the parent chain of a name being created answers.
     *
     * symlink(2) and rename(2) resolve the directory a name goes in before
     * they look at the name: ENOENT when it is absent and ENOTDIR when a
     * non-directory stands anywhere in the chain. The chain is walked
     * upward until something is there, as `destKind` walks a copy's
     * destination, because a store answers a path under a plain file with
     * the same miss as an absent one: the parent itself being a directory
     * is the one clean answer, a directory higher up means the components
     * below it are absent, and anything else is ENOTDIR. Null when the
     * parent is a directory, and when a policy closes a channel, which
     * proves nothing either way. Mirrors Python's
     * Dispatcher._parent_refusal.
     */
    async parentRefusal(path, issuer) {
        const immediate = parent(norm(path.virtual));
        let node = immediate;
        let kind;
        try {
            kind = await this.entryType(node, issuer);
            while (kind === null) {
                node = parent(node);
                kind = await this.entryType(node, issuer);
            }
        }
        catch (err) {
            if (err instanceof PolicyDenied || err instanceof PolicyError)
                return null;
            if (!isEnotdir(err))
                throw err;
            // A store that sees the file in the chain answers the probe itself
            // with ENOTDIR, which is the verdict.
            kind = FileType.FILE;
        }
        if (kind !== FileType.DIRECTORY)
            return enotdir(path);
        return node === immediate ? null : enoent(path);
    }
    /**
     * The type of what stands at `virtual`, null when nothing does.
     *
     * The channels `occupancy` asks, for a path the walk above a new name
     * reaches: namespace structure and a mount root are directories, then
     * the backend's row, then the path's own entry in its parent's
     * listing, which is how a prefix store holds a directory that is
     * nothing but a set of keys. Mirrors Python's Dispatcher._entry_type.
     */
    async entryType(virtual, issuer) {
        if (virtual === '/')
            return FileType.DIRECTORY;
        const prefixes = this.namespace.mountPrefixes();
        if (namespaceStat(sessionVisibility(this.access), prefixes, this.namespace, virtual) !== null) {
            return FileType.DIRECTORY;
        }
        const mount = this.namespace.tryMountFor(virtual);
        if (mount === null)
            return null;
        if (normDir(mount.prefix) === normDir(virtual))
            return FileType.DIRECTORY;
        const resolved = await this.namespace.resolve(virtual, false);
        const row = (await this.probeRead('stat', resolved, issuer));
        if (row !== null)
            return row.type;
        const listing = await this.parentListing(virtual, issuer);
        return listing !== null && lists(listing, virtual) ? FileType.DIRECTORY : null;
    }
    /**
     * The backend listing of the directory `virtual` sits in: null when no
     * mount serves that directory, its backend lists nothing there, or the
     * path has no name to sit in one. Mirrors Python's
     * Dispatcher._parent_listing.
     */
    async parentListing(virtual, issuer) {
        const trimmed = rstripSlash(virtual);
        const cut = trimmed.lastIndexOf('/');
        const name = trimmed.slice(cut + 1);
        if (cut < 0 || name === '')
            return null;
        const above = trimmed.slice(0, cut) || '/';
        if (this.namespace.tryMountFor(above) === null)
            return null;
        const resolved = await this.namespace.resolve(above, false);
        const entries = await this.probeRead('readdir', resolved, issuer);
        return Array.isArray(entries) ? entries.map(String) : null;
    }
    /**
     * Run one read op for a probe, or null when it found nothing.
     *
     * The probe reads on the caller's behalf but not at its request, so it
     * meets what the op would at the dispatcher: a path the caller cannot
     * see answers as the walk answers it (`sees`), and a policy that denies
     * `stat` must not be reachable through a readlink. That refusal is
     * raised, not swallowed, because only the caller knows what to answer
     * when a channel goes dark.
     *
     * The index and the path's filetype are the two kwargs that decide
     * which registered op answers, so a probe that omitted them would ask
     * a different question than the dispatcher does and report a rendered path
     * as absent. Python needs no twin of that half: its dispatcher routes
     * through `Mount.call`, which stamps both itself.
     *
     * Args:
     *   name: the function to run, `stat` or `readdir`.
     *   resolved: what the namespace resolved the path to.
     *   issuer: the mark on the op being served, carried to the probe's
     *     gate so the probe is judged as its caller's.
     */
    async probeRead(name, resolved, issuer) {
        const [vfs, scope] = resolved;
        const mount = this.namespace.tryMountFor(scope.virtual);
        if (mount === null)
            return null;
        const mark = declared(vfs.constructor, name) ?? CALLS.get(name) ?? null;
        if (mark === null)
            throw enotsup(vfs.name, name, scope);
        const vis = sessionVisibility(this.access);
        if (!sees(vis, this.namespace, scope.virtual)) {
            throw hiddenRefusal(vis, scope.virtual, false, this.namespace.symlinkTargets());
        }
        const boundary = this.boundary(mount);
        await boundary.admit(mark, scope, false, {}, issuer);
        await mount.ensureReady();
        const filetype = getExtension(scope.virtual);
        try {
            const result = await mount.use(() => mount.callKeyed(name, scope, [], {
                ...this.indexKwargs(mount),
                ...(filetype !== null ? { filetype } : {}),
            }));
            return await boundary.complete(mark, scope, false, result, issuer);
        }
        catch (err) {
            // Final on every channel: a plain file above the path means nothing
            // can be at it or under it, and symlink(2) and readlink(2) answer
            // with this errno.
            if (isEnotdir(err))
                throw err;
            // The "nothing here" set exactly, plus a backend with no such op:
            // a miss on one channel is not absence on its own, so the caller
            // tries the other.
            if (isMissError(err) || isMissingOp(err, name))
                return null;
            throw err;
        }
    }
    /**
     * Answer an extended-attribute op from the node table.
     *
     * The attributes a caller sets live on the path's node, so they
     * survive on a backend that has no such slot and move with a rename;
     * the backend's own facts are read off its stat on every call and
     * refused to writers with EPERM, the answer for an attribute the
     * filesystem keeps for itself. The listing is sorted, so both hosts
     * and every backend agree on its order. Gated like a setattr: both
     * admission gates fire on the path's turf, and a write needs a
     * writable turf. Mirrors Python's Dispatcher._xattr_op.
     *
     * Args:
     *   name: `getxattr`, `listxattr`, `setxattr` or `removexattr`.
     *   path: the path, already followed unless the caller asked for its
     *     link node itself.
     *   kwargs: `name` for all but listxattr, `value` and the
     *     `create`/`replace` flags for setxattr.
     *   report: the caller's report.
     *   issuer: the mark on the op being served.
     */
    async answerXattr(name, path, kwargs, report, issuer) {
        const timer = startOp();
        const mount = this.namespace.tryMountFor(path.virtual);
        const boundary = this.boundary(mount);
        const write = POLICY_WRITE_OPS.has(name);
        const mark = CALLS.get(name);
        if (mark === undefined)
            throw enotsup('namespace', name, path);
        await boundary.admit(mark, path, write, {}, issuer);
        await this.xattrTarget(mount, path);
        const stored = this.namespace.xattrs(path.virtual);
        const attr = typeof kwargs.name === 'string' ? kwargs.name : '';
        let result = null;
        if (name === 'listxattr') {
            result = [...stored.keys()].sort(compareCodePoints);
        }
        else if (name === 'getxattr') {
            const found = stored.get(attr);
            if (found === undefined)
                throw noXattr(path.virtual);
            result = found;
        }
        else if (name === 'setxattr') {
            if (kwargs.create === true && stored.has(attr))
                throw eexist(path.virtual);
            if (kwargs.replace === true && !stored.has(attr))
                throw noXattr(path.virtual);
            const value = kwargs.value instanceof Uint8Array ? kwargs.value : new Uint8Array();
            await this.namespace.setXattr(path.virtual, attr, value);
        }
        else {
            if (!stored.has(attr))
                throw noXattr(path.virtual);
            await this.namespace.removeXattr(path.virtual, attr);
        }
        record(name, path.virtual, VFSName.RAM, result instanceof Uint8Array ? result.byteLength : 0, timer);
        report?.served(null, null);
        return boundary.complete(mark, path, write, result, issuer);
    }
    /**
     * Answer statfs(2) for a path: the type name and the capacity of the
     * mount that holds it, which is what df reports for that mount. The path
     * must exist, as statfs's own walk requires. The namespace above every
     * mount has no file system behind it, so its type is `-` and its
     * capacity unknown. Mirrors Python's Dispatcher._statfs.
     *
     * Args:
     *   path: the path, already followed.
     *   issuer: the mark on the op being served.
     */
    async statfs(path, issuer) {
        const mount = this.namespace.tryMountFor(path.virtual);
        const boundary = this.boundary(mount);
        const statfs = {
            name: 'statfs',
            effect: Effect.METADATA,
            target: Target.ANY,
            creates: false,
            subtree: false,
        };
        await boundary.admit(statfs, path, false, {}, issuer);
        await this.xattrTarget(mount, path);
        const answer = mount === null
            ? ['-', { state: CapacityState.UNKNOWN }]
            : [mount.vfs.name, await mount.use(() => mount.vfs.capacity())];
        // A policy may deny the reply as it may any op's; a capacity is no bytes,
        // so a bound has nothing to cap.
        await boundary.complete(statfs, path, false, answer, issuer);
        return answer;
    }
    /**
     * Settle that an attribute op's path exists, which it answers first. A
     * link node's own attributes and a directory that exists only in the
     * namespace have no backend behind them; anything else the backend's
     * stat must find, or the op is ENOENT. Mirrors Python's
     * Dispatcher._xattr_target.
     */
    async xattrTarget(mount, path) {
        if (this.namespace.isLink(path.virtual))
            return;
        let stat = null;
        let missing = null;
        if (mount !== null) {
            const [, scope] = await this.namespace.resolve(path.virtual, false);
            await mount.ensureReady();
            const filetype = getExtension(scope.virtual);
            try {
                const found = await mount.use(() => mount.callKeyed('stat', scope, [], {
                    ...this.indexKwargs(mount),
                    ...(filetype !== null ? { filetype } : {}),
                }));
                stat = found instanceof FileStat ? found : null;
            }
            catch (err) {
                if (!isMissingPath(err) && !isEnotdir(err))
                    throw err;
                missing = err;
                await this.reconciler.onEnoent(mount, 'stat', path.virtual, err);
            }
        }
        if (stat !== null || this.namespaceResult('stat', path.virtual) instanceof FileStat)
            return;
        if (isEnotdir(missing))
            throw missing;
        throw mount === null ? noMount(path.virtual) : enoent(path.virtual);
    }
    /**
     * Apply attributes natively where the backend can, overlay the rest.
     *
     * A VFS with a registered setattr op applies what it can and
     * returns the residual; residual fields go to the overlay and
     * natively applied ones are dropped from it, so a stale overlay never
     * shadows a fresh backend value. A VFS without the op, and a
     * link path (which has no backend inode), overlay everything. The
     * overlay half is the dispatcher's own write, so it runs inside the same
     * gates as the native half. Mirrors Python's Dispatcher._apply_setattr.
     */
    async applySetattr(mount, scope, p, kwargs) {
        if (this.namespace.isLink(p.virtual) || !mount.answers('setattr')) {
            // No backend inode answers for the path here, so nothing would
            // refuse a missing one: the overlay would stamp it.
            await this.xattrTarget(mount, p);
            return this.overlaySetattr(p, kwargs);
        }
        const raw = await mount.callKeyed('setattr', scope, [], kwargs, this.access);
        const residual = raw;
        const applied = SETATTR_KEYS.filter((key) => kwargs[key] !== undefined && kwargs[key] !== null && !(key in residual));
        if (applied.length > 0) {
            await this.namespace.dropAttrs(p.virtual, applied);
            await this.changed(p.virtual, FileChangeKind.UPDATE, null, { times: false });
        }
        else {
            // A backend that applied nothing may not have looked for the path.
            await this.xattrTarget(mount, p);
        }
        if (Object.keys(residual).length > 0)
            await this.writeOverlay(p.virtual, residual);
        return residual;
    }
    /** Store every requested field in the namespace overlay. */
    async overlaySetattr(p, kwargs) {
        const timer = startOp();
        const overlay = {};
        for (const key of SETATTR_KEYS) {
            const value = kwargs[key];
            if (value !== undefined && value !== null)
                overlay[key] = value;
        }
        await this.writeOverlay(p.virtual, overlay);
        record('setattr', p.virtual, VFSName.RAM, 0, timer);
        return overlay;
    }
    /** Write one overlay entry, converting an ISO mtime to epoch seconds. */
    async writeOverlay(virtual, fields) {
        const { mtime, ...rest } = fields;
        await this.namespace.setAttrs(virtual, {
            ...rest,
            ...(mtime !== undefined
                ? { mtime: typeof mtime === 'string' ? new Date(mtime).getTime() / 1000 : mtime }
                : {}),
        });
    }
    /**
     * Drop what a change to `rawPath` made stale above the store.
     *
     * The mount's caches take it under every mount of its store
     * (`CacheManager.invalidate`); a removal through the dispatcher knows
     * nothing was below the name, so its index is not asked. The overlay times
     * a content write moves go too, under each name: `observed` is the epoch
     * seconds of a content write to record, null for a removal, and `times`
     * false keeps them, for a change that moved no times. `row` is a CREATE's
     * new entry, kept in its parent's listing; null drops that entry. Mirrors
     * Python's Dispatcher._changed.
     */
    /**
     * Time the directories a `mkdir -p` made above its path, as the path
     * itself is timed. After a failure only those that now exist are: a chain
     * is made outermost first, so the first one missing ends it. Mirrors
     * Python's `_date_made`.
     */
    async dateMade(made, failed) {
        for (const spec of made) {
            if (failed) {
                try {
                    const [row] = await this.dispatch('stat', spec, [], { fresh: true });
                    if (row === null || row === undefined)
                        return;
                }
                catch (err) {
                    if (!isFsError(err))
                        throw err;
                    console.debug(`mkdir -p stopped at ${spec.virtual}: ${String(err)}`);
                    return;
                }
            }
            await this.changed(spec.virtual, FileChangeKind.CREATE, null, {
                observed: systemNow(),
            });
        }
    }
    async changed(rawPath, kind, row = null, options = {}) {
        // Directory writes (mkdir/rmdir via tree copies) arrive with a trailing
        // slash; normalize so the parent computation does not invalidate the
        // written directory itself instead of its parent (Python normalizes the
        // same way via PathSpec.mount_path).
        const path = rstripSlash(rawPath) || '/';
        const mount = this.namespace.tryMountFor(path);
        if (mount === null)
            return;
        if (options.times ?? true)
            await this.namespace.clearTimes(path, options.observed ?? null);
        await mount.cacheManager?.invalidate(path, kind, row, {
            leaf: kind === FileChangeKind.DELETE,
        });
    }
    /**
     * Bind the end of a line to the mounts it started with.
     *
     * The mount table is pinned at the line's start, so a version kept when it
     * ends is judged by the mount that produced it rather than whatever holds
     * the prefix by then. Mirrors Python's Dispatcher.capture_cache_facts.
     */
    captureCacheFacts() {
        const mounts = new Map(this.namespace.mountPrefixes().map((p) => [p, this.namespace.mountFor(p)]));
        return (path) => {
            const prefix = ownerPrefix(mounts.keys(), path);
            const original = prefix === null ? null : mounts.get(prefix);
            const mount = this.namespace.tryMountFor(path);
            return factsOf(original === mount ? mount : null);
        };
    }
    /**
     * Keep the version each path last had on a line that ended; a nested
     * line's keeps only the versions of paths still lost. Mirrors Python's
     * Dispatcher.keep_versions.
     */
    async keepVersions(records, cacheFacts, lost, nested = false) {
        await keepVersions(this.cache, records, cacheFacts, lost, nested);
    }
}
//# sourceMappingURL=dispatcher.js.map