diff --git a/.github/fixtures/supabase-cli-2.109.1-images.json b/.github/fixtures/supabase-cli-2.109.1-images.json new file mode 100644 index 00000000..e737b6d5 --- /dev/null +++ b/.github/fixtures/supabase-cli-2.109.1-images.json @@ -0,0 +1,105 @@ +{ + "schemaVersion": 1, + "cliVersion": "2.109.1", + "registry": "public.ecr.aws", + "services": [ + { + "service": "db", + "source": "supabase/postgres:17.6.1.143", + "runtime": "public.ecr.aws/supabase/postgres:17.6.1.143", + "digest": "sha256:80d7b27c3e8d77cfa7226eee9508671796da214781ff15a35b3670d7ad5ee453", + "enabledByDefault": true + }, + { + "service": "gateway", + "source": "library/kong:2.8.1", + "runtime": "public.ecr.aws/supabase/kong:2.8.1", + "digest": "sha256:1b53405d8680a09d6f44494b7990bf7da2ea43f84a258c59717d4539abf09f6d", + "enabledByDefault": true + }, + { + "service": "mailpit", + "source": "axllent/mailpit:v1.30.2", + "runtime": "public.ecr.aws/supabase/mailpit:v1.30.2", + "digest": "sha256:37a38e48e9338cd7e89dfeb487f37b02ebfcd9cb23111bed2d345e79d37d6dd6", + "enabledByDefault": true + }, + { + "service": "api", + "source": "postgrest/postgrest:v14.14", + "runtime": "public.ecr.aws/supabase/postgrest:v14.14", + "digest": "sha256:d2009b5c9deffc210c8a5592698472fede14fd9f6ca89823c8474ca54d58c012", + "enabledByDefault": true + }, + { + "service": "pgmeta", + "source": "supabase/postgres-meta:v0.96.6", + "runtime": "public.ecr.aws/supabase/postgres-meta:v0.96.6", + "digest": "sha256:a84cc713585eea7b401e4a2561ec4a1e48c87083d1c7ecb4502f204bb4391300", + "enabledByDefault": true + }, + { + "service": "studio", + "source": "supabase/studio:2026.07.06-sha-66cf431", + "runtime": "public.ecr.aws/supabase/studio:2026.07.06-sha-66cf431", + "digest": "sha256:4a5163e33578b346e6eab1352034d29140cf84b6d9989aab6fcf4b39edb3b13c", + "enabledByDefault": true + }, + { + "service": "imgproxy", + "source": "darthsim/imgproxy:v3.8.0", + "runtime": "public.ecr.aws/supabase/imgproxy:v3.8.0", + "digest": "sha256:0facd355d50f3be665ebe674486f2b2e9cdaebd3f74404acd9b7fece2f661435", + "enabledByDefault": true + }, + { + "service": "edgeRuntime", + "source": "supabase/edge-runtime:v1.74.2", + "runtime": "public.ecr.aws/supabase/edge-runtime:v1.74.2", + "digest": "sha256:a82676277615aee03c4f288cbbbf68dedb5ba8693073e567ab8dbfdd11ba5d45", + "enabledByDefault": true + }, + { + "service": "vector", + "source": "timberio/vector:0.53.0-alpine", + "runtime": "public.ecr.aws/supabase/vector:0.53.0-alpine", + "digest": "sha256:ca92d617e905953c3f852e7e88061f7039460e733522e3f0c21bc6ae946b2558", + "enabledByDefault": true + }, + { + "service": "pooler", + "source": "supabase/supavisor:2.9.7", + "runtime": "public.ecr.aws/supabase/supavisor:2.9.7", + "digest": "sha256:be033cf4746a438fa7bfb6a7e589c9a4c950cc7fafea1ea8ec1a15952aa851e6", + "enabledByDefault": false + }, + { + "service": "auth", + "source": "supabase/gotrue:v2.192.0", + "runtime": "public.ecr.aws/supabase/gotrue:v2.192.0", + "digest": "sha256:b252efb680be37d4a8bf77c210cf0439c19b63a4b51929233a65dd101d25bdab", + "enabledByDefault": true + }, + { + "service": "realtime", + "source": "supabase/realtime:v2.112.6", + "runtime": "public.ecr.aws/supabase/realtime:v2.112.6", + "digest": "sha256:7b56da34216fd568042be043900d15cdd33c2c48c2116c9a333f9465255da80d", + "enabledByDefault": true + }, + { + "service": "storage", + "source": "supabase/storage-api:v1.62.5", + "runtime": "public.ecr.aws/supabase/storage-api:v1.62.5", + "digest": "sha256:1dbe962d9862ef12e20357f9d7ba5431989c1daf4a556d6cb20ee4efd1c57320", + "enabledByDefault": true + }, + { + "service": "analytics", + "source": "supabase/logflare:1.46.0", + "runtime": "public.ecr.aws/supabase/logflare:1.46.0", + "digest": "sha256:f3c7a387ab7bb94af001b907c08df14258bd255f29d4cdb8bf6b393707558bf2", + "enabledByDefault": true + } + ] +} diff --git a/.github/scripts/publish-github-release.py b/.github/scripts/publish-github-release.py new file mode 100644 index 00000000..60f37b0d --- /dev/null +++ b/.github/scripts/publish-github-release.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +"""Create, fill, verify, and publish one GitHub Release as a private transaction.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import pathlib +import re +import stat +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request + + +SEMVER_PATTERN = re.compile( + r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" +) +COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}") + + +def fail(message: str) -> None: + raise RuntimeError(f"GitHub release publication error: {message}") + + +def require(condition: bool, message: str) -> None: + if not condition: + fail(message) + + +def digest_handle(handle: object) -> str: + digest = hashlib.sha256() + handle.seek(0) + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + handle.seek(0) + return digest.hexdigest() + + +class GitHub: + def __init__(self, repository: str, token: str) -> None: + require( + re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository) is not None, + f"invalid repository {repository!r}", + ) + require(bool(token), "GH_TOKEN is required") + self.repository = repository + self.token = token + self.api_root = f"https://api.github.com/repos/{repository}" + self.headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "User-Agent": "Dory-transactional-release-publisher", + "X-GitHub-Api-Version": "2022-11-28", + } + + def json_request( + self, + method: str, + path: str, + *, + body: dict[str, object] | None = None, + expected_status: int = 200, + ) -> dict[str, object]: + data = None + headers = dict(self.headers) + if body is not None: + data = json.dumps(body, separators=(",", ":")).encode("utf-8") + headers["Content-Type"] = "application/json" + url = f"{self.api_root}/{path.lstrip('/')}" + request = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=90) as response: + payload = response.read() + require( + response.status == expected_status, + f"{method} {path} returned HTTP {response.status}, expected {expected_status}", + ) + except urllib.error.HTTPError as error: + detail = error.read(64 * 1024).decode("utf-8", errors="replace") + fail(f"{method} {path} returned HTTP {error.code}: {detail}") + except (OSError, urllib.error.URLError, TimeoutError) as error: + fail(f"{method} {path} failed closed: {error}") + if not payload: + return {} + try: + value = json.loads(payload.decode("utf-8")) + except (UnicodeError, json.JSONDecodeError) as error: + fail(f"{method} {path} returned invalid JSON: {error}") + require(isinstance(value, dict), f"{method} {path} returned a non-object") + return value + + def release_for_tag(self, tag: str) -> dict[str, object] | None: + encoded = urllib.parse.quote(tag, safe="") + path = f"releases/tags/{encoded}" + url = f"{self.api_root}/{path}" + request = urllib.request.Request(url, headers=self.headers, method="GET") + try: + with urllib.request.urlopen(request, timeout=60) as response: + payload = response.read() + require(response.status == 200, f"GET {path} returned HTTP {response.status}") + except urllib.error.HTTPError as error: + if error.code == 404: + return None + detail = error.read(64 * 1024).decode("utf-8", errors="replace") + fail(f"GET {path} returned HTTP {error.code}: {detail}") + except (OSError, urllib.error.URLError, TimeoutError) as error: + fail(f"GET {path} failed closed: {error}") + try: + release = json.loads(payload.decode("utf-8")) + except (UnicodeError, json.JSONDecodeError) as error: + fail(f"GET {path} returned invalid JSON: {error}") + require(isinstance(release, dict), f"GET {path} returned a non-object") + return release + + def upload( + self, + upload_url: str, + release_id: int, + name: str, + handle: object, + expected_size: int, + ) -> dict[str, object]: + parsed = urllib.parse.urlsplit(upload_url.removesuffix("{?name,label}")) + expected_path = f"/repos/{self.repository}/releases/{release_id}/assets" + require( + parsed.scheme == "https" + and parsed.netloc == "uploads.github.com" + and parsed.path == expected_path + and not parsed.query + and not parsed.fragment, + "GitHub returned a non-canonical release upload URL", + ) + url = urllib.parse.urlunsplit( + (parsed.scheme, parsed.netloc, parsed.path, urllib.parse.urlencode({"name": name}), "") + ) + with tempfile.NamedTemporaryFile(prefix="dory-upload-response-", suffix=".json") as response: + command = [ + "curl", + "--config", + "-", + "--silent", + "--show-error", + "--connect-timeout", + "30", + "--max-time", + "1800", + "-H", + "Accept: application/vnd.github+json", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + "-H", + "Content-Type: application/octet-stream", + "--data-binary", + f"@/dev/fd/{handle.fileno()}", + "--output", + response.name, + "--write-out", + "%{http_code}", + url, + ] + upload_environment = dict(os.environ) + upload_environment.pop("GH_TOKEN", None) + result = subprocess.run( + command, + env=upload_environment, + input=f'header = "Authorization: Bearer {self.token}"\n', + pass_fds=(handle.fileno(),), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + response.seek(0) + response_bytes = response.read() + require(result.returncode == 0, f"upload of {name} failed closed: {result.stderr.strip()}") + require(result.stdout == "201", f"upload of {name} returned HTTP {result.stdout}: {response_bytes[:65536].decode('utf-8', errors='replace')}") + try: + asset = json.loads(response_bytes.decode("utf-8")) + except (UnicodeError, json.JSONDecodeError) as error: + fail(f"upload of {name} returned invalid JSON: {error}") + require(isinstance(asset, dict), f"upload of {name} returned a non-object") + require(asset.get("name") == name, f"uploaded asset was renamed from {name}") + require(asset.get("size") == expected_size, f"uploaded asset {name} has the wrong size") + return asset + + +def exact_asset_inputs(paths: list[pathlib.Path]) -> dict[str, tuple[pathlib.Path, int, str]]: + require(bool(paths), "no release assets were supplied") + assets: dict[str, tuple[pathlib.Path, int, str]] = {} + for path in paths: + try: + info = path.lstat() + except OSError as error: + fail(f"could not inspect publication input {path}: {error}") + require(stat.S_ISREG(info.st_mode), f"publication input is not a direct regular file: {path}") + require(info.st_size > 0, f"publication input is empty: {path}") + require(path.name not in assets, f"release repeats asset name {path.name}") + with path.open("rb") as handle: + digest = digest_handle(handle) + after = os.fstat(handle.fileno()) + require( + (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns) + == (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns), + f"publication input changed while hashing: {path}", + ) + assets[path.name] = (path, info.st_size, digest) + return assets + + +def exact_ref_target(github: GitHub, tag: str) -> str: + encoded = urllib.parse.quote(tag, safe="") + reference = github.json_request("GET", f"git/ref/tags/{encoded}") + target = reference.get("object") + require(isinstance(target, dict), f"tag {tag} has an invalid Git object") + require(target.get("type") == "commit", f"tag {tag} is not the create-only lightweight ref") + sha = target.get("sha") + require(isinstance(sha, str), f"tag {tag} has no commit SHA") + return sha + + +def release_asset_state(release: dict[str, object]) -> dict[str, tuple[int | None, str | None]]: + assets = release.get("assets") + require( + isinstance(assets, list) and all(isinstance(asset, dict) for asset in assets), + "GitHub release has an invalid asset list", + ) + result: dict[str, tuple[int | None, str | None]] = {} + for asset in assets: + name = asset.get("name") + require(isinstance(name, str) and name not in result, "GitHub release repeats an asset name") + size = asset.get("size") + digest = asset.get("digest") + result[name] = ( + size if isinstance(size, int) else None, + digest if isinstance(digest, str) else None, + ) + return result + + +def revalidate_publication_authority( + repository: str, + version: str, + build: str, + project: pathlib.Path, + source_commit: str, +) -> None: + fetch = subprocess.run( + ["git", "fetch", "--force", "origin", "main"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + require(fetch.returncode == 0, f"could not refresh main before publish: {fetch.stdout.strip()}") + resolve = subprocess.run( + ["git", "rev-parse", "origin/main"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + require(resolve.returncode == 0, f"could not resolve main before publish: {resolve.stdout.strip()}") + require( + resolve.stdout.strip() == source_commit, + f"qualified commit {source_commit} is no longer exact current origin/main", + ) + verifier = pathlib.Path(__file__).with_name("verify-release-identity.py") + identity = subprocess.run( + [ + sys.executable, + str(verifier), + "--repository", + repository, + "--project", + str(project), + "--version", + version, + "--build", + build, + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + require( + identity.returncode == 0, + f"release identity changed before private draft publication: {identity.stdout.strip()}", + ) + + +def report_unpublished_state( + github: GitHub, + tag: str, + source_commit: str, + release_id: int | None, +) -> None: + messages = [ + "Automatic GitHub cleanup is disabled because the API has no conditional delete; " + "reconcile this failed private publication explicitly." + ] + if release_id is not None: + try: + release = github.json_request("GET", f"releases/{release_id}") + messages.append( + f"release_id={release_id} tag={release.get('tag_name')!r} " + f"draft={release.get('draft')!r}" + ) + except Exception as error: + messages.append(f"release_id={release_id} state lookup failed closed: {error}") + try: + tag_release = github.release_for_tag(tag) + messages.append( + f"tag={tag} release_id=" + f"{None if tag_release is None else tag_release.get('id')!r}" + ) + except Exception as error: + messages.append(f"tag={tag} release lookup failed closed: {error}") + try: + target = exact_ref_target(github, tag) + messages.append( + f"tag={tag} target={target} expected_target={source_commit}" + ) + except Exception as error: + messages.append(f"tag={tag} ref lookup failed closed: {error}") + print("WARNING: " + " ".join(messages), file=sys.stderr) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--repository", required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--source-commit", required=True) + parser.add_argument("--build", required=True) + parser.add_argument("--project", type=pathlib.Path, required=True) + parser.add_argument("--name", required=True) + parser.add_argument("--body-file", type=pathlib.Path, required=True) + parser.add_argument("--github-output", type=pathlib.Path) + parser.add_argument("assets", nargs="+", type=pathlib.Path) + arguments = parser.parse_args() + + require(SEMVER_PATTERN.fullmatch(arguments.version) is not None, "version is not canonical SemVer") + require(re.fullmatch(r"[1-9][0-9]*", arguments.build) is not None, "build is not canonical") + require(COMMIT_PATTERN.fullmatch(arguments.source_commit) is not None, "source commit is not canonical") + try: + body = arguments.body_file.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + fail(f"could not read release body: {error}") + require(bool(body.strip()), "release body is empty") + inputs = exact_asset_inputs(arguments.assets) + expected_assets = { + name: (size, f"sha256:{digest}") + for name, (_, size, digest) in inputs.items() + } + github = GitHub(arguments.repository, os.environ.get("GH_TOKEN", "")) + tag = f"v{arguments.version}" + release_id: int | None = None + published = False + publish_ambiguous = False + try: + reference = github.json_request( + "POST", + "git/refs", + body={"ref": f"refs/tags/{tag}", "sha": arguments.source_commit}, + expected_status=201, + ) + require(reference.get("ref") == f"refs/tags/{tag}", "GitHub created the wrong tag ref") + require(exact_ref_target(github, tag) == arguments.source_commit, "created tag targets another commit") + + release = github.json_request( + "POST", + "releases", + body={ + "tag_name": tag, + "target_commitish": arguments.source_commit, + "name": arguments.name, + "body": body, + "draft": True, + "prerelease": False, + "generate_release_notes": True, + }, + expected_status=201, + ) + release_id_value = release.get("id") + require(isinstance(release_id_value, int) and release_id_value > 0, "draft release has no ID") + release_id = release_id_value + require(release.get("draft") is True, "new release was exposed before asset verification") + require(release.get("tag_name") == tag, "draft release has the wrong tag") + require(release.get("target_commitish") == arguments.source_commit, "draft release has the wrong target") + upload_url = release.get("upload_url") + require(isinstance(upload_url, str), "draft release has no upload URL") + + for name, (path, expected_size, expected_digest) in inputs.items(): + with path.open("rb") as handle: + before = os.fstat(handle.fileno()) + require(before.st_size == expected_size, f"publication input changed before upload: {path}") + require(digest_handle(handle) == expected_digest, f"publication input changed before upload: {path}") + github.upload(upload_url, release_id, name, handle, expected_size) + after_digest = digest_handle(handle) + after = os.fstat(handle.fileno()) + require( + after.st_size == expected_size and after_digest == expected_digest, + f"publication input changed during upload: {path}", + ) + + for attempt in range(1, 25): + release = github.json_request("GET", f"releases/{release_id}") + require(release.get("draft") is True, "release became public before verification") + require(release.get("tag_name") == tag, "draft release tag changed") + require(release.get("target_commitish") == arguments.source_commit, "draft release target changed") + actual_assets = release_asset_state(release) + if actual_assets == expected_assets: + break + if attempt == 24: + fail(f"draft release asset set differs: {actual_assets!r} != {expected_assets!r}") + time.sleep(5) + require(exact_ref_target(github, tag) == arguments.source_commit, "tag moved during upload") + revalidate_publication_authority( + arguments.repository, + arguments.version, + arguments.build, + arguments.project, + arguments.source_commit, + ) + + publish_ambiguous = True + try: + release = github.json_request( + "PATCH", f"releases/{release_id}", body={"draft": False}, expected_status=200 + ) + except RuntimeError: + try: + observed = github.json_request("GET", f"releases/{release_id}") + except RuntimeError: + publish_ambiguous = True + else: + published = observed.get("id") == release_id and observed.get("draft") is False + publish_ambiguous = observed.get("id") != release_id or not isinstance( + observed.get("draft"), bool + ) + raise + if release.get("draft") is True: + publish_ambiguous = False + elif release.get("draft") is False: + published = True + publish_ambiguous = False + require(release.get("id") == release_id, "GitHub published another release") + require(release.get("draft") is False, "GitHub did not publish the verified draft") + + public_release = github.json_request("GET", f"releases/{release_id}") + require(public_release.get("draft") is False, "published release reverted to draft") + require(public_release.get("tag_name") == tag, "published release tag changed") + require( + public_release.get("target_commitish") == arguments.source_commit, + "published release target changed", + ) + require(release_asset_state(public_release) == expected_assets, "published asset set changed") + require(exact_ref_target(github, tag) == arguments.source_commit, "published tag moved") + if arguments.github_output is not None: + with arguments.github_output.open("a", encoding="utf-8") as output: + output.write(f"id={release_id}\n") + print(f"Published exact private draft {release_id} as {tag} with {len(inputs)} assets.") + finally: + if not published and not publish_ambiguous: + report_unpublished_state( + github, tag, arguments.source_commit, release_id + ) + elif publish_ambiguous: + print( + f"WARNING: publication state for release {release_id} is ambiguous; " + "refusing destructive cleanup", + file=sys.stderr, + ) + report_unpublished_state( + github, tag, arguments.source_commit, release_id + ) + + +if __name__ == "__main__": + try: + main() + except RuntimeError as error: + raise SystemExit(str(error)) from error diff --git a/.github/scripts/test-act-compatibility-gate.py b/.github/scripts/test-act-compatibility-gate.py new file mode 100644 index 00000000..0f52cb96 --- /dev/null +++ b/.github/scripts/test-act-compatibility-gate.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the checksum-pinned act compatibility gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "act-compatibility-gate.sh" + + +class ActCompatibilityGateTests(unittest.TestCase): + def test_contract_is_exact_and_offline_at_runtime(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-ACT", + "socket is not owned by the release user", + "Docker CLI is unavailable or indirect", + "runner image must be an exact digest reference", + "required offline runner image is missing", + "act_Darwin_$ARCHIVE_ARCH.tar.gz", + "shasum -a 256 -c -", + "verified act archive did not contain a direct executable", + "--container-daemon-socket unix:///var/run/docker.sock", + "--pull=false", + "host_to_runner_workspace=PASS", + "runner_to_host_workspace=PASS", + "act_binary_sha256=", + "docker_cli_sha256=", + ): + self.assertIn(proof, text, proof) + self.assertNotIn("--pull \\", text) + self.assertNotIn("assert ", text) + + def test_confirmation_fails_before_socket_or_download_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result = subprocess.run( + [ + str(GATE), + "--socket", str(pathlib.Path(temporary) / "missing.sock"), + "--docker", "/missing/docker", + "--runner-image", "example.invalid/runner@sha256:" + "a" * 64, + "--workroot", str(pathlib.Path(temporary) / "evidence"), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-bind-advisory-lock-gate.py b/.github/scripts/test-bind-advisory-lock-gate.py new file mode 100644 index 00000000..0bec6005 --- /dev/null +++ b/.github/scripts/test-bind-advisory-lock-gate.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the cross-container advisory-lock gate.""" + +from __future__ import annotations + +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "bind-advisory-lock-gate.sh" +PROBE = ROOT / "scripts" / "bind-advisory-lock-probe.py" + + +class BindAdvisoryLockGateTests(unittest.TestCase): + def test_gate_and_probe_cover_native_lock_semantics(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + compile(PROBE.read_text(encoding="utf-8"), str(PROBE), "exec") + gate = GATE.read_text(encoding="utf-8") + probe = PROBE.read_text(encoding="utf-8") + for proof in ( + "create-mode-zero", + "O_CREAT|O_EXCL mode-0000", + "flock-exclusive-contender", + "flock-shared-peer", + "flock-upgrade.blocked", + "flock-after-crash", + "record-nonoverlap", + "record-waiter.acquired", + "record-after-crash", + "cross_container_bind_mount=PASS", + 'mktemp -d "$HOME/.dory-bind-lock-gate.XXXXXXXX"', + "Dory socket is not owned by the release user", + ): + self.assertIn(proof, gate, proof) + for proof in ("fcntl.flock", "fcntl.lockf", "LOCK_NB", "LOCK_UN", "os.O_EXCL"): + self.assertIn(proof, probe, proof) + self.assertNotIn("assert ", gate) + self.assertNotIn("assert ", probe) + + def test_relative_workroot_fails_before_socket_or_docker_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result = subprocess.run( + [ + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--image", + "example.invalid/python@sha256:" + "a" * 64, + "--workroot", + "relative-evidence", + "--confirm", + "ISOLATED-DORY-BIND-LOCKS", + ], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("--workroot must be absolute", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-bind-file-coherence-gate.py b/.github/scripts/test-bind-file-coherence-gate.py new file mode 100755 index 00000000..4270f03d --- /dev/null +++ b/.github/scripts/test-bind-file-coherence-gate.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Offline contract for direct-file bind coherence qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "bind-file-coherence-gate.sh" + + +class BindFileCoherenceGateTests(unittest.TestCase): + def test_gate_binds_exact_runtime_mounts_and_all_coherence_phases(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-BIND-FILE-COHERENCE", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "socket is not owned by the release user", + "workroot already exists or is indirect", + "probe image must be an exact digest reference", + 'docker_e run -d --pull=never --network none --name "$NAME"', + '--mount "type=bind,src=$SHARE,dst=/work"', + '--mount "type=bind,src=$FILE,dst=/single/value.bin"', + "bind probe did not launch the exact qualified image", + "bind probe is missing its exact ownership label", + "bind probe unexpectedly has network access", + "bind probe mount graph differs from the exact host sources", + "guest retained stale metadata/content", + "same-inode-shrink", + "same-inode-grow", + "same-inode-content", + "atomic-replacement-pinned-direct", + "direct-rebind-after-replacement", + "guest-truncate", + 'docker_e ps -aq --filter "label=$LABEL"', + "exact_container_authority=PASS", + "network_isolation=PASS", + "direct_single_file_recreate_cycles=%s", + "same_inode_shrink=PASS", + "same_inode_grow=PASS", + "same_inode_content_refresh=PASS", + "direct_atomic_replacement_pins_inode=PASS", + "direct_rebind_follows_replacement=PASS", + "guest_to_host_truncation=PASS", + "owned_container_cleanup=PASS", + "docker_cli_sha256=", + "results_sha256=", + ): + self.assertIn(proof, text, proof) + self.assertIn( + "'^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$'", text + ) + for stale in ( + "alpine:latest", + "--pull never", + "docker_e rm -f $owned", + 'ids="$(docker_e ps -aq)"', + "docker_e volume rm", + "docker_e network rm", + "assert ", + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_helper_image_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--image", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={ + **os.environ, + "HOME": temporary, + "DORY_BIND_COHERENCE_CONFIRM": "", + }, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-ci-umbrella.py b/.github/scripts/test-ci-umbrella.py new file mode 100755 index 00000000..60799b62 --- /dev/null +++ b/.github/scripts/test-ci-umbrella.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Clean-checkout dependency and retry tests for scripts/ci-test.sh.""" + +from __future__ import annotations + +import pathlib +import re +import subprocess +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +CI_TEST = ROOT / "scripts" / "ci-test.sh" + + +class CIUmbrellaTests(unittest.TestCase): + def test_source_is_shell_valid_and_keeps_retry_contract(self) -> None: + subprocess.run(["bash", "-n", str(CI_TEST)], cwd=ROOT, check=True) + source = CI_TEST.read_text(encoding="utf-8") + for contract in ( + "test-security-contracts.sh", + "test-destructive-action-contracts.sh", + "test-ci-test-support.sh", + "test-dmg-distribution-signing.sh", + "test-sleep-wake-evidence.sh", + "test-live-hostshare-integration.sh", + "test-agent-protocol-consumers.sh", + "for attempt in 1 2", + 'bash scripts/test.sh app -- -skip-testing:DoryUITests', + '"${passed:-0}" -ge 300', + "still not clean after retry", + "log path cannot be a symlink", + ): + self.assertIn(contract, source) + + def test_every_invoked_repository_script_is_tracked(self) -> None: + source = CI_TEST.read_text(encoding="utf-8") + references = sorted( + set(re.findall(r"scripts/[A-Za-z0-9_.-]+(?:\.sh|\.py)", source)) + ) + tracked = set( + subprocess.check_output(["git", "ls-files"], cwd=ROOT, text=True).splitlines() + ) + missing = [reference for reference in references if reference not in tracked] + self.assertEqual(missing, [], f"untracked ci-test dependencies: {missing}") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-competitor-runtime-regression-gate.py b/.github/scripts/test-competitor-runtime-regression-gate.py new file mode 100755 index 00000000..0e496c9d --- /dev/null +++ b/.github/scripts/test-competitor-runtime-regression-gate.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Offline contract for the run-owned competitor-runtime regression qualification gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "competitor-runtime-regression-gate.sh" + + +class CompetitorRuntimeRegressionGateTests(unittest.TestCase): + def test_gate_is_exact_run_owned_bounded_and_release_bindable(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-COMPETITOR-REGRESSION", + "Dory socket must be an absolute path", + "Dory socket is unavailable or indirect", + "Dory socket is not owned by the release user", + "Dory state directory is unavailable or indirect", + "workroot already exists or is indirect", + "canonical workroot already exists or is indirect", + "release candidate evidence requires --source-commit", + "release candidate image must be digest-pinned", + "release candidate evidence requires --runtime", + "release candidate evidence requires --compose", + "release candidate evidence requires --buildx", + "standalone runtime is unavailable or indirect", + "standalone runtime member is missing or indirect", + "release candidate CLI is unavailable or indirect", + "engine restart test requires an isolated engine with zero pre-existing containers", + 'label=dev.dory.compatibility=$OWNER', + 'docker_e ps -aq --filter "label=dev.dory.compatibility=$OWNER"', + 'docker_e volume ls -q --filter "label=dev.dory.compatibility=$OWNER"', + 'docker_e network ls -q --filter "label=dev.dory.compatibility=$OWNER"', + "forwarded-connection-fds", + "concurrent-proxy-backpressure", + "compose-v2-lifecycle", + "network-api-lifecycle", + "standalone-engine-restart", + "volume-api-lifecycle", + "bind-open-fd-stability", + "image-hardlink-missing-parent", + "buildkit-concurrent-sessions", + "buildkit-cache-cancellation", + "cleanup-restart-persistence", + "docker_bin_sha256=", + "compose_bin_sha256=", + "buildx_bin_sha256=", + "results_sha256=", + "status=PASS", + "release_qualifying=", + "raise SystemExit", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'echo "socket=$SOCKET"', + 'echo "state_dir=$STATE_DIR"', + 'echo "runtime=$RUNTIME"', + 'echo "runtime_home=$RUNTIME_HOME"', + 'echo "docker_bin_resolved=$docker_bin_resolved"', + 'echo "compose_bin_resolved=$compose_bin_resolved"', + 'echo "buildx_bin_resolved=$buildx_bin_resolved"', + "docker_e system prune", + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_socket_cli_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--state-dir", + str(pathlib.Path(temporary) / "missing-state"), + "--docker", + "/missing/docker", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-container-engine-performance-gate.py b/.github/scripts/test-container-engine-performance-gate.py new file mode 100755 index 00000000..6baff1ba --- /dev/null +++ b/.github/scripts/test-container-engine-performance-gate.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Non-mutating arming tests for exact-candidate container-engine performance qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "qualify-container-engine-performance.sh" + + +class ContainerEnginePerformanceGateTests(unittest.TestCase): + def invoke( + self, + candidate: pathlib.Path, + workroot: pathlib.Path, + temporary_root: pathlib.Path, + *, + confirmation: str = "CLEAN-BENCHMARK-USER-DELETE-ENGINE-DATA", + clean_user: bool = True, + benchmark_user: bool = True, + ) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment["RUNNER_TEMP"] = str(temporary_root) + environment["PYTHONOPTIMIZE"] = "2" + for key, enabled in ( + ("DORY_RELEASE_CLEAN_USER", clean_user), + ("DORY_RELEASE_BENCHMARK_USER", benchmark_user), + ): + if enabled: + environment[key] = "1" + else: + environment.pop(key, None) + digest = "example.invalid/fixture@sha256:" + "a" * 64 + return subprocess.run( + [ + str(GATE), + "--candidate-dir", str(candidate), + "--version", "9.8.7", + "--build", "42", + "--source-commit", "b" * 40, + "--workroot", str(workroot), + "--alpine-image", digest, + "--iperf-image", digest, + "--node-image", digest, + "--postgres-image", digest, + "--redis-image", digest, + "--ruby-image", digest, + "--composer-image", digest, + "--curl-image", digest, + "--probe-url", "https://example.invalid/probe", + "--download-url", "https://example.invalid/payload", + "--download-bytes", "4096", + "--confirm", confirmation, + ], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + def test_source_is_shell_valid_and_candidate_bound(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], cwd=ROOT, check=True) + source = GATE.read_text(encoding="utf-8") + self.assertNotIn("assert ", source) + for contract in ( + "scripts/validate-release-metadata.py", + 'export PATH="$APP/Contents/Helpers:$PATH"', + '"$(command -v docker)" = "$CANDIDATE_DOCKER"', + "codesign --verify --strict --deep", + "xcrun stapler validate", + "candidate source commit mismatch", + "an existing OrbStack installation would be removed", + "an existing Colima installation would be removed", + 'LIMA_COLIMA_STATE="$HOME/.lima/colima"', + "host rebooted during the performance campaign", + "engine_state_removed=PASS", + "docs/container-engine-performance-qualification.md", + "dev.dory.container-engine-performance-qualification", + "cannot authorize Linux VM support or", + ): + self.assertIn(contract, source) + self.assertNotIn("dev.dory.linux-vm-performance-evidence", source) + + def test_explicit_clean_account_arming_precedes_host_or_filesystem_mutation(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-performance-gate-test.") as raw: + temporary = pathlib.Path(raw).resolve() + candidate = temporary / "candidate" + candidate.mkdir() + workroot = temporary / "dory-container-engine-performance" + no_confirmation = self.invoke( + candidate, workroot, temporary, confirmation="wrong" + ) + no_clean_user = self.invoke( + candidate, workroot, temporary, clean_user=False + ) + no_benchmark_user = self.invoke( + candidate, workroot, temporary, benchmark_user=False + ) + self.assertIn("--confirm CLEAN-BENCHMARK-USER-DELETE-ENGINE-DATA", no_confirmation.stdout) + self.assertIn("DORY_RELEASE_CLEAN_USER=1 is required", no_clean_user.stdout) + self.assertIn("DORY_RELEASE_BENCHMARK_USER=1 is required", no_benchmark_user.stdout) + + def test_candidate_and_workroot_authorities_reject_indirection_or_overlap(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-performance-gate-test.") as raw: + temporary = pathlib.Path(raw).resolve() + candidate = temporary / "candidate" + candidate.mkdir() + candidate_link = temporary / "candidate-link" + candidate_link.symlink_to(candidate, target_is_directory=True) + indirect = self.invoke( + candidate_link, + temporary / "dory-container-engine-performance", + temporary, + ) + wrong_name = self.invoke( + candidate, + temporary / "performance-output", + temporary, + ) + parent = temporary / "dory-container-engine-performance" + nested_candidate = parent / "candidate" + nested_candidate.mkdir(parents=True) + overlap = self.invoke(nested_candidate, parent, temporary) + self.assertIn("candidate directory must be direct", indirect.stdout) + self.assertIn( + "dedicated dory-container-engine-performance name", wrong_name.stdout + ) + self.assertIn("workroot cannot contain the candidate", overlap.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-data-disk-growth-gate.py b/.github/scripts/test-data-disk-growth-gate.py new file mode 100755 index 00000000..08567daa --- /dev/null +++ b/.github/scripts/test-data-disk-growth-gate.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Offline contract for sparse Docker data-disk growth qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "data-disk-growth-gate.sh" + + +class DataDiskGrowthGateTests(unittest.TestCase): + def test_gate_binds_helpers_storage_authority_and_unprivileged_probes(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-RUNTIME-DATA-DISK-GROWTH", + "runtime must be absolute", + "Docker CLI must be absolute", + "candidate helper is unavailable or indirect", + "--image must be an exact digest reference", + "workroot already exists or is indirect", + "mke2fs is required as a direct executable", + "data-disk growth Unix socket path is", + "runtime socket is unavailable or owned by another user", + 'docker_e pull "$IMAGE"', + "local probe image does not retain its exact registry authority", + 'docker_e volume create --label "$LABEL" "$NAME"', + "growth volume differs from its exact run authority", + "--rm --pull=never --network none --label", + '--mount "type=volume,src=$NAME,dst=/data"', + "df -Pk /data", + "resize/fstrim startup took", + "boot-time fstrim evidence", + "named-volume marker changed after restart", + "helper grew a disk still attached to the running VM", + "pre-growth capacity response is not initialized at 128 GiB", + "explicit growth response is not exactly 256 GiB", + "e2fsck_mode=forced-preen", + "explicit growth did not use a forced offline preen", + "owned probe container survived completion", + "owned volume cleanup failed", + "dory_engine_sha256=", + "dory_hv_sha256=", + "docker_cli_sha256=", + "mke2fs_sha256=", + "exact_candidate_helpers=PASS", + "exact_probe_image=PASS", + "network_isolated_probes=PASS", + "privileged_host_bind_absent=PASS", + "discard_reclaim=PASS", + "explicit_capacity_growth=PASS", + "owned_container_cleanup=PASS", + "owned_volume_cleanup=PASS", + "runtime_shutdown=PASS", + ): + self.assertIn(proof, text, proof) + self.assertIn( + "'^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$'", text + ) + for stale in ( + "alpine:3.20", + "--privileged", + "-v /:/host", + "/host/var/lib/docker", + "assert ", + "runtime=$RUNTIME", + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_helpers_runtime_home_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + runtime_home = pathlib.Path(temporary) / "runtime-must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--runtime", + "/missing/runtime", + "--docker", + "/missing/docker", + "--image", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={ + **os.environ, + "HOME": temporary, + "DORY_DATA_DISK_RUNTIME_HOME": str(runtime_home), + }, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + self.assertFalse(runtime_home.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-data-drive-volume-identity-gate.py b/.github/scripts/test-data-drive-volume-identity-gate.py new file mode 100755 index 00000000..f3b8c88b --- /dev/null +++ b/.github/scripts/test-data-drive-volume-identity-gate.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Offline contract for physical APFS data-drive volume identity qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "data-drive-volume-identity-gate.sh" + + +class DataDriveVolumeIdentityGateTests(unittest.TestCase): + def test_gate_binds_candidate_helper_images_devices_and_drive_authority(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "PHYSICAL-APFS-VOLUME-IDENTITY", + "physical Apple silicon is required", + "dory-hv must be absolute", + "dory-hv is missing or indirect", + "workroot already exists or is indirect", + "generated APFS mount name is already in use", + "hdiutil attach -plist -nobrowse", + "hdiutil attached the image at an unexpected mount point", + "hdiutil did not report an exact mounted APFS device", + 'hdiutil detach "$first_device"', + 'hdiutil detach "$second_device"', + "data-drive manifest has an unexpected shape", + "data-drive manifest ID differs from dory-hv output", + "selection authority has an unexpected shape", + "selection authority differs from the APFS volume", + "clearing runtime state forgot the selected drive", + "bookmark did not recover the renamed volume", + "detached selected volume was accepted", + "same-name replacement volume was accepted", + "original drive identity changed", + "external_volume_identity=PASS", + "durable_selection_outside_runtime_state=PASS", + "bookmark_volume_rename_recovery=PASS", + "missing_volume_shadow_prevention=PASS", + "same_name_wrong_volume_rejected=PASS", + "original_volume_reaccepted=PASS", + "exact_candidate_helper=PASS", + "exact_device_detach=PASS", + "dory_hv_sha256=", + ): + self.assertIn(proof, text, proof) + for stale in ( + "assert ", + 'hdiutil detach "$MOUNT"', + 'hdiutil detach "$RENAMED_MOUNT"', + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_architecture_helper_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--dory-hv", + "/missing/dory-hv", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-default-platform-image-gate.py b/.github/scripts/test-default-platform-image-gate.py new file mode 100755 index 00000000..45af8561 --- /dev/null +++ b/.github/scripts/test-default-platform-image-gate.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Offline contract for default multi-platform image selection qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "default-platform-image-gate.sh" + + +class DefaultPlatformImageGateTests(unittest.TestCase): + def test_gate_binds_candidate_default_selection_and_reporting_surfaces(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-DEFAULT-PLATFORM", + "Dory socket is not owned by the release user", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "workroot already exists or is indirect", + "qualification engine is not empty", + "# Intentionally no --platform here.", + 'docker_e pull "$IMAGE"', + 'docker_e create --pull=never --name "$NAME"', + "container did not use the exact requested image", + "container does not carry the exact run authority label", + "default-platform container binds host paths", + "fresh qualification store contains", + "image-list and system-df storage bytes disagree", + "local image does not retain the exact requested manifest-list authority", + 'docker_e ps -aq --filter "label=dev.dory.default-platform=$OWNER"', + "fresh_empty_store=PASS", + "default_pull_without_platform=PASS", + "single_platform_local_image=PASS", + "default_run_architecture=PASS", + "exact_container_image=PASS", + "host_path_free_container=PASS", + "image_list_system_df_reconciled=PASS", + "owned_container_cleanup=PASS", + "docker_cli_sha256=", + ): + self.assertIn(proof, text, proof) + self.assertIn( + "'^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$'", text + ) + self.assertNotIn('docker_e pull --platform', text) + for stale in ( + "assert ", + 'ids="$(docker_e ps -aq)"', + "docker_e volume rm", + "docker_e network rm", + 'echo "socket=$SOCKET"', + 'echo "docker=$DOCKER"', + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_helper_image_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--image", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-desktop-linux-live-gate.py b/.github/scripts/test-desktop-linux-live-gate.py new file mode 100644 index 00000000..c2da66a1 --- /dev/null +++ b/.github/scripts/test-desktop-linux-live-gate.py @@ -0,0 +1,593 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the physical managed-desktop release gate.""" + +from __future__ import annotations + +import os +import pathlib +import plistlib +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "desktop-linux-live-gate.sh" + + +class DesktopLinuxLiveGateTests(unittest.TestCase): + @staticmethod + def _write_plist(path: pathlib.Path, value: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as handle: + plistlib.dump(value, handle) + + @staticmethod + def _write_executable(path: pathlib.Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile("/usr/bin/true", path) + path.chmod(0o700) + + def _signed_gate_fixture( + self, + root: pathlib.Path, + *, + vmm_identifier: str = "dory-vmm", + extra_vmm_entitlement: bool = False, + extra_vmm_xpc_entitlement: bool = False, + vmm_xpc_services: bool = False, + omit_renderer_worker: bool = False, + runner_package_type: str = "APPL", + runner_symlink: bool = False, + vmm_executable_name: str = "dory-vmm", + ) -> tuple[list[str], dict[str, str]]: + runner_temp = root / "runner" + helpers = root / "helpers" + components = root / "components" + workroot = runner_temp / "gate" + for directory in (runner_temp, helpers, components): + directory.mkdir() + ctl = helpers / "dorydctl" + ctl.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + ctl.chmod(0o700) + + runner = helpers / "DoryHVRunner.app" + runner_executable = runner / "Contents" / "MacOS" / "dory-hv" + fs_worker = runner / "Contents" / "XPCServices" / "DoryFSWorker.xpc" + renderer_worker = ( + runner / "Contents" / "XPCServices" / "DoryRendererWorker.xpc" + ) + vmm = helpers / "DoryVMM.app" + self._write_executable(runner_executable) + self._write_executable(fs_worker / "Contents" / "MacOS" / "DoryFSWorker") + if not omit_renderer_worker: + self._write_executable( + renderer_worker / "Contents" / "MacOS" / "DoryRendererWorker" + ) + self._write_executable(vmm / "Contents" / "MacOS" / "dory-vmm") + self._write_plist( + runner / "Contents" / "Info.plist", + { + "CFBundleExecutable": "dory-hv", + "CFBundleIdentifier": "com.pythonxi.Dory.HVRunner", + "CFBundlePackageType": runner_package_type, + "NSCameraUsageDescription": "Camera test.", + "NSMicrophoneUsageDescription": "Microphone test.", + }, + ) + self._write_plist( + fs_worker / "Contents" / "Info.plist", + { + "CFBundleExecutable": "DoryFSWorker", + "CFBundleIdentifier": "com.pythonxi.Dory.HVRunner.FSWorker", + "CFBundlePackageType": "XPC!", + "XPCService": {"ServiceType": "Application"}, + }, + ) + if not omit_renderer_worker: + self._write_plist( + renderer_worker / "Contents" / "Info.plist", + { + "CFBundleExecutable": "DoryRendererWorker", + "CFBundleIdentifier": "com.pythonxi.Dory.HVRunner.RendererWorker", + "CFBundlePackageType": "XPC!", + "XPCService": {"ServiceType": "Application"}, + }, + ) + self._write_plist( + vmm / "Contents" / "Info.plist", + { + "CFBundleExecutable": vmm_executable_name, + "CFBundleIdentifier": vmm_identifier, + "CFBundlePackageType": "APPL", + "NSMicrophoneUsageDescription": "Microphone test.", + }, + ) + + entitlement_values = { + "runner": { + "com.apple.security.device.audio-input": True, + "com.apple.security.device.camera": True, + "com.apple.security.hypervisor": True, + }, + "filesystem": {}, + "renderer": { + "com.apple.security.app-sandbox": True, + "com.apple.security.application-groups": [ + "864H636QW4.dory-renderer" + ], + }, + "vmm": { + "com.apple.security.device.audio-input": True, + "com.apple.security.virtualization": True, + }, + } + if extra_vmm_entitlement: + entitlement_values["vmm"][ + "com.apple.security.cs.disable-library-validation" + ] = True + if extra_vmm_xpc_entitlement: + entitlement_values["vmm"]["com.apple.security.xpc-service"] = True + bundles = [ + ("filesystem", fs_worker), + ] + if not omit_renderer_worker: + bundles.append(("renderer", renderer_worker)) + bundles.extend((("runner", runner), ("vmm", vmm))) + for name, bundle in bundles: + entitlements = root / f"{name}.entitlements" + self._write_plist(entitlements, entitlement_values[name]) + subprocess.run( + [ + "/usr/bin/codesign", + "--force", + "--sign", + "-", + "--entitlements", + str(entitlements), + str(bundle), + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if vmm_xpc_services: + (vmm / "Contents" / "XPCServices").mkdir(parents=True) + if runner_symlink: + direct_runner = helpers / "DoryHVRunner.direct.app" + runner.rename(direct_runner) + runner.symlink_to(direct_runner.name) + + assets = [] + for name in ("Image-desktop", "debian.ext4", "debian-update.tar"): + path = root / name + path.write_bytes(name.encode("ascii")) + assets.append(path) + arguments = [ + str(GATE), + "--ctl", str(ctl), + "--component-dir", str(components), + "--kernel", str(assets[0]), + "--debian-rootfs", str(assets[1]), + "--debian-update", str(assets[2]), + "--distro", "debian", + "--version", "9.8.7", + "--workroot", str(workroot), + "--confirm", "EXACT-CANDIDATE-DESKTOPS", + ] + return arguments, {**os.environ, "RUNNER_TEMP": str(runner_temp)} + + def test_shell_contract_separates_desktop_recovery_from_venus_qualification(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + + required = ( + "scope=managed-rootfs-only", + "generic_arm64_efi_iso_software_baseline=SEPARATE-GATE", + "--component-dir", + "component install-candidate", + "component verify all --offline --json", + "component path linux-desktop dory-desktop-kernel-arm64.lzfse", + 'cmp -s "$KERNEL" "$installed_kernel"', + "--guest-user dorygate --guest-uid 1550", + '--desktop-distro "$distro" --runtime accelerated --graphics "$graphics_preference"', + "graphics_preference=virgl-venus", + "--require-acceleration", + "--require-release-signature", + "verify_exact_entitlements", + "DoryHVRunner XPC worker graph is not exact", + "DoryVMM must not contain XPCServices", + "developer_id_requirement", + "designated requirement is not canonical", + "is not hardened-runtime signed", + "com.apple.security.device.camera", + "com.apple.security.virtualization", + "com.apple.security.cs.disable-library-validation", + "DoryHVRunner.app is missing or indirect", + "require_arm64_slice", + "applicationGraphSHA256", + "componentCandidateInventorySHA256", + "component import response binds another catalog", + "signed VM qualification binds another candidate inventory", + "machine launched a different VM helper", + "runnerCodeDirectoryHash", + "running VM code identity differs from the candidate", + "exact_release_binding=PASS", + "verify-renderer-bootstrap-qualification.py", + "--clipboard bidirectional", + "--resolved-graphics (hardware-accelerated-3d|software)", + "/run/dory/graphics-requested-backend", + "virgl2+venus", + "virgl2)", + "software:software)", + "software-ready", + "^venus-unavailable:", + "fallback=virgl2$", + "venus-ready:", + "managed_desktop_baseline=PASS", + "GSK_RENDERER=gl", + "MOZ_ENABLE_WAYLAND=0", + "XDG_SESSION_TYPE=x11", + 'run_desktop ubuntu "$UBUNTU_ROOTFS" gdm3 gnome-shell firefox', + "contract=vulkan-1.3-application", + "hardware-device=yes", + "dynamic-rendering=yes", + "synchronization2=yes", + "maintenance4=yes", + "color-atlas-texture-binding=yes", + "color-atlas-copy-dst=yes", + '--distribution-installation "$distribution_installation"', + '--runtime-installation "$runtime_installation"', + '"provenance": "verified-update-bundle"', + "stale-update-rejected", + "--zed-archive", + 'applicationReadiness"]["applicationTag', + '"v$ZED_VERSION" = "$TUPLE_ZED_TAG"', + "zed-native-venus", + "/zed.app/libexec/zed-editor", + "for candidate_pid in", + "*crash-handler*) continue", + "^LD_LIBRARY_PATH=.", + "zed --version", + "--foreground", + "tr -cs '0-9.'", + "ZED_RESULT=UNAVAILABLE", + "ZED_RESULT=PASS", + "ZED_RESULT=FAIL", + "strict acceleration requires native Ubuntu Venus/Zed PASS", + "zed_native_venus=%s", + "mesa_virgl_desktop=%s", + "renderer_release_signature=%s", + "glxinfo -B", + "glxgears -info", + "OpenGL renderer string:.*virgl", + "llvmpipe|softpipe|swrast|software rasterizer", + "mesa-virgl-desktop", + "VK_DRIVER_FILES", + "VK_ICD_FILENAMES", + "external-sync-fd=yes", + "import-signaled-fd=yes", + "export-sync-fd=yes", + "queue-submit2=yes", + "fence-signal=yes", + "dory-vulkan-probe --wsi=xcb", + "wsi-surface=xcb", + "surface-create=yes", + "present-queue=yes", + "surface-format-policy=first-capability-format", + "surface-format-id=[1-9][0-9]*", + "color-atlas-format=(bgra8|rgba8)-unorm", + "fifo-present=yes", + "swapchain-create=yes", + "swapchain-extent=64x64", + "swapchain-images=[1-9][0-9]*", + "swapchain-acquire=yes", + "swapchain-render=yes", + "queue-present=yes", + "present-idle=yes", + '/opt/dory/mesa/lib/libvulkan_virtio.so\' \\"/proc/\\$zed_pid/maps\\"', + "sleep 30", + "VK_ERROR_DEVICE_LOST", + "-u ZED_ALLOW_EMULATED_GPU", + 'machine snapshot "$machine"', + 'machine restore-snapshot "$machine"', + 'machine delete-snapshot "$machine"', + "recovery-exact-bytes-restored", + 'identity.get("mode") != "resolved-plan"', + "restore reused the snapshot's stale launch plan", + "snapshot_restore_exact_bytes=PASS", + "graceful-shutdown-armed", + "dory-release-graceful-shutdown.service", + "ExecStop=/bin/sh -c 'printf graceful-shutdown-pass", + "machine stop did not complete cleanly", + "graceful_shutdown=PASS", + "display-baseline-ready", + "Accessibility permission is required for display qualification", + "set size of front window of targetProcess to {960, 640}", + "dynamic-display-resized", + "guest display mode did not follow the host window resize", + "dynamic-display-restored", + "dynamic_retina_display=PASS", + 'keystroke "f" using {command down, control down}', + 'attribute "AXFullScreen"', + "fullscreen-display-resized", + "guest display mode did not follow the full-screen host window", + "fullscreen-display-restored", + "guest display mode did not restore after leaving full screen", + "fullscreen_display=PASS", + "cursor-left-ready", + "xsetroot -cursor_name left_ptr", + "cursor-crosshair-ready", + "xsetroot -cursor_name crosshair", + 'screencapture -C -x -R"$cursor_region"', + "guest cursor shape did not change the captured macOS cursor", + "cursor-shapes.sha256", + "cursor-restored", + "cursor_shape=PASS", + "clipboard-host-to-guest-pass", + "/usr/lib/dory/clipboard get 'text/plain;charset=utf-8'", + "clipboard-guest-source-ready", + "/usr/lib/dory/clipboard set 'text/plain;charset=utf-8'", + "guest clipboard did not reach the host", + "clipboard_bidirectional=PASS", + "input-window-ready", + "xterm -title DoryInputGate", + "click at {clickX, clickY}", + "keystroke inputToken", + "keyboard-pointer-input-pass", + "host keyboard/pointer input did not reach the guest exactly", + "keyboard_pointer_input=PASS", + "workroot must be a strict child of RUNNER_TEMP", + ) + for proof in required: + self.assertIn(proof, text, proof) + + forbidden = ( + "--env DORY_", + '--env "DORY_', + '--bundle "$update_bundle"', + '--kernel "$KERNEL"', + "assert ", + "= virgl2\n", + "rollback-pass", + 'LD_LIBRARY_PATH="\\$library_path"', + "venus_implicit_fencing=true", + 'venus_implicit_fencing="\\$fencing"', + "dory-vulkan-probe --wsi=wayland", + "wsi-surface=\\$wsi", + "WAYLAND_DISPLAY", + "XDG_SESSION_TYPE=wayland", + "ZED_EXPECTED", + "ZED_QUALIFIED", + "pgrep -n -u dorygate -f '/zed.app/libexec/zed-editor'", + "grep -q '^LD_LIBRARY_PATH='", + ) + for stale in forbidden: + self.assertNotIn(stale, text, stale) + + def test_managed_gate_does_not_claim_generic_iso_qualification(self) -> None: + text = GATE.read_text(encoding="utf-8") + self.assertIn("ARM64 EFI ISO installation", text) + self.assertIn("separate end-to-end gate", text) + self.assertIn("generic_arm64_efi_iso_software_baseline=SEPARATE-GATE", text) + self.assertNotIn("--installer-iso", text) + + def test_live_gate_rejects_excess_vmm_entitlement(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + arguments, environment = self._signed_gate_fixture( + pathlib.Path(temporary), extra_vmm_entitlement=True + ) + result = subprocess.run( + arguments, + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("DoryVMM retains forbidden library-validation authority", result.stderr) + + def test_live_gate_rejects_runner_bundle_symlink(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + arguments, environment = self._signed_gate_fixture( + pathlib.Path(temporary), runner_symlink=True + ) + result = subprocess.run( + arguments, + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("DoryHVRunner.app is missing or indirect", result.stderr) + + def test_live_gate_rejects_wrong_vmm_bundle_identity(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + arguments, environment = self._signed_gate_fixture( + pathlib.Path(temporary), vmm_identifier="dev.dory.forged-vmm" + ) + result = subprocess.run( + arguments, + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("DoryVMM CFBundleIdentifier is not dory-vmm", result.stderr) + + def test_live_gate_rejects_vmm_xpc_authority_and_services(self) -> None: + cases = ( + ( + {"extra_vmm_xpc_entitlement": True}, + "DoryVMM retains forbidden XPC authority", + ), + ( + {"vmm_xpc_services": True}, + "DoryVMM must not contain XPCServices", + ), + ) + for options, expected in cases: + with self.subTest(expected=expected), tempfile.TemporaryDirectory() as temporary: + arguments, environment = self._signed_gate_fixture( + pathlib.Path(temporary), **options + ) + result = subprocess.run( + arguments, + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(expected, result.stderr) + + def test_live_gate_rejects_incomplete_worker_and_info_graphs(self) -> None: + cases = ( + ( + {"omit_renderer_worker": True}, + "renderer worker is missing", + ), + ( + {"runner_package_type": "XPC!"}, + "DoryHVRunner CFBundlePackageType is not APPL", + ), + ( + {"vmm_executable_name": "forged-vmm"}, + "DoryVMM CFBundleExecutable is not dory-vmm", + ), + ) + for options, expected in cases: + with self.subTest(expected=expected), tempfile.TemporaryDirectory() as temporary: + arguments, environment = self._signed_gate_fixture( + pathlib.Path(temporary), **options + ) + result = subprocess.run( + arguments, + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(expected, result.stderr) + + def test_release_gate_rejects_adhoc_signature_graph(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + arguments, environment = self._signed_gate_fixture(pathlib.Path(temporary)) + arguments.insert(-2, "--require-release-signature") + result = subprocess.run( + arguments, + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("DoryHVRunner is not signed by Dory's Developer ID", result.stderr) + + def test_non_ubuntu_workroot_check_does_not_require_zed_assets(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + runner_temp = root / "runner" + helpers = root / "helpers" + components = root / "components" + outside = root / "outside" + for directory in (runner_temp, helpers, components): + directory.mkdir() + for helper in ("dorydctl", "dory-vmm"): + path = helpers / helper + path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + path.chmod(0o700) + hv_runner = helpers / "DoryHVRunner.app" / "Contents" / "MacOS" / "dory-hv" + hv_runner.parent.mkdir(parents=True) + hv_runner.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + hv_runner.chmod(0o700) + for worker_name, executable_name in ( + ("DoryFSWorker.xpc", "DoryFSWorker"), + ("DoryRendererWorker.xpc", "DoryRendererWorker"), + ): + worker = ( + helpers + / "DoryHVRunner.app" + / "Contents" + / "XPCServices" + / worker_name + / "Contents" + / "MacOS" + / executable_name + ) + worker.parent.mkdir(parents=True) + worker.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + worker.chmod(0o700) + vmm = helpers / "DoryVMM.app" / "Contents" / "MacOS" / "dory-vmm" + vmm.parent.mkdir(parents=True) + vmm.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + vmm.chmod(0o700) + with (vmm.parents[1] / "Info.plist").open("wb") as handle: + plistlib.dump( + { + "CFBundleExecutable": "dory-vmm", + "CFBundleIdentifier": "dory-vmm", + "CFBundlePackageType": "APPL", + }, + handle, + ) + assets = [] + for name in ("Image-desktop", "debian.ext4", "debian-update.tar"): + path = root / name + path.write_bytes(name.encode("ascii")) + assets.append(path) + + result = subprocess.run( + [ + str(GATE), + "--ctl", + str(helpers / "dorydctl"), + "--component-dir", + str(components), + "--kernel", + str(assets[0]), + "--debian-rootfs", + str(assets[1]), + "--debian-update", + str(assets[2]), + "--distro", + "debian", + "--version", + "9.8.7", + "--workroot", + str(outside), + "--confirm", + "EXACT-CANDIDATE-DESKTOPS", + ], + cwd=ROOT, + env={**os.environ, "RUNNER_TEMP": str(runner_temp)}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(result.returncode, 64, result.stderr) + self.assertIn("workroot must be a strict child of RUNNER_TEMP", result.stderr) + self.assertNotIn("Zed", result.stderr) + self.assertFalse(outside.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-devcontainers-compatibility-gate.py b/.github/scripts/test-devcontainers-compatibility-gate.py new file mode 100644 index 00000000..8ae695d9 --- /dev/null +++ b/.github/scripts/test-devcontainers-compatibility-gate.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the Dev Containers compatibility gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "devcontainers-compatibility-gate.sh" + + +class DevContainersCompatibilityGateTests(unittest.TestCase): + def test_contract_uses_exact_candidate_and_fixture(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-DEVCONTAINERS", + "socket is not owned by the release user", + "Docker CLI is unavailable or indirect", + "--image must be an exact digest reference", + "required offline fixture image is missing", + "dist.integrity", + "registry.npmjs.org", + "executed Dev Containers CLI version does not match", + "host_to_container_workspace=PASS", + "container_to_host_workspace=PASS", + "exact_baseline_cleanup=PASS", + "docker_cli_sha256=", + "node_sha256=", + "npm_sha256=", + ): + self.assertIn(proof, text, proof) + for stale in ('"image": "alpine:3.22"', "assert "): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_or_npm_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result = subprocess.run( + [ + str(GATE), + "--socket", str(pathlib.Path(temporary) / "missing.sock"), + "--docker", "/missing/docker", + "--version", "0.87.0", + "--image", "example.invalid/alpine@sha256:" + "a" * 64, + "--workroot", str(pathlib.Path(temporary) / "evidence"), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-direct-dmg-install-gate.py b/.github/scripts/test-direct-dmg-install-gate.py new file mode 100644 index 00000000..2728d13e --- /dev/null +++ b/.github/scripts/test-direct-dmg-install-gate.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Offline contract checks for the destructive physical DMG install boundary.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "direct-dmg-install-gate.sh" + + +class DirectDMGInstallGateTests(unittest.TestCase): + def invoke(self, *arguments: str, runner_temp: str | None = None) -> subprocess.CompletedProcess[str]: + environment = dict(os.environ) + environment["DORY_RELEASE_CLEAN_USER"] = "1" + if runner_temp is not None: + environment["RUNNER_TEMP"] = runner_temp + return subprocess.run( + ["bash", str(GATE), *arguments], + cwd=ROOT, + env=environment, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + def test_script_is_syntax_valid_and_has_no_python_assertions(self) -> None: + syntax = subprocess.run(["bash", "-n", str(GATE)], check=False) + self.assertEqual(syntax.returncode, 0) + source = GATE.read_text(encoding="utf-8") + self.assertNotIn("assert ", source) + self.assertIn("validate-release-metadata.py", source) + self.assertIn("verify-release-sbom.py", source) + self.assertIn("hdiutil attach -readonly -nobrowse -plist", source) + self.assertIn("release-candidate-live-smoke.sh", source) + self.assertIn("DORY_RELEASE_LIVE_CONFIRMED=ISOLATED-DORY-RELEASE-USER", source) + self.assertIn('DORY_RELEASE_SOURCE_COMMIT="$SOURCE_COMMIT"', source) + self.assertIn('DORY_RELEASE_LIVE_LOG_ROOT="$EVIDENCE/live-smoke"', source) + self.assertIn('$EVIDENCE/live-smoke/live-manifest.txt', source) + + def test_help_documents_the_destructive_confirmation(self) -> None: + result = self.invoke("--help") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("CLEAN-RELEASE-USER-DMG-INSTALL", result.stdout) + self.assertIn("--install-only", result.stdout) + + def test_missing_confirmation_fails_before_mutation(self) -> None: + result = self.invoke( + "--dmg", "missing.dmg", + "--sbom", "missing.json", + "--release-manifest", "missing-manifest.json", + "--version", "9.8.7", + "--build", "42", + "--source-commit", "a" * 40, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("--confirm", result.stderr) + + def test_workroot_must_be_beneath_runner_temp(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-dmg-gate-test.") as directory: + root = pathlib.Path(directory) + inputs = [] + for name in ("candidate.dmg", "candidate.cdx.json", "release-manifest.json"): + path = root / name + path.write_bytes(b"fixture\n") + inputs.append(path) + result = self.invoke( + "--dmg", str(inputs[0]), + "--sbom", str(inputs[1]), + "--release-manifest", str(inputs[2]), + "--version", "9.8.7", + "--build", "42", + "--source-commit", "a" * 40, + "--workroot", "/Applications", + "--confirm", "CLEAN-RELEASE-USER-DMG-INSTALL", + runner_temp=str(root), + ) + self.assertEqual(result.returncode, 2) + self.assertIn("unsafe --workroot", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-dory-machine-create.sh b/.github/scripts/test-dory-machine-create.sh index 3fa0d5cf..1888dcc2 100755 --- a/.github/scripts/test-dory-machine-create.sh +++ b/.github/scripts/test-dory-machine-create.sh @@ -36,7 +36,9 @@ DORY_MACHINE_CREATE_CAPTURE="$capture" \ DORYDCTL_BIN="$TMP/dorydctl" \ DORY_SANDBOX_KERNEL="$TMP/kernel" \ DORY_SANDBOX_ROOTFS="$TMP/rootfs" \ -DORY_MACHINE_ENV_ALLOW_LIST= \ +DORY_MACHINE_ENV_ALLOW_LIST='ANTHROPIC_API_KEY,GH_TOKEN' \ +ANTHROPIC_API_KEY='opaque-anthropic-secret' \ +GH_TOKEN='opaque-github-secret' \ HOME="$TMP/home" \ /bin/bash "$ROOT/scripts/dory" machine create test @@ -51,4 +53,36 @@ printf '%s\n' \ "$TMP/rootfs" > "$expected" cmp "$expected" "$capture" +rm -f "$capture" +if DORY_MACHINE_CREATE_CAPTURE="$capture" \ + DORYDCTL_BIN="$TMP/dorydctl" \ + DORY_SANDBOX_KERNEL="$TMP/kernel" \ + DORY_SANDBOX_ROOTFS="$TMP/rootfs" \ + HOME="$TMP/home" \ + /bin/bash "$ROOT/scripts/dory" machine create rejected --env API_TOKEN=opaque-value \ + >"$TMP/rejected.out" 2>"$TMP/rejected.err"; then + echo "persistent machine environment was accepted" >&2 + exit 1 +fi +grep -F "machine create no longer accepts persistent environment values" "$TMP/rejected.err" >/dev/null +[ ! -e "$capture" ] || { + echo "dorydctl was invoked for rejected persistent environment" >&2 + exit 1 +} + +# Sandbox lifecycle/credential grants use the typed machine-create contract. The shell must not +# resurrect the retired persistent environment escape hatch, and retained status must consume the +# daemon's safe first-class projection rather than a raw environment payload. +grep -F 'create_args+=(--sandbox --sandbox-ssh-agent' "$ROOT/scripts/dory" >/dev/null +grep -F 'policy = status.get("sandboxPolicy")' "$ROOT/scripts/dory" >/dev/null +if grep -F 'create_args+=(--env "DORY_SANDBOX' "$ROOT/scripts/dory" >/dev/null; then + echo "sandbox creation still persists lifecycle authority through raw environment" >&2 + exit 1 +fi +if grep -F 'environment = {row["key"]: row["value"] for row in status.get("env", [])}' \ + "$ROOT/scripts/dory" >/dev/null; then + echo "sandbox status still depends on raw environment disclosure" >&2 + exit 1 +fi + echo "dory machine create regression test: PASS" diff --git a/.github/scripts/test-ecr-registry-retry-gate.py b/.github/scripts/test-ecr-registry-retry-gate.py new file mode 100755 index 00000000..8896dc77 --- /dev/null +++ b/.github/scripts/test-ecr-registry-retry-gate.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Offline contract for interrupted ECR push/retry qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "ecr-registry-retry-gate.sh" + + +class ECRRegistryRetryGateTests(unittest.TestCase): + def test_gate_binds_candidate_account_repository_digests_and_cleanup(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "DISPOSABLE-ECR-INTERRUPT-RETRY", + "Dory socket is not owned by the release user", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "no sibling docker-buildx plugin", + "registry hostname region differs from --region", + "workroot already exists or is indirect", + "isolated Buildx copy differs from the candidate plugin", + "local base image does not retain its exact registry authority", + "AWS caller account differs from the ECR registry account", + "ECR repository authority differs from the requested registry path", + 'rm -f "$WORKDIR/aws-caller.json" "$WORKDIR/ecr-repository.json"', + "DOCKER_CONFIG=\"$DOCKER_CONFIG\"", + "--network none --pull=false --progress=plain", + "first ECR push completed or stalled before an upload could be interrupted", + "interrupted ECR push returned success", + "ECR push output does not contain one exact manifest digest", + "repeated ECR manifest PUT changed the manifest digest", + "ECR registry digest differs from the repeated push digest", + 'REMOTE_DIGEST_REF="$REGISTRY/$REPOSITORY@$remote_digest"', + 'docker_e pull "$REMOTE_DIGEST_REF"', + "--rm --pull=never --network none --label", + "ECR repull/run returned the wrong layer checksum", + "ECR cleanup did not report the one unique image tag", + "remote ECR tag survived confirmed deletion", + "remote ECR deletion could not be verified fail-closed", + "owned ECR retry container survived cleanup", + "isolated Docker credential directory survived cleanup", + "caller_account_sha256=", + "repository_authority_sha256=", + "docker_cli_sha256=", + "buildx_cli_sha256=", + "aws_cli_sha256=", + "registry_digest_agreement=PASS", + "digest_based_repull=PASS", + "remote_deletion_verified=PASS", + "owned_container_cleanup=PASS", + "isolated_credential_cleanup=PASS", + ): + self.assertIn(proof, text, proof) + self.assertIn( + "'^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$'", text + ) + for stale in ( + "assert ", + 'ln -s "$BUILDX"', + 'docker_e pull "$REMOTE_REF"', + 'docker_e run --rm "$REMOTE_REF"', + "aws ecr ", + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_aws_helpers_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--base-image", + "invalid", + "--registry", + "invalid", + "--repository", + "invalid", + "--region", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-endurance-reliability-soak.py b/.github/scripts/test-endurance-reliability-soak.py new file mode 100755 index 00000000..162e5650 --- /dev/null +++ b/.github/scripts/test-endurance-reliability-soak.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Offline contract for the eight-hour endurance reliability release soak.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "endurance-reliability-soak.sh" +ANALYZER = ROOT / "scripts" / "analyze-endurance-resources.py" +HEADER = ( + "phase\tcycle\tepoch\tpid_count\tfd_total\trss_kb\tcpu_percent\tstate_kb\t" + "fseventsd_pid_count\tfseventsd_rss_kb\tfseventsd_cpu_percent\n" +) + + +class EnduranceReliabilitySoakTests(unittest.TestCase): + def test_gate_binds_isolated_candidate_resources_and_terminal_evidence(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + subprocess.run(["python3", "-m", "py_compile", str(ANALYZER)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-ENDURANCE-RELIABILITY", + "Dory socket must be an absolute path", + "Dory socket is unavailable or indirect", + "Dory socket is not owned by the release user", + "state directory must be a direct canonical path", + "process root must exactly equal the state directory", + "dory-dataplane-proxy|gvproxy", + "Docker CLI must be a direct canonical path", + "workroot already exists or is indirect", + "release candidate evidence requires --source-commit", + "release candidate evidence cannot use --cycles", + "release candidate duration must be at least eight hours", + "release candidate image must be digest-pinned", + "release candidate fixture must resolve to linux/arm64", + "release candidate FD growth budget is too permissive", + "DOCKER_CONTEXT", + "COMPOSE_PROJECT_NAME", + 'bounded 120 docker_raw "$@"', + "resource analyzer is unavailable or indirect", + "resource analyzer changed during the soak", + "isolated Docker socket identity changed during the soak", + "isolated state authority changed during the soak", + "Docker CLI changed during the soak", + "endurance gate changed during the soak", + "endurance soak changed the exact fixture image identity", + "guest ownership request changed host bind ownership", + "duration-based soak ended before its requested wall time", + "cycles_sha256=", + "resources_sha256=", + "resource_analysis_sha256=", + "analyzer_sha256=", + "same_user_socket=PASS", + "exact_process_authority=PASS", + "exact_image_identity=PASS", + "resource_plateau=PASS", + "owned_cleanup=PASS", + "release_qualifying=", + "status=PASS", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'mkdir -p "$WORKROOT"', + 'DOCKER_HOST="unix://$SOCKET" docker', + 'echo "socket=$SOCKET"', + 'echo "state_dir=$STATE_DIR"', + 'echo "process_root=$PROCESS_ROOT"', + "DORY_ENDURANCE_SOURCE_ONLY", + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_socket_state_cli_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + workroot = root / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(root / "missing.sock"), + "--state-dir", + str(root / "missing-state"), + "--docker", + "/missing/docker", + "--image", + "invalid", + "--process-root", + str(root / "missing-state"), + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + def test_analyzer_accepts_exact_plateau_and_rejects_bad_schema_and_nan(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + valid = root / "valid.tsv" + valid.write_text( + HEADER + + "baseline\t0\t100\t2\t10\t1000\t1.0\t2000\t1\t3000\t1.0\n" + + "cleaned\t1\t110\t2\t11\t1010\t1.0\t2010\t1\t3010\t1.0\n" + + "final\t1\t120\t2\t11\t1010\t1.0\t2010\t1\t3010\t1.0\n", + encoding="utf-8", + ) + args = [ + "python3", + str(ANALYZER), + str(valid), + "--fd-growth", + "16", + "--rss-growth-mb", + "384", + "--disk-growth-mb", + "256", + "--idle-cpu", + "25", + "--fseventsd-rss-growth-mb", + "128", + "--fseventsd-cpu", + "25", + ] + result = subprocess.run(args, text=True, capture_output=True, check=False) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("resource plateau PASS", result.stdout) + + bad_schema = root / "bad-schema.tsv" + bad_schema.write_text(HEADER.replace("phase", "unexpected") + "x\n", encoding="utf-8") + result = subprocess.run(args[:2] + [str(bad_schema)] + args[3:], text=True, capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("unexpected schema", result.stderr) + + invalid = root / "invalid.tsv" + invalid.write_text(valid.read_text(encoding="utf-8").replace("1.0", "nan", 1), encoding="utf-8") + result = subprocess.run(args[:2] + [str(invalid)] + args[3:], text=True, capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("finite and non-negative", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-external-volume-bind-gate.py b/.github/scripts/test-external-volume-bind-gate.py new file mode 100644 index 00000000..9356d720 --- /dev/null +++ b/.github/scripts/test-external-volume-bind-gate.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the physical external-APFS bind gate.""" + +from __future__ import annotations + +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "external-volume-bind-gate.sh" + + +class ExternalVolumeBindGateTests(unittest.TestCase): + def test_external_volume_contract_is_complete(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "DISCONNECT-RECONNECT-DEDICATED-APFS", + "DORY-DEDICATED-RELEASE-APFS-V1", + "test root must be below /Volumes/", + "test path is not on an external physical volume", + "external test volume is not APFS", + "--image must be a digest-pinned fixture", + "candidate socket is not owned by the release user", + "external FIFO rejected promptly", + "operations=10000", + "64 MiB external bind checksum mismatch", + '"$DORY" engine sleep', + 'diskutil unmount "$device_identifier"', + "missing external volume unexpectedly accepted a bind write", + "external APFS bytes were lost across unmount/remount", + "disconnect_reconnect=PASS", + ): + self.assertIn(proof, text, proof) + for stale in ("alpine:latest", "assert "): + self.assertNotIn(stale, text, stale) + + def test_confirmation_is_required_before_host_mutation(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result = subprocess.run( + [ + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--dory", + "/missing/dory", + "--state-dir", + str(pathlib.Path(temporary) / "state"), + "--path", + str(pathlib.Path(temporary) / "volume"), + "--image", + "example.invalid/alpine@sha256:" + "a" * 64, + "--workroot", + str(pathlib.Path(temporary) / "evidence"), + ], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-generate-appcast.py b/.github/scripts/test-generate-appcast.py new file mode 100644 index 00000000..fdb5d418 --- /dev/null +++ b/.github/scripts/test-generate-appcast.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the signed appcast generator.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest +import xml.etree.ElementTree as ET + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GENERATOR = ROOT / "scripts/generate-appcast.sh" +SPARKLE = "http://www.andymatuschak.org/xml-namespaces/sparkle" +DORY = "https://augani.github.io/dory/appcast" + + +class GenerateAppcastTests(unittest.TestCase): + def test_schema_two_item_is_escaped_signed_and_preserves_only_older_items(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + artifact = root / "Dory-1.2.3-app-update.zip" + artifact.write_bytes(b"signed candidate bytes") + previous = root / "previous.xml" + previous.write_text( + """ + +42 +1.2.3 + + +41 +1.2.2 + +\n""", + encoding="utf-8", + ) + output = root / "appcast.xml" + environment = { + **os.environ, + "DORY_SPARKLE_ED_SIGNATURE": "c2lnbmF0dXJl", + "DORY_APPCAST_TITLE": "Dory & Desktop", + "DORY_APPCAST_PUBDATE": "Fri, 21 Aug 2026 06:00:00 +0000", + "DORY_DATA_SCHEMA_VERSION": "2", + "DORY_MINIMUM_READABLE_DATA_SCHEMA": "1", + "DORY_MAXIMUM_READABLE_DATA_SCHEMA": "2", + "DORY_COMPONENT_CATALOG_SCHEMA": "2", + } + completed = subprocess.run( + ["bash", str(GENERATOR), "1.2.3", "42", str(artifact), str(output), str(previous)], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + tree = ET.parse(output) + channel = tree.getroot().find("channel") + self.assertIsNotNone(channel) + self.assertEqual(channel.findtext("title"), "Dory & Desktop") + items = channel.findall("item") + self.assertEqual(len(items), 2) + self.assertEqual(items[0].findtext(f"{{{SPARKLE}}}version"), "42") + self.assertEqual(items[0].findtext(f"{{{DORY}}}dataSchemaVersion"), "2") + self.assertEqual(items[0].findtext(f"{{{DORY}}}componentCatalogSchema"), "2") + enclosure = items[0].find("enclosure") + self.assertIsNotNone(enclosure) + self.assertEqual(enclosure.attrib[f"{{{SPARKLE}}}edSignature"], "c2lnbmF0dXJl") + self.assertEqual(int(enclosure.attrib["length"]), artifact.stat().st_size) + self.assertEqual(items[1].findtext(f"{{{SPARKLE}}}version"), "41") + + def test_invalid_schema_range_fails_without_publishing_output(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + artifact = root / "candidate.zip" + artifact.write_bytes(b"candidate") + output = root / "appcast.xml" + completed = subprocess.run( + ["bash", str(GENERATOR), "1.2.3", "42", str(artifact), str(output)], + cwd=ROOT, + env={ + **os.environ, + "DORY_SPARKLE_ED_SIGNATURE": "c2lnbmF0dXJl", + "DORY_DATA_SCHEMA_VERSION": "3", + "DORY_MINIMUM_READABLE_DATA_SCHEMA": "1", + "DORY_MAXIMUM_READABLE_DATA_SCHEMA": "2", + }, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertNotEqual(completed.returncode, 0) + self.assertIn("range excludes", completed.stderr) + self.assertFalse(output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-graphics-backend-resolver.py b/.github/scripts/test-graphics-backend-resolver.py new file mode 100644 index 00000000..53216784 --- /dev/null +++ b/.github/scripts/test-graphics-backend-resolver.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import pathlib +import shlex +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +RESOLVER = ROOT / "guest/desktop/rootfs-overlay/usr/lib/dory/resolve-graphics-backend" +ACTIVATOR = ROOT / "guest/desktop/rootfs-overlay/usr/lib/dory/configure-graphics-backend" +DISPLAY_MANAGER_DROP_IN = ( + ROOT + / "guest/desktop/rootfs-overlay/etc/systemd/system/display-manager.service.d" + / "10-dory-graphics.conf" +) + + +class GraphicsBackendResolverTests(unittest.TestCase): + def resolve(self, command_line: str, expected: str) -> None: + result = subprocess.run( + [str(RESOLVER), command_line], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + self.assertEqual(result.stdout, f"{expected}\n") + self.assertEqual(result.stderr, "") + + def reject(self, command_line: str, message: str) -> None: + result = subprocess.run( + [str(RESOLVER), command_line], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(message, result.stderr) + + def test_resolves_default_versioned_and_legacy_contracts(self) -> None: + self.resolve("quiet root=/dev/vda", "software") + self.resolve("quiet dory.graphics=software", "software") + self.resolve("dory.graphics=virgl", "virgl2") + self.resolve("dory.graphics=virgl-venus", "virgl2+venus") + self.resolve("quiet dory.gpu=venus", "virgl2+venus") + + def test_rejects_duplicate_conflicting_and_unknown_authority(self) -> None: + self.reject( + "dory.graphics=virgl dory.graphics=virgl", + "multiple dory.graphics authorities", + ) + self.reject( + "dory.graphics=virgl-venus dory.gpu=venus", + "versioned and legacy graphics authorities conflict", + ) + self.reject("dory.graphics=future", "unsupported graphics contract") + self.reject("dory.gpu=software", "unsupported legacy graphics contract") + + +class GraphicsBackendActivationTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.temporary.name) + self.environment = self.root / "etc/environment.d/70-dory-graphics.conf" + self.session = self.root / "etc/X11/Xsession.d/70dory-graphics" + self.requested = self.root / "run/dory/graphics-requested-backend" + self.effective = self.root / "run/dory/graphics-backend" + self.status = self.root / "run/dory/graphics-status" + self.icd = self.root / "opt/dory/mesa/share/vulkan/icd.d/virtio_icd.aarch64.json" + self.preflight = self.root / "usr/lib/dory/preflight-graphics-pack" + self.cmdline = self.root / "proc/cmdline" + + rewritten = ACTIVATOR.read_text(encoding="utf-8") + assignments = { + "environment_file=/etc/environment.d/70-dory-graphics.conf": + f"environment_file={shlex.quote(str(self.environment))}", + "session_environment=/etc/X11/Xsession.d/70dory-graphics": + f"session_environment={shlex.quote(str(self.session))}", + "requested_file=/run/dory/graphics-requested-backend": + f"requested_file={shlex.quote(str(self.requested))}", + "effective_file=/run/dory/graphics-backend": + f"effective_file={shlex.quote(str(self.effective))}", + "graphics_status=/run/dory/graphics-status": + f"graphics_status={shlex.quote(str(self.status))}", + "venus_icd=/opt/dory/mesa/share/vulkan/icd.d/virtio_icd.aarch64.json": + f"venus_icd={shlex.quote(str(self.icd))}", + "venus_preflight=/usr/lib/dory/preflight-graphics-pack": + f"venus_preflight={shlex.quote(str(self.preflight))}", + "backend_resolver=/usr/lib/dory/resolve-graphics-backend": + f"backend_resolver={shlex.quote(str(RESOLVER))}", + "kernel_cmdline_file=/proc/cmdline": + f"kernel_cmdline_file={shlex.quote(str(self.cmdline))}", + } + for original, replacement in assignments.items(): + self.assertEqual(rewritten.count(original), 1, original) + rewritten = rewritten.replace(original, replacement) + + self.script = self.root / "configure-graphics-backend" + self.script.write_text(rewritten, encoding="utf-8") + self.script.chmod(0o700) + self.icd.parent.mkdir(parents=True) + self.icd.write_text("{}\n", encoding="utf-8") + self.cmdline.parent.mkdir(parents=True) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def configure_preflight(self, exit_code: int) -> None: + self.preflight.parent.mkdir(parents=True, exist_ok=True) + self.preflight.write_text( + "#!/bin/sh\n" + "printf '%s\\n' 'driver=venus external-sync-fd=yes " + "import-signaled-fd=yes queue-submit2=yes fence-signal=yes'\n" + f"exit {exit_code}\n", + encoding="utf-8", + ) + self.preflight.chmod(0o700) + + def activate(self, command_line: str) -> subprocess.CompletedProcess[str]: + self.cmdline.write_text(f"{command_line}\n", encoding="utf-8") + return subprocess.run( + [str(self.script)], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + def test_venus_publishes_requested_and_effective_state_after_preflight(self) -> None: + self.configure_preflight(0) + result = self.activate("quiet dory.graphics=virgl-venus") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.requested.read_text(), "virgl2+venus\n") + self.assertEqual(self.effective.read_text(), "virgl2+venus\n") + self.assertIn("venus-ready: driver=venus", self.status.read_text()) + expected_icd = str(self.icd) + self.assertEqual( + self.environment.read_text().splitlines()[1:], + [ + "GSK_RENDERER=gl", + f"VK_DRIVER_FILES={expected_icd}", + f"VK_ICD_FILENAMES={expected_icd}", + ], + ) + self.assertEqual( + self.session.read_text().splitlines()[1:], + [ + "export GSK_RENDERER=gl", + f"export VK_DRIVER_FILES={expected_icd}", + f"export VK_ICD_FILENAMES={expected_icd}", + ], + ) + + def test_failed_preflight_falls_back_to_virgl_without_blocking_desktop(self) -> None: + self.configure_preflight(23) + for path in (self.environment, self.session, self.effective): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("stale\n", encoding="utf-8") + result = self.activate("dory.graphics=virgl-venus") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.requested.read_text(), "virgl2+venus\n") + self.assertEqual(self.effective.read_text(), "virgl2\n") + self.assertEqual( + self.environment.read_text().splitlines()[1:], + ["GSK_RENDERER=gl"], + ) + self.assertEqual( + self.session.read_text().splitlines()[1:], + ["export GSK_RENDERER=gl"], + ) + self.assertIn("venus-unavailable:", self.status.read_text()) + self.assertIn("fallback=virgl2", self.status.read_text()) + + def test_missing_venus_pack_falls_back_to_virgl(self) -> None: + result = self.activate("dory.graphics=virgl-venus") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.requested.read_text(), "virgl2+venus\n") + self.assertEqual(self.effective.read_text(), "virgl2\n") + self.assertIn("preflight is missing", self.status.read_text()) + + def test_software_activation_keeps_managed_gtk_policy_without_venus(self) -> None: + self.configure_preflight(0) + result = self.activate("quiet dory.graphics=software") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.requested.read_text(), "software\n") + self.assertEqual(self.effective.read_text(), "software\n") + self.assertEqual(self.status.read_text(), "software-ready\n") + self.assertEqual( + self.environment.read_text().splitlines()[1:], + ["GSK_RENDERER=gl"], + ) + self.assertEqual( + self.session.read_text().splitlines()[1:], + ["export GSK_RENDERER=gl"], + ) + + def test_conflicting_authority_has_no_requested_or_effective_state(self) -> None: + self.configure_preflight(0) + result = self.activate("dory.graphics=virgl-venus dory.gpu=venus") + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.requested.exists()) + self.assertFalse(self.effective.exists()) + self.assertIn("authorities conflict", self.status.read_text()) + + def test_display_manager_orders_after_graphics_without_requiring_it(self) -> None: + drop_in = DISPLAY_MANAGER_DROP_IN.read_text(encoding="utf-8") + self.assertIn("Wants=dory-graphics-backend.service", drop_in) + self.assertIn("After=dory-graphics-backend.service", drop_in) + self.assertNotIn("Requires=dory-graphics-backend.service", drop_in) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-graphics-pack-installer.py b/.github/scripts/test-graphics-pack-installer.py new file mode 100644 index 00000000..5e8d7dd1 --- /dev/null +++ b/.github/scripts/test-graphics-pack-installer.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import pathlib +import subprocess +import tarfile +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +INSTALLER = ROOT / "guest/desktop/install-graphics-pack.sh" +EXPECTED_FILES = { + "lib/libvulkan_virtio.so": b"candidate-icd", + "libexec/dory-vulkan-compositor-probe": b"candidate-compositor-probe", + "libexec/dory-vulkan-probe": b"candidate-probe", + "share/dory/build-packages.txt": b"fixture=1\n", + "share/dory/runtime.env": b"schema=6\npack_layout=single-tree\n", + "share/vulkan/icd.d/virtio_icd.aarch64.json": b"{}\n", +} + + +def archive( + root: pathlib.Path, + name: str, + *, + extra: str | None = None, + symlink: bool = False, + runtime_manifest: bytes | None = None, +) -> pathlib.Path: + source = root / f"{name}-source" / "opt/dory/mesa" + for relative, contents in EXPECTED_FILES.items(): + path = source / relative + path.parent.mkdir(parents=True, exist_ok=True) + if relative == "share/dory/runtime.env" and runtime_manifest is not None: + contents = runtime_manifest + path.write_bytes(contents) + path.chmod(0o755 if relative.startswith("libexec/") else 0o644) + if extra: + path = source / extra + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"stale") + path.chmod(0o644) + if symlink: + (source / "lib/escape.so").symlink_to("/tmp/escape") + tar_path = root / f"{name}.tar" + with tarfile.open(tar_path, "w", format=tarfile.PAX_FORMAT) as output: + output.add(source.parents[1], arcname="./opt", recursive=True) + compressed = root / f"{name}.tar.zst" + subprocess.run( + ["zstd", "-q", "-f", str(tar_path), "-o", str(compressed)], + check=True, + ) + return compressed + + +class GraphicsPackInstallerTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="dory-graphics-pack-test-") + self.root = pathlib.Path(self.temporary.name) + self.target = self.root / "target" + self.target.mkdir() + + def tearDown(self) -> None: + self.temporary.cleanup() + + def install(self, candidate: pathlib.Path, *, succeeds: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + [str(INSTALLER), str(candidate), str(self.target), str(os.getuid())], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if succeeds and result.returncode != 0: + self.fail(result.stderr) + if not succeeds and result.returncode == 0: + self.fail("invalid graphics pack unexpectedly installed") + return result + + def test_old_schema_tree_is_replaced_without_stale_dso(self) -> None: + old = self.target / "opt/dory/mesa" + (old / "lib").mkdir(parents=True) + (old / "lib/libxcb-keysyms.so.1.0.0").write_bytes(b"old") + (old / "share/dory").mkdir(parents=True) + (old / "share/dory/runtime.env").write_text("schema=2\n", encoding="utf-8") + + self.install(archive(self.root, "candidate")) + + installed = self.target / "opt/dory/mesa" + actual = { + path.relative_to(installed).as_posix(): path.read_bytes() + for path in installed.rglob("*") + if path.is_file() + } + self.assertEqual(actual, EXPECTED_FILES) + self.assertFalse((self.target / "opt/dory/.mesa-update-transaction").exists()) + + def test_extra_file_and_symlink_are_rejected_without_replacing_old_tree(self) -> None: + old = self.target / "opt/dory/mesa" + old.mkdir(parents=True) + sentinel = old / "sentinel" + sentinel.write_bytes(b"old") + + self.install(archive(self.root, "extra", extra="lib/libxcb-keysyms.so.1"), succeeds=False) + self.assertEqual(sentinel.read_bytes(), b"old") + self.install(archive(self.root, "symlink", symlink=True), succeeds=False) + self.assertEqual(sentinel.read_bytes(), b"old") + + def test_schema_four_candidate_is_rejected_without_replacing_old_tree(self) -> None: + old = self.target / "opt/dory/mesa" + old.mkdir(parents=True) + sentinel = old / "sentinel" + sentinel.write_bytes(b"old") + + candidate = archive( + self.root, + "schema-four", + runtime_manifest=b"schema=4\npack_layout=single-tree\n", + ) + self.install(candidate, succeeds=False) + self.assertEqual(sentinel.read_bytes(), b"old") + + def test_interrupted_old_tree_rename_is_recovered_before_replacement(self) -> None: + transaction = self.target / "opt/dory/.mesa-update-transaction" + previous = transaction / "previous" + previous.mkdir(parents=True) + (previous / "old-sentinel").write_bytes(b"old") + (transaction / "owner").write_text("pid=999999999\n", encoding="utf-8") + + self.install(archive(self.root, "recovery")) + + self.assertFalse(transaction.exists()) + self.assertFalse((self.target / "opt/dory/mesa/old-sentinel").exists()) + self.assertEqual( + (self.target / "opt/dory/mesa/lib/libvulkan_virtio.so").read_bytes(), + b"candidate-icd", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-gvproxy-qemu-switch-gate.py b/.github/scripts/test-gvproxy-qemu-switch-gate.py new file mode 100755 index 00000000..f9cac0c2 --- /dev/null +++ b/.github/scripts/test-gvproxy-qemu-switch-gate.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Offline contract for exact gvproxy QEMU-switch qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "gvproxy-qemu-switch-gate.py" + + +class GVProxyQEMUSwitchGateTests(unittest.TestCase): + def test_gate_binds_compiled_identity_private_sockets_frames_and_shutdown(self) -> None: + subprocess.run(["python3", "-m", "py_compile", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "EXACT-GVPROXY-QEMU-SWITCH", + "gvproxy must be an absolute path", + "gvproxy is unavailable or indirect", + "gvproxy is not owned by the release user", + "release candidate digest is compiled and cannot be overridden", + "release candidate evidence requires --source-commit", + "release candidate evidence requires --provenance", + "provenance is missing or indirect", + "provenance must contain exactly one verified_sha256", + "reproducible-build SHA-256 mismatch", + "unexpected version identity", + "workroot already exists or is indirect", + "canonical workroot already exists or is indirect", + "evidence must be the exact WORKROOT/manifest.txt authority", + "gvproxy published a missing, indirect, or foreign-owned switch socket", + "LAN-to-guest Ethernet frame changed in transit", + "guest-to-LAN Ethernet frame", + "gvproxy exited before teardown", + "gvproxy did not terminate within two seconds of SIGTERM", + "schema=3", + "status=PASS", + "source_commit=", + "gvproxy_sha256=", + "gvproxy_build_sha256=", + "provenance_sha256=", + "same_user_switch_sockets=PASS", + "graceful_helper_shutdown=PASS", + "lan_to_guest=PASS", + "guest_to_lan=PASS", + "frame_contract_sha256=", + "release_qualifying=", + "evidence.chmod(0o600)", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "gvproxy_path=", + 'os.environ.get("DORY_NETWORK_MTU"', + 'mkdir(parents=True, exist_ok=True)', + '"release_qualifying=true"', + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_binary_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "python3", + str(GATE), + "/missing/gvproxy", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertNotEqual(result.returncode, 0, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-homebrew-install-gate.py b/.github/scripts/test-homebrew-install-gate.py new file mode 100755 index 00000000..271180cb --- /dev/null +++ b/.github/scripts/test-homebrew-install-gate.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Non-mutating contract tests for the clean-user Homebrew release gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "homebrew-install-gate.sh" +SOURCE_COMMIT = "a" * 40 + + +class HomebrewInstallGateTests(unittest.TestCase): + def invoke( + self, + candidate: pathlib.Path, + workroot: pathlib.Path, + *, + clean_user: bool = True, + confirmation: str = "CLEAN-RELEASE-USER-HOMEBREW-INSTALL", + temporary_root: pathlib.Path, + ) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment["RUNNER_TEMP"] = str(temporary_root) + environment["PYTHONOPTIMIZE"] = "2" + if clean_user: + environment["DORY_RELEASE_CLEAN_USER"] = "1" + else: + environment.pop("DORY_RELEASE_CLEAN_USER", None) + return subprocess.run( + [ + str(GATE), + "--candidate-dir", + str(candidate), + "--version", + "9.8.7", + "--build", + "42", + "--source-commit", + SOURCE_COMMIT, + "--workroot", + str(workroot), + "--confirm", + confirmation, + ], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + def test_source_is_shell_valid_and_has_no_optimizer_bypass(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], cwd=ROOT, check=True) + source = GATE.read_text(encoding="utf-8") + self.assertNotIn("assert ", source) + for contract in ( + "scripts/validate-release-metadata.py", + "scripts/verify-release-sbom.py", + "com.apple.quarantine", + "source=Notarized Developer ID", + "data_drive_preserved=PASS", + "zap_preserved_data=PASS", + "profile_restoration=PASS", + ): + self.assertIn(contract, source) + + def test_confirmation_and_clean_user_guards_precede_mutation(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-homebrew-gate-test.") as raw: + temporary = pathlib.Path(raw).resolve() + candidate = temporary / "candidate" + candidate.mkdir() + workroot = temporary / "dory-homebrew-install" + missing_confirmation = self.invoke( + candidate, + workroot, + confirmation="wrong", + temporary_root=temporary, + ) + missing_clean_user = self.invoke( + candidate, + workroot, + clean_user=False, + temporary_root=temporary, + ) + self.assertNotEqual(missing_confirmation.returncode, 0) + self.assertIn("--confirm CLEAN-RELEASE-USER-HOMEBREW-INSTALL", missing_confirmation.stdout) + self.assertNotEqual(missing_clean_user.returncode, 0) + self.assertIn("DORY_RELEASE_CLEAN_USER=1 is required", missing_clean_user.stdout) + + def test_candidate_symlink_is_rejected_before_host_checks(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-homebrew-gate-test.") as raw: + temporary = pathlib.Path(raw).resolve() + target = temporary / "candidate-target" + target.mkdir() + candidate = temporary / "candidate-link" + candidate.symlink_to(target, target_is_directory=True) + result = self.invoke( + candidate, + temporary / "dory-homebrew-install", + temporary_root=temporary, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("--candidate-dir must be a direct directory", result.stdout) + + def test_workroot_is_restricted_to_owned_runner_temp_namespace(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-homebrew-gate-test.") as raw: + temporary = pathlib.Path(raw).resolve() + candidate = temporary / "candidate" + candidate.mkdir() + wrong_name = self.invoke( + candidate, + temporary / "unscoped-output", + temporary_root=temporary, + ) + outside = self.invoke( + candidate, + ROOT / "dory-homebrew-install", + temporary_root=temporary, + ) + target = temporary / "existing-output" + target.mkdir() + link = temporary / "dory-homebrew-install" + link.symlink_to(target, target_is_directory=True) + symlink = self.invoke(candidate, link, temporary_root=temporary) + nested_candidate = temporary / "dory-homebrew-install.candidate-parent" / "candidate" + nested_candidate.mkdir(parents=True) + overlap = self.invoke( + nested_candidate, + nested_candidate.parent, + temporary_root=temporary, + ) + self.assertIn("dedicated dory-homebrew-install name", wrong_name.stdout) + self.assertIn("inside the runner temporary directory", outside.stdout) + self.assertIn("must not be a symlink", symlink.stdout) + self.assertIn("cannot contain the candidate", overlap.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-host-network-integrity-gate.py b/.github/scripts/test-host-network-integrity-gate.py new file mode 100644 index 00000000..b865d3a1 --- /dev/null +++ b/.github/scripts/test-host-network-integrity-gate.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the physical host-network recovery gate.""" + +from __future__ import annotations + +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "host-network-integrity-gate.sh" + + +class HostNetworkIntegrityGateTests(unittest.TestCase): + def test_physical_recovery_contract_is_complete(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + + for proof in ( + "SLEEP-AND-WAKE-THIS-MAC", + "VPN-ROUTE-CHURN", + "network probe image must be digest-pinned", + "Dory socket is not owned by the release user", + "candidate app has no notarization ticket", + "Notarized Developer ID", + "sudo -n pmset relative wake", + "sudo -n pmset sleepnow", + "default-route.contract dns.contract proxy.contract service-dns.contract resolvers.contract", + "required custom DNS server is absent", + "no active VPN-like interface is present", + "already has an active Tailscale exit node", + "ROUTE_CHURN_ROUNDS=3", + "Arm cleanup before requesting the mutation", + "host network contract did not self-heal after exit-node round", + "interactive machine shell", + "fresh machine exec failed", + "machine disk persistence failed", + "release_qualifying=", + "tailscale_cli_sha256=", + ): + self.assertIn(proof, text, proof) + + self.assertRegex(text, r"\^\.\+@sha256:\[0-9a-f\]\{64\}\$") + self.assertIn("(.ExitNodeStatus // null) == null", text) + self.assertIn("(.ExitNode // false) == true", text) + self.assertLess( + text.index("TAILSCALE_EXIT_NODE_ACTIVE=1", text.index("run_route_churn()")), + text.index('"$TAILSCALE_BIN" set --exit-node="$TAILSCALE_EXIT_NODE"'), + ) + self.assertNotIn("tailscale-baseline-disable", text) + for stale in ("alpine:latest", "assert "): + self.assertNotIn(stale, text, stale) + + def test_physical_confirmation_fails_before_host_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result = subprocess.run( + [ + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--app", + "/missing/Dory.app", + "--workroot", + str(pathlib.Path(temporary) / "evidence"), + ], + cwd=ROOT, + env={ + "DORY_NETWORK_INTEGRITY_IMAGE": "example.invalid/alpine@sha256:" + "a" * 64, + "HOME": temporary, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + }, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("physical sleep requires --confirm-physical-sleep", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-interrupted-upgrade-rollback-gate.py b/.github/scripts/test-interrupted-upgrade-rollback-gate.py new file mode 100755 index 00000000..80505b87 --- /dev/null +++ b/.github/scripts/test-interrupted-upgrade-rollback-gate.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Non-mutating arming tests for the physical interrupted-upgrade gate.""" + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import sys +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "interrupted-upgrade-rollback-gate.sh" +WORKFLOW = ROOT / ".github" / "workflows" / "release.yml" +PUBLIC_KEY = "AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4=" + + +class InterruptedUpgradeRollbackGateTests(unittest.TestCase): + @staticmethod + def fixture(temporary: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path]: + app = temporary / "Dory.app" + app.mkdir() + signer = temporary / "sign_update" + signer.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + signer.chmod(0o755) + return app, signer + + @staticmethod + def invoke( + temporary: pathlib.Path, + app: pathlib.Path, + signer: pathlib.Path, + *, + confirmation: str = "CLEAN-RELEASE-USER-INTERRUPTED-UPGRADE", + workroot_name: str = "dory-release-live-transactional-upgrade", + ) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment.update( + { + "DORY_RELEASE_CLEAN_USER": "1", + "DORY_SPARKLE_PRIVATE_KEY": "test-private-key", + "PYTHONOPTIMIZE": "2", + "RUNNER_TEMP": str(temporary), + } + ) + return subprocess.run( + [ + str(GATE), + "--candidate-app", str(app), + "--sign-update", str(signer), + "--version", "9.8.7", + "--build", "42", + "--source-commit", "a" * 40, + "--fixture-image", "example.invalid/alpine@sha256:" + "b" * 64, + "--workroot", str(temporary / workroot_name), + "--confirm", confirmation, + ], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + def test_source_is_shell_valid_optimizer_safe_and_exactly_bound(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], cwd=ROOT, check=True) + source = GATE.read_text(encoding="utf-8") + self.assertNotIn("assert ", source) + for contract in ( + "candidate app must be a direct Dory.app", + "sign_update must be a direct executable", + "nested virtualization is not release-qualifying", + "source=Notarized Developer ID", + "candidate is not signed by Dory team 864H636QW4", + "workroot must be inside runner temporary storage", + "run authority already exists", + '"schemaVersion": 2', + '"role": "qualification-evidence"', + '"virtualMachineQualification": {', + "2", + ".github/scripts/verify-ed25519-signature.swift", + "automaticRollback", + "component_catalog_signatures_verified=PASS", + "component_qualification_authority=PASS", + "durable_volume_sentinel_preserved=PASS", + "initial_clean_user_state_restored=PASS", + ): + self.assertIn(contract, source) + self.assertNotIn("codesign --force --deep --sign", source) + + def test_schema_two_catalog_fixture_has_complete_qualification_authority(self) -> None: + source = GATE.read_text(encoding="utf-8") + start = source.index("import base64, hashlib, json, pathlib, sys") + end = source.index("\nPY\n\nsign_file()", start) + generator = source[start:end] + with tempfile.TemporaryDirectory(prefix="dory-upgrade-catalog-test.") as raw: + root = pathlib.Path(raw).resolve() + (root / "component-v1.txt").write_text("generation-one\n", encoding="utf-8") + (root / "component-v2.txt").write_text("generation-two\n", encoding="utf-8") + prior_argv = sys.argv + try: + sys.argv = [ + "fixture-generator", str(root), "9.8.7", "a" * 40, + "https://127.0.0.1/catalog-v1.json", + "https://127.0.0.1/catalog-v2.json", + "b" * 64, "c" * 64, "Mac16,1", "26A123", PUBLIC_KEY, + ] + exec(compile(generator, str(GATE), "exec"), {}) + finally: + sys.argv = prior_argv + + first = json.loads((root / "catalog-v1.json").read_text(encoding="utf-8")) + second = json.loads((root / "catalog-v2.json").read_text(encoding="utf-8")) + manifest = json.loads( + (root / "virtual-machine-qualification.json").read_text(encoding="utf-8") + ) + self.assertEqual(first["schemaVersion"], 2) + self.assertEqual(second["schemaVersion"], 2) + self.assertLess(first["generatedAt"], second["generatedAt"]) + self.assertEqual( + first["virtualMachineQualification"]["manifestIdentity"], + manifest["manifestIdentity"], + ) + machines = next(item for item in first["components"] if item["id"] == "linux-machines") + self.assertEqual(machines["qualification"], [manifest["records"][0]["qualificationIdentity"]]) + self.assertEqual( + {asset["role"] for asset in machines["assets"]}, + {"qualification-evidence", "build-metadata"}, + ) + self.assertEqual(manifest["records"][0]["hostHardwareModelIdentifier"], "Mac16,1") + + def test_release_evidence_binding_is_optimizer_safe(self) -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + block = workflow.split( + " - name: Verify interrupted transactional-upgrade evidence binding", 1 + )[1].split("\n - name:", 1)[0] + self.assertNotIn("assert ", block) + for contract in ( + '"component_catalog_schema": "2"', + '"component_catalog_signatures_verified"', + '"component_qualification_authority"', + "invalid or duplicate evidence row", + ): + self.assertIn(contract, block) + + def test_confirmation_indirect_candidate_and_workroot_fail_before_physical_probe(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-upgrade-gate-test.") as raw: + temporary = pathlib.Path(raw).resolve() + app, signer = self.fixture(temporary) + confirmation = self.invoke(temporary, app, signer, confirmation="wrong") + linked = temporary / "linked-Dory.app" + linked.symlink_to(app, target_is_directory=True) + indirect = self.invoke(temporary, linked, signer) + workroot = self.invoke(temporary, app, signer, workroot_name="unscoped") + self.assertIn("requires --confirm", confirmation.stdout) + self.assertIn("candidate app must be a direct Dory.app", indirect.stdout) + self.assertIn("dedicated interrupted-upgrade name", workroot.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-kubernetes-tooling-compatibility-gate.py b/.github/scripts/test-kubernetes-tooling-compatibility-gate.py new file mode 100755 index 00000000..dc9844ca --- /dev/null +++ b/.github/scripts/test-kubernetes-tooling-compatibility-gate.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Offline contract for the exact Kubernetes tooling compatibility gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "kubernetes-tooling-compatibility-gate.sh" + + +class KubernetesToolingCompatibilityGateTests(unittest.TestCase): + def test_gate_binds_candidate_tools_control_plane_workloads_and_cleanup(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-KUBERNETES-TOOLING", + "socket is not owned by the release user", + "$helper_name helper must be an absolute path", + "$helper_name helper is unavailable or indirect", + "tool cache tilt.tgz is missing or indirect", + "tool cache skaffold is missing or indirect", + "verified Tilt archive did not contain a direct regular binary", + "re.escape(version)", + '"$KUBECTL" version --client -o json', + 'shasum -a 256 "$DOCKER"', + 'shasum -a 256 "$KUBECTL"', + 'docker_e run -d --pull=never --privileged --name "$K3S_CONTAINER"', + 'dory.release.kubernetes-tooling.run=$RUN_ID', + "nested k3s container does not use the exact qualified image", + "nested k3s control plane unexpectedly binds host paths", + 'binding.get("HostIp") != "127.0.0.1"', + 're.fullmatch(r"127\\.0\\.0\\.1:([0-9]{1,5})\\n?", text)', + 'servers != ["https://127.0.0.1:6443"]', + "host Kubernetes API connection failed at stability sample", + "expected exactly one qualified workload pod", + "workload pod does not declare the exact qualified image", + "runtime workload image has no immutable image ID", + "ingress_only_network_policy_egress=PASS", + 'docker_e rm -f -v "$k3s_container_id"', + 'label=dory.release.kubernetes-tooling.run=$RUN_ID', + "k3s_container_exact_image=PASS", + "privileged_nested_control_plane=PASS", + "exact_workload_image=PASS", + "docker_cli_sha256=", + "kubectl_sha256=", + "tilt_binary_sha256=", + "skaffold_binary_sha256=", + "owned_container_cleanup=PASS", + "exact_baseline_cleanup=PASS", + ): + self.assertIn(proof, text, proof) + self.assertIn( + "'^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$'", text + ) + for stale in ( + "dory-k8s-tooling-gate", + "cleanup_objects", + 'ids="$(docker_e ps -aq)"', + "docker_e volume rm", + "docker_e network rm", + "assert ", + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_helpers_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--kubectl", + "/missing/kubectl", + "--workroot", + str(workroot), + "--k3s-image", + "invalid", + "--workload-image", + "invalid", + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-linux-vm-performance-evidence.py b/.github/scripts/test-linux-vm-performance-evidence.py new file mode 100755 index 00000000..edc498db --- /dev/null +++ b/.github/scripts/test-linux-vm-performance-evidence.py @@ -0,0 +1,524 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import copy +import hashlib +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + + +REPO = pathlib.Path(__file__).resolve().parents[2] +TOOL = REPO / "scripts" / "validate-linux-vm-performance-evidence.py" +MATRIX_CELL = "a" * 64 + + +def canonical(value: object) -> bytes: + return ( + json.dumps( + value, + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + + "\n" + ).encode("utf-8") + + +def digest(character: str) -> str: + return character * 64 + + +def frozen_budget( + *, quality: str = "software", budget_id: str = "ux.boot.login-ready.duration" +) -> dict: + return { + "applicability": { + "architecture": "arm64", + "graphicsQuality": quality, + "matrixCellIDs": [MATRIX_CELL], + "qualificationModes": ["release"], + }, + "approval": { + "approvedAt": "2026-08-23T12:34:56Z", + "owner": "Dory performance", + "recordSHA256": digest("b"), + "reviewer": "Dory release", + }, + "baselineEvidenceSHA256": digest("c"), + "direction": "atMost", + "id": budget_id, + "lowerBound": None, + "metricDefinitionRevision": 1, + "rationale": "Synthetic validator fixture; not a product performance threshold.", + "state": "frozen", + "statistic": "p95", + "unit": "milliseconds", + "upperBound": 42.5, + } + + +def frozen_budget_set( + *, quality: str = "software", budget_id: str = "ux.boot.login-ready.duration" +) -> dict: + return { + "architecture": "arm64", + "budgetSetID": "linux-vm-release", + "budgets": [frozen_budget(quality=quality, budget_id=budget_id)], + "kind": "dev.dory.linux-vm-performance-budget-set", + "revision": 1, + "schemaVersion": 1, + "state": "frozen", + } + + +def provisional_budget_set() -> dict: + budget = frozen_budget() + budget.update( + { + "approval": None, + "baselineEvidenceSHA256": None, + "lowerBound": None, + "state": "provisional", + "upperBound": None, + } + ) + budget["applicability"]["qualificationModes"] = ["calibration"] + return { + "architecture": "arm64", + "budgetSetID": "linux-vm-calibration", + "budgets": [budget], + "kind": "dev.dory.linux-vm-performance-budget-set", + "revision": 1, + "schemaVersion": 1, + "state": "provisional", + } + + +def evidence_manifest(*, mode: str = "release", verdict: str = "qualified") -> dict: + return { + "campaign": { + "clockCalibration": "evidence/clocks.json", + "definitionID": "linux-desktop-interactive", + "definitionRevision": 1, + "harnessSHA256": digest("d"), + "matrixCellID": MATRIX_CELL, + "matrixCellDescriptor": "evidence/matrix-cell.json", + "samplingPlan": "evidence/sampling-plan.json", + "workloadSHA256": digest("e"), + }, + "candidate": { + "applicationSHA256": digest("f"), + "budgetSetSHA256": digest("1"), + "codeSignatureEvidence": "evidence/candidate-signatures.json", + "componentCandidateInventorySHA256": digest("3"), + "runtimePlanSHA256": digest("4"), + "sbomSHA256": digest("2"), + "virtualHardwareABIVersion": "arm64-v1", + }, + "fallbacks": "summary/fallbacks.json", + "guest": { + "architecture": "arm64", + "guestToolsSHA256": digest("5"), + "initrdSHA256": None, + "installedSystemIdentity": "evidence/guest-system.json", + "installerSHA256": digest("6"), + "installerSignatureEvidence": "evidence/installer-signature.json", + "kernelSHA256": digest("7"), + "mesaAndRendererClientIdentity": "evidence/guest-graphics.json", + }, + "host": { + "architecture": "arm64", + "displayTopology": "evidence/host-display.json", + "identity": "evidence/host-identity.json", + "noiseControls": "evidence/noise-controls.json", + "powerAndThermalState": "evidence/host-state.json", + "storageTopology": "evidence/host-storage.json", + }, + "kind": "dev.dory.linux-vm-performance-evidence", + "launch": { + "backend": "vz", + "devices": "evidence/devices.json", + "graphics": { + "accelerationEvidence": None, + "accelerationEvidenceSHA256": None, + "fallback": False, + "fallbackReason": None, + "implementation": "software", + "requestedQuality": "software", + "selectedQuality": "software", + "selectionReceiptSHA256": digest("8"), + }, + "graphicsSelectionReceipt": "evidence/graphics-selection.json", + "operationID": "12345678-1234-4abc-8def-1234567890ab", + "planGeneration": 1, + "resources": "evidence/resources.json", + }, + "observations": "raw/observations.jsonl", + "qualificationMode": mode, + "schemaVersion": 1, + "signature": "signatures/evidence-bundle.sig", + "summaries": "summary/metrics.json", + "unavailableEvidence": "summary/unavailable.json", + "verdict": verdict, + } + + +class LinuxVMPerformanceEvidenceTests(unittest.TestCase): + def invoke( + self, + evidence: dict, + budget_set: dict, + *, + success: bool, + rebind: bool = True, + evidence_raw: bytes | None = None, + budget_raw: bytes | None = None, + ) -> subprocess.CompletedProcess[str]: + evidence = copy.deepcopy(evidence) + budget_set = copy.deepcopy(budget_set) + budget_bytes = budget_raw if budget_raw is not None else canonical(budget_set) + if rebind: + evidence["candidate"]["budgetSetSHA256"] = hashlib.sha256( + budget_bytes + ).hexdigest() + evidence_bytes = ( + evidence_raw if evidence_raw is not None else canonical(evidence) + ) + with tempfile.TemporaryDirectory( + prefix="dory-linux-vm-performance." + ) as temporary: + root = pathlib.Path(temporary).resolve() + evidence_path = root / "evidence.json" + budget_path = root / "budget-set.json" + evidence_path.write_bytes(evidence_bytes) + budget_path.write_bytes(budget_bytes) + result = subprocess.run( + [ + "python3", + os.fspath(TOOL), + "--evidence", + os.fspath(evidence_path), + "--budget-set", + os.fspath(budget_path), + ], + cwd=REPO, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if success and result.returncode != 0: + self.fail(f"validator rejected fixture: {result.stderr}") + if not success and result.returncode == 0: + self.fail("validator accepted adversarial fixture") + return result + + def rejected( + self, evidence: dict, budget_set: dict, expected: str, **kwargs: object + ) -> None: + result = self.invoke(evidence, budget_set, success=False, **kwargs) + self.assertIn(expected, result.stderr) + + def test_accepts_canonical_qualified_and_calibration_documents(self) -> None: + release = self.invoke(evidence_manifest(), frozen_budget_set(), success=True) + self.assertIn("structure: PASS", release.stdout) + self.assertRegex(release.stdout, r"budget-set\.sha256=[0-9a-f]{64}") + + calibration = evidence_manifest( + mode="calibration", verdict="not-release-qualifying" + ) + self.invoke(calibration, provisional_budget_set(), success=True) + + def test_rejects_duplicate_keys_and_noncanonical_or_nonfinite_json(self) -> None: + evidence = evidence_manifest() + budgets = frozen_budget_set() + duplicate_evidence = b'{"kind":"duplicate",' + canonical(evidence)[1:] + self.rejected( + evidence, + budgets, + "repeats key 'kind'", + evidence_raw=duplicate_evidence, + ) + + duplicate_budget = b'{"kind":"duplicate",' + canonical(budgets)[1:] + self.rejected( + evidence, + budgets, + "repeats key 'kind'", + budget_raw=duplicate_budget, + ) + + pretty = (json.dumps(evidence, indent=2, sort_keys=True) + "\n").encode() + self.rejected(evidence, budgets, "must be canonical JSON", evidence_raw=pretty) + + nonfinite = canonical(budgets).replace( + b'"upperBound":42.5', b'"upperBound":NaN' + ) + self.rejected(evidence, budgets, "non-finite number", budget_raw=nonfinite) + + def test_schema_kind_mode_verdict_and_shapes_fail_closed(self) -> None: + mutations = [ + ( + lambda value: value.update(schemaVersion=2), + "evidence schema is unsupported", + ), + ( + lambda value: value.update(kind="dev.dory.other"), + "evidence kind is unsupported", + ), + ( + lambda value: value.update(qualificationMode="diagnostic"), + "qualification mode is unsupported", + ), + (lambda value: value.update(verdict="PASS"), "verdict contradicts"), + (lambda value: value.update(unexpected=True), "extra=['unexpected']"), + ( + lambda value: value["candidate"].pop("runtimePlanSHA256"), + "missing=['runtimePlanSHA256']", + ), + ( + lambda value: value["launch"]["graphics"].update(unknown=True), + "extra=['unknown']", + ), + ] + for mutate, expected in mutations: + with self.subTest(expected=expected): + evidence = evidence_manifest() + mutate(evidence) + self.rejected(evidence, frozen_budget_set(), expected) + + calibration = evidence_manifest(mode="calibration", verdict="qualified") + self.rejected(calibration, provisional_budget_set(), "verdict contradicts") + + budget = frozen_budget_set() + budget["schemaVersion"] = True + self.rejected(evidence_manifest(), budget, "budget set schema is unsupported") + budget = frozen_budget_set() + budget["kind"] = "dev.dory.other" + self.rejected(evidence_manifest(), budget, "budget set kind is unsupported") + budget = frozen_budget_set() + budget["unknown"] = True + self.rejected(evidence_manifest(), budget, "extra=['unknown']") + budget = frozen_budget_set() + budget["budgets"][0]["direction"] = "faster" + self.rejected(evidence_manifest(), budget, "direction is unsupported") + budget = frozen_budget_set() + budget["budgets"][0]["approval"]["unknown"] = True + self.rejected(evidence_manifest(), budget, "extra=['unknown']") + + def test_candidate_plan_operation_and_budget_bindings_are_exact(self) -> None: + cases = [ + ( + "candidate", + "componentCandidateInventorySHA256", + digest("A"), + "lowercase SHA-256", + ), + ("candidate", "runtimePlanSHA256", "0" * 64, "all-zero identity"), + ("launch", "operationID", "not-a-uuid", "canonical UUID"), + ("launch", "planGeneration", True, "positive integer"), + ("launch", "planGeneration", 0, "positive integer"), + ] + for owner, key, replacement, expected in cases: + with self.subTest(owner=owner, key=key): + evidence = evidence_manifest() + evidence[owner][key] = replacement + self.rejected(evidence, frozen_budget_set(), expected) + + evidence = evidence_manifest() + evidence["candidate"]["budgetSetSHA256"] = digest("9") + self.rejected(evidence, frozen_budget_set(), "does not bind", rebind=False) + + def test_native_arm64_classification_is_mandatory(self) -> None: + evidence = evidence_manifest() + evidence["guest"]["architecture"] = "x86_64" + self.rejected(evidence, frozen_budget_set(), "native arm64") + + evidence = evidence_manifest() + evidence["host"]["architecture"] = "x86_64" + self.rejected(evidence, frozen_budget_set(), "host architecture must be arm64") + + budgets = frozen_budget_set() + budgets["architecture"] = "x86_64" + self.rejected( + evidence_manifest(), budgets, "budget set architecture must be arm64" + ) + + budgets = frozen_budget_set() + budgets["budgets"][0]["applicability"]["architecture"] = "x86_64" + self.rejected(evidence_manifest(), budgets, "architecture must be arm64") + + def test_bundle_references_cannot_escape_alias_or_change_role(self) -> None: + paths = [ + ("../raw/observations.jsonl", "forbidden path component"), + ("/raw/observations.jsonl", "canonical relative POSIX path"), + ("raw\\observations.jsonl", "POSIX separators"), + ("summary/observations.jsonl", "inside raw/"), + ("raw/observations.json", "must end in .jsonl"), + ] + for replacement, expected in paths: + with self.subTest(path=replacement): + evidence = evidence_manifest() + evidence["observations"] = replacement + self.rejected(evidence, frozen_budget_set(), expected) + + evidence = evidence_manifest() + evidence["summaries"] = evidence["fallbacks"] + self.rejected(evidence, frozen_budget_set(), "references must be unique") + + evidence = evidence_manifest() + evidence["signature"] = "evidence/signature.sig" + self.rejected(evidence, frozen_budget_set(), "inside signatures/") + + def test_provisional_or_unapproved_bounds_cannot_qualify(self) -> None: + self.rejected( + evidence_manifest(), + provisional_budget_set(), + "requires a frozen budget set", + ) + + budgets = frozen_budget_set() + budgets["budgets"][0].update( + approval=None, + baselineEvidenceSHA256=None, + lowerBound=None, + state="provisional", + upperBound=None, + ) + self.rejected(evidence_manifest(), budgets, "contains a provisional budget") + + for key, replacement, expected in ( + ("baselineEvidenceSHA256", None, "lowercase SHA-256"), + ("approval", None, "must be an object"), + ("upperBound", None, "must be a JSON number"), + ): + with self.subTest(key=key): + budgets = frozen_budget_set() + budgets["budgets"][0][key] = replacement + self.rejected(evidence_manifest(), budgets, expected) + + budgets = frozen_budget_set() + record = budgets["budgets"][0] + record.update(direction="range", lowerBound=10.0, upperBound=5.0) + self.rejected(evidence_manifest(), budgets, "range bounds are reversed") + + def test_budget_identifiers_and_applicability_are_unambiguous(self) -> None: + budgets = frozen_budget_set() + duplicate = copy.deepcopy(budgets["budgets"][0]) + budgets["budgets"].append(duplicate) + self.rejected(evidence_manifest(), budgets, "sorted and unique") + + budgets = frozen_budget_set() + budgets["budgets"][0]["applicability"]["matrixCellIDs"] = [ + digest("b"), + MATRIX_CELL, + ] + self.rejected( + evidence_manifest(), budgets, "matrixCellIDs must be sorted and unique" + ) + + budgets = frozen_budget_set() + budgets["budgets"][0]["applicability"]["matrixCellIDs"] = [digest("9")] + self.rejected(evidence_manifest(), budgets, "no applicable frozen budget") + + budgets = frozen_budget_set( + quality="software", budget_id="gpu.accelerated.frame-time" + ) + self.rejected( + evidence_manifest(), budgets, "accelerated GPU metric is misclassified" + ) + + def test_graphics_selection_fallback_and_acceleration_are_not_interchangeable( + self, + ) -> None: + evidence = evidence_manifest() + evidence["launch"]["graphics"]["implementation"] = "virgl-venus" + self.rejected(evidence, frozen_budget_set(), "software implementation") + + evidence = evidence_manifest() + evidence["launch"]["graphics"]["accelerationEvidence"] = ( + "evidence/graphics-acceleration.json" + ) + self.rejected( + evidence, + frozen_budget_set(), + "software graphics cannot reference acceleration evidence", + ) + + evidence = evidence_manifest() + evidence["launch"]["graphics"]["accelerationEvidenceSHA256"] = digest("9") + self.rejected( + evidence, + frozen_budget_set(), + "software graphics cannot carry acceleration evidence", + ) + + evidence = evidence_manifest() + graphics = evidence["launch"]["graphics"] + graphics.update(requestedQuality="accelerated", selectedQuality="software") + self.rejected( + evidence, frozen_budget_set(), "fallback classification contradicts" + ) + + evidence = evidence_manifest() + graphics = evidence["launch"]["graphics"] + graphics.update( + fallback=True, + fallbackReason="Renderer receipt unavailable", + requestedQuality="accelerated", + selectedQuality="software", + ) + self.rejected( + evidence, + frozen_budget_set(), + "qualified evidence cannot contain a graphics fallback", + ) + + budgets = frozen_budget_set( + quality="accelerated", budget_id="gpu.accelerated.frame-time" + ) + self.rejected(evidence_manifest(), budgets, "graphics quality contradicts") + + def test_structurally_bound_acceleration_requires_rawhv_and_accelerated_budget( + self, + ) -> None: + evidence = evidence_manifest() + evidence["launch"]["backend"] = "rawhv" + evidence["launch"]["graphics"].update( + accelerationEvidence="evidence/graphics-acceleration.json", + accelerationEvidenceSHA256=digest("9"), + implementation="virgl-venus", + requestedQuality="accelerated", + selectedQuality="accelerated", + ) + budgets = frozen_budget_set( + quality="accelerated", budget_id="gpu.accelerated.frame-time" + ) + self.invoke(evidence, budgets, success=True) + + evidence["launch"]["backend"] = "vz" + self.rejected(evidence, budgets, "not available on this backend") + + evidence["launch"]["backend"] = "rawhv" + evidence["launch"]["graphics"]["accelerationEvidence"] = None + self.rejected(evidence, budgets, "must be a string") + + evidence["launch"]["graphics"]["accelerationEvidence"] = ( + "evidence/graphics-acceleration.json" + ) + evidence["launch"]["graphics"]["accelerationEvidenceSHA256"] = None + self.rejected(evidence, budgets, "lowercase SHA-256") + + evidence["launch"]["graphics"]["accelerationEvidenceSHA256"] = digest("9") + software_budget = frozen_budget_set(quality="any") + self.rejected(evidence, software_budget, "no applicable accelerated budget") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-live-migration-gate.py b/.github/scripts/test-live-migration-gate.py new file mode 100644 index 00000000..bb771826 --- /dev/null +++ b/.github/scripts/test-live-migration-gate.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the isolated live migration gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "live-orbstack-migration-smoke.sh" + + +class LiveMigrationGateTests(unittest.TestCase): + def test_contract_is_exact_and_owned(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-DORY-LIVE-MIGRATION", + "live migration base image must be an exact digest reference", + "DORY_LIVE_DOCKER_BIN is required", + "live migration Docker CLI is unavailable or indirect", + "source and target sockets must differ", + "socket is not owned by the release user", + "DORY_LIVE_ORBSTACK_MIGRATION_MARKER", + "live migration marker must remain below its private root", + "source_baseline_restored=PASS", + "target_baseline_restored=PASS", + "volume_64mib_checksum=PASS", + "docker_cli_sha256=", + "helper_archive_sha256=", + ): + self.assertIn(proof, text, proof) + for stale in ("alpine:3.20", "DOCKER_HOST=\"unix://$socket\" docker", "assert "): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_or_docker_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result = subprocess.run( + [str(GATE)], + cwd=ROOT, + env={ + **os.environ, + "HOME": temporary, + "DORY_LIVE_MIGRATION_BASE_IMAGE": "example.invalid/alpine@sha256:" + "a" * 64, + "DORY_LIVE_DOCKER_BIN": "/missing/docker", + }, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("DORY_LIVE_MIGRATION_CONFIRMED", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-localstack-compatibility-gate.py b/.github/scripts/test-localstack-compatibility-gate.py new file mode 100755 index 00000000..4ccb84cb --- /dev/null +++ b/.github/scripts/test-localstack-compatibility-gate.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the exact LocalStack S3/SQS compatibility gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "localstack-compatibility-gate.sh" + + +class LocalStackCompatibilityGateTests(unittest.TestCase): + def test_contract_is_exact_offline_and_scoped(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-LOCALSTACK", + "socket is not owned by the release user", + "Docker CLI is unavailable or indirect", + "LocalStack image must be an exact digest reference", + "required offline LocalStack image is missing", + "--pull=never", + "dory.release.localstack.run", + "LocalStack container did not use the exact requested image", + "LocalStack unexpectedly received a Docker socket", + 'binding[0].get("HostIp") != "127.0.0.1"', + "requested loopback port was widened to all host interfaces", + "awslocal s3api put-object", + "awslocal s3api get-object", + "awslocal sqs send-message", + "awslocal sqs receive-message", + "s3_object_roundtrip=PASS", + "sqs_message_roundtrip=PASS", + "owned_container_cleanup=PASS", + "exact_baseline_cleanup=PASS", + "docker_cli_sha256=", + ): + self.assertIn(proof, text, proof) + for stale in ("docker_e pull", "docker_e volume rm", "docker_e network rm", "assert "): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_image_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + str(GATE), + "--socket", str(pathlib.Path(temporary) / "missing.sock"), + "--docker", "/missing/docker", + "--image", "example.invalid/localstack@sha256:" + "a" * 64, + "--workroot", str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-long-lived-network-soak.py b/.github/scripts/test-long-lived-network-soak.py new file mode 100755 index 00000000..042b317b --- /dev/null +++ b/.github/scripts/test-long-lived-network-soak.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Offline contract for the >24-hour same-connection network release soak.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "long-lived-network-soak.sh" + + +class LongLivedNetworkSoakTests(unittest.TestCase): + def test_gate_binds_same_connection_latency_external_tcp_and_candidate(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-LONG-LIVED-TCP", + "Dory socket must be an absolute path", + "Dory socket is unavailable or indirect", + "Dory socket is not owned by the release user", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "workroot already exists or is indirect", + "release candidate evidence requires --source-commit", + "release candidate duration must exceed 24 hours", + "release candidate image must be digest-pinned", + "release candidate fixture must resolve to linux/arm64", + "DOCKER_CONTEXT", + "required offline image is missing", + "`connection` is never replaced", + "measured TCP connection closed", + "connection tuple changed during the measured soak", + "duration_beyond_24_hours=", + "host.docker.internal", + "managed-machine p99 protocol RTT exceeded 100ms", + "sustained >=150ms plateau", + "managed-machine outbound TCP failed too often", + "managed-machine outbound TCP had consecutive failures", + "long-lived soak changed the exact fixture image identity", + "service fixture survived owned cleanup", + "managed-machine fixture survived owned cleanup", + "same_tcp_connection=PASS", + "machine_to_docker_service=PASS", + "machine_service_regular_200_400ms_plateau=ABSENT", + "machine_outbound_tcp=PASS", + "exact_image_identity=PASS", + "owned_cleanup=PASS", + "heartbeats_sha256=", + "machine_service_sha256=", + "machine_outbound_sha256=", + "summary_sha256=", + "docker_cli_sha256=", + "same_user_socket=PASS", + "release_qualifying=", + "status=PASS", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'mkdir -p "$WORKROOT"', + 'DOCKER_HOST="unix://$SOCKET" "$DOCKER"', + 'echo "socket=$SOCKET"', + 'echo "docker=$DOCKER"', + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_socket_cli_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--image", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-machine-resource-reconfiguration-gate.py b/.github/scripts/test-machine-resource-reconfiguration-gate.py new file mode 100644 index 00000000..85314629 --- /dev/null +++ b/.github/scripts/test-machine-resource-reconfiguration-gate.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Offline safety and CLI contract for the physical VM resource gate.""" + +from __future__ import annotations + +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "machine-resource-reconfiguration-gate.sh" + + +class MachineResourceReconfigurationGateTests(unittest.TestCase): + def test_current_machine_contract_is_complete(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + 'machine create "$MACHINE" --kernel "$KERNEL" --rootfs "$ROOTFS"', + 'machine update "$MACHINE" --cpus 8 --memory-mb 16384', + 'machine update "$MACHINE" --cpus 2 --memory-mb 4096', + 'machine exec "$MACHINE" --json -- sh -ec', + 'machine provision "$MACHINE" --recipe k8s-lab', + 'machine stats "$MACHINE"', + 'machine delete "$MACHINE"', + 'out-of-contract $invalid update unexpectedly succeeded', + 'test -f /root/dory-resource-marker', + '[ ! -L "$KERNEL" ]', + '[ ! -L "$ROOTFS" ]', + ): + self.assertIn(proof, text, proof) + for stale in ("--env", "assert ", "rm -rf"): + self.assertNotIn(stale, text, stale) + + def test_symlinked_candidate_input_fails_before_mutation(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + ctl = root / "dorydctl" + ctl.write_text("#!/bin/sh\nexit 99\n", encoding="utf-8") + ctl.chmod(0o700) + kernel = root / "kernel" + kernel.write_bytes(b"kernel") + kernel_link = root / "kernel-link" + kernel_link.symlink_to(kernel) + rootfs = root / "rootfs" + rootfs.write_bytes(b"rootfs") + work = root / "evidence" + + result = subprocess.run( + [ + str(GATE), + "--ctl", + str(ctl), + "--kernel", + str(kernel_link), + "--rootfs", + str(rootfs), + "--workroot", + str(work), + "--confirm", + "ISOLATED-DORY-MACHINE-RESOURCES", + ], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("kernel is not an exact regular file", result.stderr) + self.assertFalse(work.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-managed-data-drive-gate.py b/.github/scripts/test-managed-data-drive-gate.py new file mode 100755 index 00000000..7189cdbe --- /dev/null +++ b/.github/scripts/test-managed-data-drive-gate.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Offline contract for managed durable-data-drive release qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "managed-data-drive-gate.sh" + + +class ManagedDataDriveGateTests(unittest.TestCase): + def test_gate_binds_candidate_and_exact_durable_object_continuity(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-MANAGED-DATA-DRIVE", + "physical Apple silicon is required", + "runtime must be an absolute path", + "runtime is unavailable or indirect", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "runtime member is missing or indirect", + "workroot already exists or is indirect", + "isolated HOME exists or is indirect", + "temporary alias path already exists or is indirect", + "release candidate evidence requires --source-commit", + "release candidate image must be digest-pinned", + "release candidate image is not preloaded in the isolated engine", + "engine socket is missing or indirect after first start", + "engine socket is not owned by the release user", + "first-launch retry left its partial bundle", + "interrupted first launch adopted a different drive identity", + "missing metadata silently adopted a different drive", + "running engine accepted a different drive", + "missing external volume was accepted", + "a second engine attached the live drive", + "stopped runtime silently created a replacement selected drive", + "transient runtime replacement changed the image identity", + "transient runtime replacement changed the container identity", + "transient runtime replacement changed the network identity", + "drive manifest has an unexpected shape", + "selection authority differs from the drive manifest", + "exact_image_identity=PASS", + "exact_container_identity=PASS", + "exact_network_identity=PASS", + "same_user_engine_socket=PASS", + "release_qualifying=", + "source_commit=", + "docker_cli_sha256=", + "dory_engine_sha256=", + "dory_hv_sha256=", + "gvproxy_sha256=", + "dataplane_proxy_sha256=", + "kernel_asset_sha256=", + "rootfs_asset_sha256=", + "agent_asset_sha256=", + "drive_manifest_sha256=", + "selection_authority_sha256=", + "status=PASS", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'mkdir -p "$WORKROOT"', + 'rm -rf "$WORKROOT"', + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_architecture_runtime_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--runtime", + "/missing/runtime", + "--docker", + "/missing/docker", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-native-ipv6-gate.py b/.github/scripts/test-native-ipv6-gate.py new file mode 100755 index 00000000..59b19671 --- /dev/null +++ b/.github/scripts/test-native-ipv6-gate.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Offline contract for exact native-IPv6 release qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "native-ipv6-gate.sh" + + +class NativeIPv6GateTests(unittest.TestCase): + def test_gate_binds_candidate_network_contract_and_external_route(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-NATIVE-IPV6", + "Apple silicon is required", + "candidate inputs must use absolute paths", + "missing or indirect input", + "workroot already exists or is indirect", + "external IPv6 endpoint is invalid", + "release candidate evidence requires --source-commit", + "release candidate evidence requires --require-external", + "release candidate image must be digest-pinned", + "release candidate evidence requires signed gvproxy provenance and payload inventory", + "signed gvproxy authority is missing or indirect", + "dory_verify_signed_gvproxy_payload", + "DOCKER_CONTEXT", + 'DOCKER_HOST="unix://$SOCKET"', + "--direct-ipv6", + "default bridge does not enable IPv6", + "fd7d:6f72:7901::/64", + "Cloudflare AAAA resolution is missing", + "registry AAAA resolution is missing", + "fd7d:6f72:7900::1", + "--noproxy '*'", + "dual-stack localhost publishing failed", + "engine socket is not owned by the release user", + "engine restart changed the exact fixture image identity", + "release candidate image is not preloaded", + "Mac has an IPv6 route but container TCP failed", + "external_ipv6_tcp=", + "exact_image_identity=PASS", + "same_user_engine_socket=PASS", + "dory_hv_sha256=", + "gvproxy_input_sha256=", + "kernel_input_sha256=", + "rootfs_input_sha256=", + "docker_cli_sha256=", + "network_contract_sha256=", + "guest_network_log_sha256=", + "release_qualifying=", + "status=PASS", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'mkdir -p "$WORKROOT"', + 'DOCKER_HOST="unix://$SOCKET" "$DOCKER"', + 'run --rm alpine:3.20', + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_architecture_inputs_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--dory-hv", + "/missing/dory-hv", + "--gvproxy", + "/missing/gvproxy", + "--kernel", + "/missing/kernel", + "--rootfs", + "/missing/rootfs", + "--docker", + "/missing/docker", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-nonnative-arch-pacman-gate.py b/.github/scripts/test-nonnative-arch-pacman-gate.py new file mode 100755 index 00000000..2ab8a328 --- /dev/null +++ b/.github/scripts/test-nonnative-arch-pacman-gate.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Offline contract for linux/amd64 Arch pacman sandbox qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "nonnative-arch-pacman-gate.sh" + + +class NonnativeArchPacmanGateTests(unittest.TestCase): + def test_gate_binds_fresh_arch_image_fex_and_default_pacman_sandbox(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-DORY-NONNATIVE-ARCH-PACMAN", + "Dory socket must be an absolute path", + "Dory socket is unavailable or indirect", + "Dory socket is not owned by the release user", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "--base-image must be digest-pinned", + "workroot already exists or is indirect", + "release candidate evidence requires --source-commit", + "DOCKER_CONTEXT", + "Arch base image already exists; the gate requires a fresh isolated pull", + "pull --platform linux/amd64", + "Arch fixture is not linux/amd64", + "RUN pacman -Sy --noconfirm fzf", + "--disable-sandbox", + "competitor's seccomp/sandbox failure", + "flags: POCF", + "FEX_ROOTFS", + "FEX_NEEDSSECCOMP", + "fex_bundle_read_only=PASS", + "fex_config_read_only=PASS", + "fex_private_runtime=PASS", + "fex_shared_server_socket=PASS", + "Arch pacman gate image survived cleanup", + "base_image_id=", + "source_commit=", + "pacman_default_sandbox=PASS", + "alpm_user_switch=PASS", + "fzf_inventory=PASS", + "fzf_runtime=PASS", + "docker_api_after_build=PASS", + "owned_cleanup=PASS", + "docker_cli_sha256=", + "build_output_sha256=", + "run_output_sha256=", + "release_qualifying=", + "status=PASS", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'mkdir -p "$WORKROOT"', + 'DOCKER_HOST="unix://$SOCKET" "$DOCKER"', + "pacman -Sy --noconfirm --disable-sandbox", + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_socket_cli_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--base-image", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-nonnative-build-smoke.py b/.github/scripts/test-nonnative-build-smoke.py new file mode 100644 index 00000000..85a12ba0 --- /dev/null +++ b/.github/scripts/test-nonnative-build-smoke.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the non-native BuildKit release smoke.""" + +from __future__ import annotations + +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "nonnative-build-smoke.sh" + + +class NonNativeBuildSmokeTests(unittest.TestCase): + def test_build_is_digest_pinned_and_network_free(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "images must be digest-pinned", + 'docker_e image inspect "$ALPINE_IMAGE"', + 'docker_e image inspect "$BUILD_IMAGE"', + "FEX-x86_64", + "flags: POCF", + "--platform \"linux/$TARGET_ARCH\"", + "RUN --network=none npm ci --ignore-scripts --no-audit --no-fund", + "RUN --network=none npm run build", + "--pull=false", + "nested-gnu-tar-ok", + "hardlink.txt", + "dory-nonnative-build-ok", + "explicit workdir must not already exist", + ): + self.assertIn(proof, text, proof) + for mutable in ("alpine:latest", "node:20-alpine\n", "apk add"): + self.assertNotIn(mutable, text, mutable) + + def test_fixture_is_self_contained(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + fixture = pathlib.Path(temporary) / "fixture" + subprocess.run( + [ + "bash", + "-c", + 'gate="$1"; fixture="$2"; set --; ' + 'DORY_NONNATIVE_SMOKE_SOURCE_ONLY=1 source "$gate"; ' + 'write_node_build_fixture "$fixture"', + "bash", + str(GATE), + str(fixture), + ], + cwd=ROOT, + check=True, + ) + expected = { + "package.json", + "package-lock.json", + "src/app.mjs", + "scripts/build.mjs", + "test/app.test.mjs", + "vendor/dory-math/package.json", + "vendor/dory-math/index.mjs", + } + actual = { + str(path.relative_to(fixture)) + for path in fixture.rglob("*") + if path.is_file() + } + self.assertEqual(actual, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-nonnative-exec-conformance-gate.py b/.github/scripts/test-nonnative-exec-conformance-gate.py new file mode 100755 index 00000000..17f7cb70 --- /dev/null +++ b/.github/scripts/test-nonnative-exec-conformance-gate.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Offline contract for the Apple-Silicon FEX exec-conformance release gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "nonnative-exec-conformance-gate.sh" + + +class NonnativeExecConformanceGateTests(unittest.TestCase): + def test_gate_binds_exact_socket_cli_images_and_exec_matrix(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-DORY-NONNATIVE-EXEC", + "Dory socket must be an absolute path", + "Dory socket is unavailable or indirect", + "Dory socket is not owned by the release user", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "fixture images must be digest-pinned", + "workroot already exists or is indirect", + "release candidate evidence requires --source-commit", + "DOCKER_CONTEXT", + 'DOCKER_HOST="unix://$SOCKET"', + "fixture already exists; the gate requires fresh isolated pulls", + "image platform does not match linux/", + "private FEX handoff marker leaked", + "PR_SET_NO_NEW_PRIVS failed", + "PR_SET_SECCOMP failed", + "mkdir bypassed inherited guest seccomp", + "fd-exec-arguments-buildkit=PASS", + "fd-exec-null-argv-buildkit=PASS", + "seccomp-shebang-chain-buildkit=PASS", + "flags: POCF", + "docker exec descriptor chain failed", + "Docker API wedged after exec conformance", + "builder prune --all --force", + "base_image_id=", + "native_image_id=", + "source_commit=", + "fex_sha256=", + "fex_server_sha256=", + "guest_seccomp_inheritance=PASS", + "fd_exec_arguments=PASS", + "fd_exec_null_argv=PASS", + "buildkit_exec_matrix=PASS", + "runtime_exec_matrix=PASS", + "docker_exec_matrix=PASS", + "isolated_builder_cache_prune=PASS", + "docker_cli_sha256=", + "dockerfile_sha256=", + "build_log_sha256=", + "release_qualifying=", + "status=PASS", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'mkdir -p "$WORKROOT"', + 'DOCKER_HOST="unix://$SOCKET" "$DOCKER"', + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_socket_cli_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--base-image", + "invalid", + "--native-image", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-nonnative-mmdebstrap-gate.py b/.github/scripts/test-nonnative-mmdebstrap-gate.py new file mode 100755 index 00000000..e39a4b41 --- /dev/null +++ b/.github/scripts/test-nonnative-mmdebstrap-gate.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Offline contract for linux/amd64 mmdebstrap release qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "nonnative-mmdebstrap-gate.sh" + + +class NonnativeMmdebstrapGateTests(unittest.TestCase): + def test_gate_binds_fresh_debian_image_and_proc_less_nested_rootfs(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-DORY-NONNATIVE-MMDEBSTRAP", + "Dory socket must be an absolute path", + "Dory socket is unavailable or indirect", + "Dory socket is not owned by the release user", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "--base-image must be digest-pinned", + "workroot already exists or is indirect", + "release candidate evidence requires --source-commit", + "DOCKER_CONTEXT", + "Debian base image already exists; the gate requires a fresh isolated pull", + "pull --platform linux/amd64", + "Debian fixture is not linux/amd64", + "RUN mmdebstrap --variant=minbase trixie /tmp/rootfs.tar", + "bad fd number|cat >&10 returned|hooklistener errored", + "flags: POCF", + "nested-chroot-shebang-ok", + "test ! -e /tmp/dory-nested-root/proc/self", + "private_marker_isolation=PASS", + "generated linux/amd64 Debian rootfs archive verification failed", + "Docker API wedged after the mmdebstrap build", + "builder prune --all --force", + "mmdebstrap gate image survived cleanup", + "base_image_id=", + "built_image_id=", + "source_commit=", + "reported_dockerfile_commands=PASS", + "mmdebstrap_minbase_trixie=PASS", + "bad_fd_number_absent=PASS", + "rootfs_archive_readable=PASS", + "nested_chroot_no_proc=PASS", + "nested_chroot_shebang=PASS", + "build_cache_cleanup=PASS", + "isolated_builder_cache_prune=PASS", + "owned_cleanup=PASS", + "docker_cli_sha256=", + "dockerfile_sha256=", + "base_inspect_sha256=", + "build_log_sha256=", + "run_output_sha256=", + "release_qualifying=", + "status=PASS", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'mkdir -p "$WORKROOT"', + 'DOCKER_HOST="unix://$SOCKET" "$DOCKER"', + "--security-opt seccomp=unconfined", + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_socket_cli_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--base-image", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-nonnative-nix-gc-gate.py b/.github/scripts/test-nonnative-nix-gc-gate.py new file mode 100755 index 00000000..def4a78e --- /dev/null +++ b/.github/scripts/test-nonnative-nix-gc-gate.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Offline contract for linux/amd64 Nix garbage-collection qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "nonnative-nix-gc-gate.sh" + + +class NonnativeNixGCGateTests(unittest.TestCase): + def test_gate_binds_fresh_amd64_image_and_exact_gc_outcome(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-DORY-NONNATIVE-NIX-GC", + "Dory socket must be an absolute path", + "Dory socket is unavailable or indirect", + "Dory socket is not owned by the release user", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "--image must be digest-pinned", + "workroot already exists or is indirect", + "release candidate evidence requires --source-commit", + "DOCKER_CONTEXT", + "Nix fixture already exists; the gate requires a fresh isolated pull", + "pull --platform linux/amd64", + "Nix fixture is not linux/amd64", + 'version="$(nix --version)"', + 'nix-collect-garbage --delete-old', + "gc_deleted_unreachable_path=PASS", + "Docker API wedged after non-native Nix GC", + "Nix fixture survived local cleanup", + "image_id=", + "source_commit=", + "nix_version=2.34.7", + "fresh_pull=PASS", + "unreachable_store_path_created=PASS", + "nix_collect_garbage_delete_old=PASS", + "unreachable_store_path_deleted=PASS", + "owned_cleanup=PASS", + "docker_cli_sha256=", + "run_output_sha256=", + "release_qualifying=", + "status=PASS", + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + 'mkdir -p "$WORKROOT"', + 'DOCKER_HOST="unix://$SOCKET" "$DOCKER"', + ): + self.assertNotIn(unsafe, text, unsafe) + + def test_confirmation_fails_before_socket_cli_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--image", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-offline-bundled-boot-gate.py b/.github/scripts/test-offline-bundled-boot-gate.py new file mode 100755 index 00000000..ef144a7e --- /dev/null +++ b/.github/scripts/test-offline-bundled-boot-gate.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Offline contract for exact bundled-image boot without an observed host TCP dependency.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "offline-bundled-boot-gate.sh" + + +class OfflineBundledBootGateTests(unittest.TestCase): + def test_gate_binds_exact_candidate_bytes_and_continuously_samples_tcp(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "DISPOSABLE-RUNTIME-OFFLINE-CACHE", + "--runtime must be absolute", + "runtime directory is unavailable or indirect", + "required runtime helper is missing or indirect", + "required runtime asset is missing or indirect", + "workroot already exists or is indirect", + "offline HOME base overlaps a protected runtime/evidence root", + "offline Docker backend socket path is", + 'cp -cR "$RUNTIME/." "$RUNTIME_COPY/"', + 'cmp "$RUNTIME/$relative" "$RUNTIME_COPY/$relative"', + "dory_engine_sha256=", + "dory_hv_sha256=", + "gvproxy_sha256=", + "dataplane_proxy_sha256=", + "kernel_asset_sha256=", + "rootfs_asset_sha256=", + "agent_asset_sha256=", + "HTTP_PROXY=http://127.0.0.1:9", + "HTTPS_PROXY=http://127.0.0.1:9", + "ALL_PROXY=socks5://127.0.0.1:9", + "NO_PROXY=", + "http_proxy=http://127.0.0.1:9", + "https_proxy=http://127.0.0.1:9", + "all_proxy=socks5://127.0.0.1:9", + "no_proxy=", + 'TCP_MONITOR_OUTPUT="$WORKDIR/fresh-host-tcp-continuous.txt"', + 'TCP_MONITOR_OUTPUT="$WORKDIR/cached-host-tcp-continuous.txt"', + "sleep 0.05", + "offline boot retained a host TCP dependency", + "published a Docker socket owned by another user", + 'mv "$kernel_asset" "$HIDDEN_ASSETS/"', + 'mv "$rootfs_asset" "$HIDDEN_ASSETS/"', + "fresh boot did not prepare a direct bundled kernel", + "fresh boot did not prepare a direct bundled rootfs", + "cached offline boot changed the prepared kernel", + "cached offline boot changed the prepared rootfs", + "cached offline boot tried to prepare a missing kernel source", + "cached offline boot tried to prepare a missing rootfs source", + "stop returned but the isolated Docker socket remained published", + "fresh_bundled_boot=PASS", + "cached_boot_without_bundle_sources=PASS", + "dead_proxy_environment=PASS", + "host_tcp_dependency_absence=PASS", + "continuous_host_tcp_sampling=PASS", + "same_user_docker_socket=PASS", + "observable_stop_teardown=PASS", + "prepared_assets_unchanged=PASS", + ): + self.assertIn(proof, text, proof) + for stale in ( + "assert ", + 'echo "runtime=$RUNTIME"', + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_runtime_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--runtime", + str(pathlib.Path(temporary) / "missing-runtime"), + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-private-registry-auth-gate.py b/.github/scripts/test-private-registry-auth-gate.py new file mode 100755 index 00000000..88d0f806 --- /dev/null +++ b/.github/scripts/test-private-registry-auth-gate.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Offline contract for isolated authenticated-registry and image-lifecycle qualification.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "private-registry-auth-gate.sh" + + +class PrivateRegistryAuthGateTests(unittest.TestCase): + def test_gate_binds_candidate_registry_auth_context_and_secret_cleanup(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-PRIVATE-REGISTRY", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "socket is not owned by the release user", + "workroot already exists or is indirect", + "Docker Buildx plugin must be an absolute path", + "Docker Buildx plugin is unavailable or indirect", + "isolated Buildx copy differs from the candidate plugin", + "qualified image does not retain its exact registry authority", + "registry volume is missing its exact run authority", + "--pull=never --name", + '--mount "type=volume,src=$VOLUME,dst=/var/lib/registry"', + '--mount "type=bind,src=$AUTH,dst=/auth"', + "authenticated registry runtime binding differs from the qualified image/network", + "authenticated registry mount graph differs from its exact authorities", + "authenticated registry environment omits its loopback/auth contract", + "unauthenticated pull unexpectedly succeeded", + "unauthenticated pull failed without an authentication rejection", + 'CONTEXT="$WORKDIR/context"', + '"$CONTEXT/Dockerfile"', + "BuildKit context is not the exact Dockerfile-only authority", + "--progress plain --pull --network none", + "BuildKit secret leaked into image history", + "private registry credential or BuildKit secret leaked into the image archive", + "save/load changed the image identity", + "filtered image prune retained the run-owned derived image", + 'docker_e ps -aq --filter "label=dev.dory.private-registry=$RUN_ID"', + "private registry evidence retained secret bytes", + "dockerfile_only_build_context=PASS", + "archive_secret_nonleak=PASS", + "secret_free_evidence=PASS", + "owned_cleanup=PASS", + "isolated_credential_cleanup=PASS", + "docker_cli_sha256=", + "buildx_cli_sha256=", + ): + self.assertIn(proof, text, proof) + self.assertIn( + "'^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$'", text + ) + for stale in ( + 'ln -s "$BUILDX"', + '"$HOME/.docker/cli-plugins/docker-buildx"', + '-v "$VOLUME:/var/lib/registry"', + '-v "$AUTH:/auth"', + '-t "$BUILT_REF" -- "$WORKDIR"', + "assert ", + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_helpers_images_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--buildx", + "/missing/buildx", + "--base-image", + "invalid", + "--source-commit", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-prune-safety-gate.py b/.github/scripts/test-prune-safety-gate.py new file mode 100755 index 00000000..9eefd445 --- /dev/null +++ b/.github/scripts/test-prune-safety-gate.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Offline contract for destructive prune qualification on an exact isolated engine.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "prune-safety-gate.sh" + + +class PruneSafetyGateTests(unittest.TestCase): + def test_gate_proves_exact_preconditions_survivors_victims_and_cleanup(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-PRUNE", + "Docker CLI must be an absolute path", + "Docker CLI is unavailable or indirect", + "socket is not owned by the release user", + "workroot already exists or is indirect", + "dedicated engine must contain exactly the qualified base image", + "initial system-df does not contain exactly the qualified base image", + "initial system-df contains pre-existing", + "--network none --pull=false", + "prune fixture build returned an invalid image ID", + "prune fixture containers do not bind the exact built images", + "fixture engine contains containers outside the exact prune scenario", + "fixture engine contains volumes outside the exact prune scenario", + "fixture engine contains custom networks outside the exact prune scenario", + "fixture engine contains images outside the exact prune scenario", + "docker_e system prune -af --volumes", + "docker_e container prune -f", + "docker_e image prune -af", + "docker_e network prune -f", + "docker_e volume prune -af", + "docker_e builder prune -af", + "protected volume data changed during prune", + "stopped victim container survived prune", + "unused victim image survived prune", + "unused victim volume survived prune", + "unused victim network survived prune", + "post-prune images differ from the protected fixture image", + "exact_base_image_precondition=PASS", + "exact_owned_fixture=PASS", + "active_volume_bytes_preserved=PASS", + "build_cache_removed=PASS", + "owned_cleanup=PASS", + "docker_cli_sha256=", + ): + self.assertIn(proof, text, proof) + self.assertIn( + "'^[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}$'", text + ) + for stale in ( + "assert ", + "Docker CLI is unavailable\"", + "^.+@sha256", + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_helper_source_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--base-image", + "invalid", + "--source-commit", + "invalid", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("destructive prune requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-readiness-gate.py b/.github/scripts/test-readiness-gate.py new file mode 100644 index 00000000..c9514d84 --- /dev/null +++ b/.github/scripts/test-readiness-gate.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the cross-engine readiness gate.""" + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "readiness.sh" +DIGEST_IMAGE = "example.invalid/fixture@sha256:" + "a" * 64 + + +class ReadinessGateTests(unittest.TestCase): + def test_release_contract_is_fail_closed_and_reproducible(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "strict readiness requires READINESS_DOCKER_BIN for the exact candidate CLI", + "strict readiness Docker CLI is unavailable or indirect", + "readiness fixture images must be exact digest references", + "READINESS_NONNATIVE_BUILD_IMAGE must be an exact digest reference", + "READINESS_WORKDIR must not be a symlink", + "physical Intel qualification must target exactly the Dory engine", + "independently confirmed native Intel host facts", + "READINESS_STOP_ORBSTACK_CONFIRMED=STOP-ORBSTACK-FOR-READINESS", + "Rosetta translates applications only inside an eligible ARM64 Linux VM", + "Dory exposes no partial x86 VM mode", + "packaged QEMU TCG backend", + 'stat -f %u "$ENGINE_SOCK"', + 'docker_e image inspect "$ALPINE_IMAGE"', + "container lifecycle + logs + exec + stats", + "BuildKit npm ci + build + test", + "memory/cpu resource limits + update", + "same-host competitor correctness gate", + "json.dumps(payload, indent=2, sort_keys=True)", + "Content-Length: 14", + ): + self.assertIn(proof, text, proof) + for stale in ( + "alpine:latest", + "nginx:alpine", + "node:20-alpine", + "FROM ubuntu:24.04", + "docker_e pull", + "pending dory-vmm Rosetta", + "Rosetta x86-64 machine execution", + ): + self.assertNotIn(stale, text, stale) + + def test_source_mode_defines_helpers_without_creating_evidence(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + evidence = pathlib.Path(temporary) / "must-not-exist" + summary = pathlib.Path(temporary) / "summary.json" + command = f''' +set -euo pipefail +READINESS_SOURCE_ONLY=1 READINESS_WORKDIR={str(evidence)!r} source {str(GATE)!r} +test "$(binfmt_handler_for_arch amd64)" = FEX-x86_64 +test "$(binfmt_handler_for_arch arm64)" = qemu-aarch64 +test ! -e {str(evidence)!r} +SUMMARY_JSON={str(summary)!r} +RESULTS={str(pathlib.Path(temporary) / 'results.tsv')!r} +MEMORY_RESULTS={str(pathlib.Path(temporary) / 'memory.tsv')!r} +RUN_ID='quoted"run' +ENGINES='dory,"other' +write_summary +''' + subprocess.run(["bash", "-c", command], cwd=ROOT, check=True) + payload = json.loads(summary.read_text(encoding="utf-8")) + self.assertEqual(payload["runId"], 'quoted"run') + self.assertEqual(payload["engines"], 'dory,"other') + + def test_x86_guest_boundary_has_no_partial_vm_claim(self) -> None: + public_contracts = ( + ROOT / "README.md", + ROOT / "COMPATIBILITY.md", + ROOT / "website/public/llms-full.txt", + ROOT / "docs/linux-vm-performance-contract.md", + ) + for contract in public_contracts: + text = contract.read_text(encoding="utf-8") + self.assertIn("no partial x86 VM", text, contract) + self.assertIn("packaged QEMU TCG backend", text, contract) + + llms_contract = (ROOT / "website/public/llms-full.txt").read_text(encoding="utf-8") + self.assertIn("`dory vm` is also unavailable and fails closed", llms_contract) + self.assertNotIn("`dory vm` is an in-process framework engine surface", llms_contract) + + app_store = (ROOT / "Dory/Models/AppStore.swift").read_text(encoding="utf-8") + for stale in ( + "Dory's built-in Intel engine needs", + "one-off `dory vm --rosetta` path", + "x86/amd64 emulation enabled", + ): + self.assertNotIn(stale, app_store, stale) + self.assertIn("x86_64 Linux applications inside Dory's ARM64 container", app_store) + + def test_mutable_fixture_fails_before_docker_or_socket_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + env = { + **os.environ, + "HOME": temporary, + "READINESS_WORKDIR": str(pathlib.Path(temporary) / "evidence"), + "READINESS_DOCKER_BIN": "/usr/bin/true", + "READINESS_ALPINE_IMAGE": "alpine:latest", + "RUN_NONNATIVE_ARCH": "0", + } + result = subprocess.run( + [str(GATE), "--engines", "dory"], + cwd=ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("fixture images must be exact digest references", result.stderr) + + def test_strict_mode_requires_an_explicit_candidate_cli(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + env = { + **os.environ, + "HOME": temporary, + "READINESS_WORKDIR": str(pathlib.Path(temporary) / "evidence"), + "READINESS_DOCKER_BIN": "", + "READINESS_ALPINE_IMAGE": DIGEST_IMAGE, + "RUN_NONNATIVE_ARCH": "0", + } + result = subprocess.run( + [str(GATE), "--engines", "dory", "--strict"], + cwd=ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("strict readiness requires READINESS_DOCKER_BIN", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-release-candidate-live-smoke.py b/.github/scripts/test-release-candidate-live-smoke.py new file mode 100644 index 00000000..ff43f536 --- /dev/null +++ b/.github/scripts/test-release-candidate-live-smoke.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the exact physical release-candidate wrapper.""" + +from __future__ import annotations + +import os +import pathlib +import re +import socket +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "release-candidate-live-smoke.sh" +FEX_KIND_GATE = ROOT / "scripts" / "fex-kind-live-gate.sh" + + +class ReleaseCandidateLiveSmokeTests(unittest.TestCase): + def test_live_contract_binds_candidate_and_all_physical_gates(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "DORY_RELEASE_LIVE_CONFIRMED=ISOLATED-DORY-RELEASE-USER", + "live qualification requires an exact source commit", + "candidate app is unavailable or indirect", + "candidate executable is unavailable or indirect", + "candidate Docker socket is not owned by the release user", + "candidate app has no valid notarization ticket", + "candidate is not accepted as Notarized Developer ID", + "required offline release fixture is missing", + "ISOLATED-DORY-MACHINE-RESOURCES", + "EXACT-CANDIDATE-DESKTOPS", + '--component-dir "$DESKTOP_COMPONENT_DIR"', + "ISOLATED-EXTERNAL-APFS-BIND", + "ISOLATED-DORY-BIND-LOCKS", + "SLEEP-AND-WAKE-THIS-MAC", + 'DORY_APP="$APP"', + 'READINESS_DOCKER_BIN="$DOCKER_CLI"', + 'READINESS_ALPINE_IMAGE="$FIXTURE_IMAGE"', + 'READINESS_NONNATIVE_BUILD_IMAGE="$NONNATIVE_BUILD_IMAGE"', + "live-manifest.txt", + "live_candidate=PASS", + "zed-linux-aarch64.tar.gz", + 'ZED_VERSION="1.16.1"', + "releases/download/v$ZED_VERSION/zed-linux-aarch64.tar.gz", + "384499c75d75c6aab53110dbc1d8856f6f774baaa32dc57b9963f9e29f8d007b", + 'managed_desktop_baseline=$MANAGED_DESKTOP_BASELINE_RESULT', + 'mesa_virgl_desktop=$MESA_VIRGL_DESKTOP_RESULT', + 'renderer_release_signature=$RENDERER_RELEASE_SIGNATURE_RESULT', + 'zed_native_venus=$ZED_NATIVE_VENUS_RESULT', + "--require-acceleration", + "--require-release-signature", + "native Ubuntu Venus/Zed application evidence did not pass", + "Mesa VirGL desktop application evidence did not pass", + "renderer release qualification signature was not authenticated", + "signed desktop component candidate is unavailable or indirect", + "signed Kubernetes component is unavailable or indirect", + "release-build/component-candidate/arm64/component-candidate-inventory.json", + "Kubernetes component TeamIdentifier does not match the candidate app", + "Kubernetes component bytes differ from the immutable candidate inventory", + 'candidate_team_identifier=$APP_TEAM_IDENTIFIER', + 'kubectl_team_identifier=$KUBECTL_TEAM_IDENTIFIER', + 'kubectl_component_sha256=$KUBECTL_COMPONENT_SHA256', + 'component_inventory_sha256=$COMPONENT_INVENTORY_SHA256', + "scripts/fex-kind-live-gate.sh", + "EXACT-DORY-FEX-KIND", + 'fex_kind_issue_78=$FEX_KIND_GATE_RESULT', + 'KIND_VERSION="0.29.0"', + "314d8f1428842fd1ba2110fd0052a0f0b3ab5773ab1bdcdad1ff036e913310c9", + "DORY_RELEASE_LIVE_LOG_ROOT", + ): + self.assertIn(proof, text, proof) + for stale in ("alpine:latest", "nginx:alpine", "node:20-alpine", "assert "): + self.assertNotIn(stale, text, stale) + + def test_every_invoked_script_is_tracked(self) -> None: + text = GATE.read_text(encoding="utf-8") + dependencies = sorted(set(re.findall(r"scripts/[A-Za-z0-9._/-]+\.sh", text))) + self.assertGreaterEqual(len(dependencies), 10) + for dependency in dependencies: + result = subprocess.run( + ["git", "ls-files", "--error-unmatch", dependency], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 0, f"untracked live dependency: {dependency}") + + def test_fex_kind_gate_is_the_exact_issue_78_reproduction(self) -> None: + subprocess.run(["bash", "-n", str(FEX_KIND_GATE)], check=True) + text = FEX_KIND_GATE.read_text(encoding="utf-8") + for proof in ( + "EXACT-DORY-FEX-KIND", + "kindest/node:v1.33.0@sha256:02f73d6ae3f11ad5d543f16736a2cb2a63a300ad60e81dac22099b0b04784a4e", + "polinux/stress:1.0.4@sha256:b6144f84f9c15dac80deb48d3a646b55c7043ab1d83ea0a697c09097aaad21aa", + 'EXPECTED_NODE_RUNC_VERSION="1.2.3"', + "guest/kernel/verify-build.sh arm64", + "guest/initfs/verify-build.sh arm64", + "running Dory VM kernel differs from the same-commit Venus release artifact", + "running Dory VM initfs differs from the same-commit release artifact", + 'node_runtime="$(docker_e inspect', + "/usr/local/bin/runc.real", + "/usr/local/bin/dory-runc", + "runc.real is not the preserved kind node runtime file mount", + "flags: POCF", + 'kubectl_e exec "$EXEC_POD" -- uname -m', + "issue #78 one-shot result is not x86_64", + "nested runc exec result is not x86_64", + "FEXServerClient", + "Failure to setup client", + "runc_wrapper_sha256=", + "fex_sha256=", + "fex_server_sha256=", + "fex_errors=absent", + "kind cluster cleanup failed", + "kind node container remains after cleanup", + "kind node image remains after cleanup", + "isolated_cleanup=PASS", + "docker_after=PASS", + "issue_78=PASS", + "status=PASS", + ): + self.assertIn(proof, text, proof) + self.assertNotIn("kindest/node:v1.33 --", text) + + def test_fex_kind_confirmation_fails_before_host_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(FEX_KIND_GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--workroot", + str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm EXACT-DORY-FEX-KIND", result.stderr) + self.assertFalse(workroot.exists()) + + def test_fex_kind_rejects_unpinned_kind_bytes_before_evidence_creation(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + socket_path = root / "dory.sock" + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(str(socket_path)) + try: + fake_tool = root / "fake-tool" + fake_tool.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + fake_tool.chmod(0o755) + running_kernel = root / "running-kernel" + expected_kernel = root / "expected-kernel" + running_initfs = root / "running-initfs" + expected_initfs = root / "expected-initfs" + running_kernel.write_bytes(b"same kernel\n") + expected_kernel.write_bytes(b"same kernel\n") + running_initfs.write_bytes(b"same initfs\n") + expected_initfs.write_bytes(b"same initfs\n") + workroot = root / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(FEX_KIND_GATE), + "--socket", + str(socket_path), + "--docker", + str(fake_tool), + "--kind", + str(fake_tool), + "--kubectl", + str(fake_tool), + "--kernel", + str(running_kernel), + "--initfs", + str(running_initfs), + "--expected-kernel", + str(expected_kernel), + "--expected-initfs", + str(expected_initfs), + "--source-commit", + "a" * 40, + "--workroot", + str(workroot), + "--confirm", + "EXACT-DORY-FEX-KIND", + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + finally: + listener.close() + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("kind v0.29.0 Darwin ARM64 digest mismatch", result.stderr) + self.assertFalse(workroot.exists()) + + def test_dedicated_user_confirmation_fails_before_host_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + app = pathlib.Path(temporary) / "Dory.app" + app.mkdir() + result = subprocess.run( + [str(GATE), str(app)], + cwd=ROOT, + env={ + **os.environ, + "HOME": temporary, + "DORY_RELEASE_SOURCE_COMMIT": "a" * 40, + }, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("DORY_RELEASE_LIVE_CONFIRMED", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-release-candidate-qualifier.py b/.github/scripts/test-release-candidate-qualifier.py new file mode 100755 index 00000000..f8c582a8 --- /dev/null +++ b/.github/scripts/test-release-candidate-qualifier.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Offline contract for the exact signed-candidate qualification orchestrator.""" + +from __future__ import annotations + +import json +import os +import pathlib +import re +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "qualify-release-candidate.sh" + + +class ReleaseCandidateQualifierTests(unittest.TestCase): + def test_orchestrator_uses_signed_schema_two_authority_and_current_gate_apis(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "QUALIFY-EXACT-DORY-RELEASE", + "validate-release-metadata.py", + "signed schema-2 release metadata validation failed", + "checked-out source does not match --source-commit", + "tracked qualification harness differs from the checked-out source commit", + "qualification harness authority changed during qualification", + "qualification harness bytes changed during qualification", + "engine PID is outside the exact isolated runtime authority", + "PHYSICAL-APFS-VOLUME-IDENTITY", + "ISOLATED-RUNTIME-DATA-DISK-GROWTH", + "ISOLATED-ENGINE-DEFAULT-PLATFORM", + "ISOLATED-ENGINE-PRIVATE-REGISTRY", + "ISOLATED-ENGINE-BIND-FILE-COHERENCE", + "ISOLATED-ENGINE-TESTCONTAINERS", + '--ryuk-image "$TESTCONTAINERS_RYUK_IMAGE"', + '--image "$IMAGE"', + '--compose "$COMPOSE"', + '--runner-image "$ACT_RUNNER_IMAGE"', + "ISOLATED-ENGINE-LONG-LIVED-TCP", + "ISOLATED-ENGINE-ENDURANCE-RELIABILITY", + "candidate-binding.txt", + "component_catalog_schema=2", + "component_catalog_signature_sha256=", + "app_executable_sha256=", + "dory_vmm_sha256=", + "kernel_sha256=", + "rootfs_sha256=", + "guest_agent_sha256=", + '"schemaVersion": 2', + '"kind": "dev.dory.release-qualification"', + '"candidateBindingSha256"', + '"componentCatalogSchemaVersion": 2', + ): + self.assertIn(proof, text, proof) + for unsafe in ( + "assert ", + '"schemaVersion": 1', + 'catalog.get("schemaVersion") == 1', + "DORY_ALLOW_UNNOTARIZED_QUALIFICATION", + "DORY_ALLOW_SHORT_QUALIFICATION", + "trap cleanup EXIT INT TERM", + 'rm -rf "$private_registry_workroot"', + 'DOCKER_HOST="unix://$SOCKET" "$DOCKER"', + ): + self.assertNotIn(unsafe, text, unsafe) + + references = set( + re.findall(r"(? None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + qualification = root / "must-not-exist" + result = subprocess.run( + [ + "bash", + str(GATE), + "--build-dir", + str(root / "missing-build"), + "--version", + "1.2.3", + "--build", + "42", + "--source-commit", + "a" * 40, + "--qualification-root", + str(qualification), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(qualification.exists()) + + def test_completion_writer_emits_typed_schema_two_binding(self) -> None: + text = GATE.read_text(encoding="utf-8") + marker = '<<\'PY\'\nimport json\nimport sys\n\n(\n output, release_qualifying' + start = text.find(marker) + self.assertNotEqual(start, -1) + script_start = start + len("<<'PY'\n") + script_end = text.find("\nPY\nmv \"$WORKDIR/qualification.complete.json.partial\"", script_start) + self.assertNotEqual(script_end, -1) + writer = text[script_start:script_end] + + digest = "b" * 64 + with tempfile.TemporaryDirectory() as temporary: + output = pathlib.Path(temporary) / "complete.json" + arguments = [ + str(output), "true", "false", "1.2.3", "42", "a" * 40, "7", "3", + digest, digest, digest, digest, digest, "28800", "90000", "12.0.4", + "0.87.0", "0.2.89", "localstack@sha256:" + digest, "0.37.5", "2.109.1", + "k3s@sha256:" + digest, "nginx@sha256:" + digest, "2.23.0", + "python@sha256:" + digest, digest, digest, "alpine@sha256:" + digest, + "registry@sha256:" + digest, "ssh@sha256:" + digest, "ryuk@sha256:" + digest, + "node@sha256:" + digest, digest, digest, digest, digest, "123456", + ] + result = subprocess.run( + ["python3", "-", *arguments], + input=writer, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + payload = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(payload["schemaVersion"], 2) + self.assertEqual(payload["kind"], "dev.dory.release-qualification") + self.assertIs(payload["releaseQualifying"], True) + self.assertEqual(payload["sourceCommit"], "a" * 40) + self.assertEqual(payload["candidateBindingSha256"], digest) + self.assertEqual(payload["componentCatalogSchemaVersion"], 2) + self.assertEqual(payload["componentCatalogSignatureSha256"], digest) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-release-metadata.py b/.github/scripts/test-release-metadata.py new file mode 100644 index 00000000..8824c578 --- /dev/null +++ b/.github/scripts/test-release-metadata.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +"""Focused tamper tests for the signed schema-2 release catalog boundary.""" + +from __future__ import annotations + +import base64 +import hashlib +import importlib.util +import json +import pathlib +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +VALIDATOR_PATH = ROOT / "scripts" / "validate-release-metadata.py" +SPEC = importlib.util.spec_from_file_location("validate_release_metadata", VALIDATOR_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError("release metadata validator could not be loaded") +VALIDATOR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(VALIDATOR) + + +class ReleaseCatalogTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.tool_directory = tempfile.TemporaryDirectory(prefix="dory-release-signing-test.") + tool_root = pathlib.Path(cls.tool_directory.name) + signer_source = tool_root / "signer.swift" + signer_source.write_text( + textwrap.dedent( + """ + import CryptoKit + import Foundation + + func fail() -> Never { exit(2) } + let arguments = Array(CommandLine.arguments.dropFirst()) + guard let command = arguments.first else { fail() } + if command == "keygen", arguments.count == 3 { + let key = Curve25519.Signing.PrivateKey() + try key.rawRepresentation.write(to: URL(fileURLWithPath: arguments[1])) + try Data(key.publicKey.rawRepresentation.base64EncodedString().utf8) + .write(to: URL(fileURLWithPath: arguments[2])) + } else if command == "sign", arguments.count == 4 { + let privateData = try Data(contentsOf: URL(fileURLWithPath: arguments[1])) + let key = try Curve25519.Signing.PrivateKey(rawRepresentation: privateData) + let message = try Data(contentsOf: URL(fileURLWithPath: arguments[2])) + let signature = try key.signature(for: message).base64EncodedString() + "\\n" + try Data(signature.utf8).write(to: URL(fileURLWithPath: arguments[3])) + } else { + fail() + } + """ + ), + encoding="utf-8", + ) + cls.signer = tool_root / "signer" + subprocess.run( + ["xcrun", "swiftc", str(signer_source), "-o", str(cls.signer)], + check=True, + ) + cls.private_key = tool_root / "private-key" + cls.public_key_file = tool_root / "public-key" + subprocess.run( + [str(cls.signer), "keygen", str(cls.private_key), str(cls.public_key_file)], + check=True, + ) + cls.public_key = cls.public_key_file.read_text(encoding="ascii") + + @classmethod + def tearDownClass(cls) -> None: + cls.tool_directory.cleanup() + + def setUp(self) -> None: + self.directory = tempfile.TemporaryDirectory(prefix="dory-release-catalog-test.") + self.build = pathlib.Path(self.directory.name) + self.components = self.build / "components" / "arm64" + self.components.mkdir(parents=True) + self.catalog_path = self.components / "catalog.json" + self.digest_path = self.components / "catalog.json.sha256" + self.signature_path = self.components / "catalog.json.sig" + self.asset_name = ( + "Dory-9.8.7-component-linux-desktop-arm64-" + "virtual-machine-qualification.json" + ) + self.asset_payload = b"q" + (self.components / self.asset_name).write_bytes(self.asset_payload) + asset_digest = hashlib.sha256(self.asset_payload).hexdigest() + key_id = hashlib.sha256(base64.b64decode(self.public_key, validate=True)).hexdigest() + self.catalog = { + "kind": "dev.dory.component-catalog", + "schemaVersion": 2, + "releaseVersion": "9.8.7", + "generatedAt": "2026-08-21T01:02:03Z", + "minimumAppVersion": "9.8.7", + "architecture": "arm64", + "components": [ + self.component("docker-core", [], []), + self.component( + "linux-desktop", + ["docker-core"], + [{ + "path": "virtual-machine-qualification.json", + "role": "qualification-evidence", + "url": ( + "https://github.com/Augani/dory/releases/download/v9.8.7/" + f"{self.asset_name}" + ), + "compression": "none", + "downloadBytes": 1, + "installedBytes": 1, + "sha256": asset_digest, + "installedSHA256": asset_digest, + "executable": False, + }], + qualification=["qualification-1"], + attestation_digest=asset_digest, + ), + ], + "virtualMachineQualification": { + "component": "linux-desktop", + "path": "virtual-machine-qualification.json", + "manifestIdentity": "qualification-1", + "manifestFormatVersion": 2, + "signingKeyID": key_id, + }, + } + + def tearDown(self) -> None: + self.directory.cleanup() + + @staticmethod + def component( + identifier: str, + dependencies: list[str], + assets: list[dict], + *, + qualification: list[str] | None = None, + attestation_digest: str = "d" * 64, + ) -> dict: + return { + "id": identifier, + "version": "9.8.7", + "displayName": identifier, + "summary": f"{identifier} fixture", + "dependencies": dependencies, + "downloadBytes": 1, + "installedBytes": 1, + "assets": assets, + "architectures": ["arm64"], + "hostRequirements": {"platform": "macos", "minimumVersion": "14.0"}, + "provides": [], + "requires": [], + "provenance": { + "sourceCommit": "a" * 40, + "builder": "dory.test", + "recipeDigest": "b" * 64, + "sbomDigest": "c" * 64, + "attestationDigest": attestation_digest, + }, + "qualification": qualification or [], + } + + def publish(self, *, resign: bool = True) -> None: + self.catalog_path.write_text( + json.dumps(self.catalog, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + self.digest_path.write_text( + hashlib.sha256(self.catalog_path.read_bytes()).hexdigest() + "\n", + encoding="ascii", + ) + if resign: + subprocess.run( + [ + str(self.signer), + "sign", + str(self.private_key), + str(self.catalog_path), + str(self.signature_path), + ], + check=True, + ) + + def validate(self) -> set[str]: + return VALIDATOR.validate_catalog( + self.build, + "9.8.7", + "a" * 40, + public_key=self.public_key, + ) + + def test_signed_schema_two_catalog_is_accepted(self) -> None: + self.publish() + self.assertEqual( + self.validate(), + {"Dory-9.8.7-component-linux-desktop-arm64-virtual-machine-qualification.json"}, + ) + + def test_schema_one_is_rejected_even_when_correctly_signed(self) -> None: + self.catalog["schemaVersion"] = 1 + self.publish() + with self.assertRaisesRegex(ValueError, "schema 2"): + self.validate() + + def test_test_fixture_catalog_kind_is_never_public_release_metadata(self) -> None: + self.catalog["kind"] = "dev.dory.component-catalog.test-fixture" + self.publish() + with self.assertRaisesRegex(ValueError, "kind mismatch"): + self.validate() + + def test_qualification_schema_one_is_rejected_even_when_correctly_signed(self) -> None: + self.catalog["virtualMachineQualification"]["manifestFormatVersion"] = 1 + self.publish() + with self.assertRaisesRegex(ValueError, "qualification schema"): + self.validate() + + def test_catalog_mutation_with_rewritten_digest_fails_signature(self) -> None: + self.publish() + self.catalog["generatedAt"] = "2026-08-21T01:02:04Z" + self.publish(resign=False) + with self.assertRaisesRegex(ValueError, "signature is invalid"): + self.validate() + + def test_unknown_catalog_field_is_rejected(self) -> None: + self.catalog["unexpected"] = True + self.publish() + with self.assertRaisesRegex(ValueError, "shape is invalid"): + self.validate() + + def test_signed_asset_digest_must_match_delivered_bytes(self) -> None: + self.catalog["components"][1]["assets"][0]["sha256"] = "e" * 64 + self.catalog["components"][1]["assets"][0]["installedSHA256"] = "e" * 64 + self.publish() + with self.assertRaisesRegex(ValueError, "digest differs from catalog"): + self.validate() + + def test_signed_asset_size_must_match_delivered_bytes(self) -> None: + self.catalog["components"][1]["assets"][0]["downloadBytes"] = 2 + self.catalog["components"][1]["assets"][0]["installedBytes"] = 2 + self.publish() + with self.assertRaisesRegex(ValueError, "byte count differs from catalog"): + self.validate() + + def test_uncompressed_asset_must_have_equal_stored_and_installed_binding(self) -> None: + self.catalog["components"][1]["assets"][0]["installedSHA256"] = "f" * 64 + self.publish() + with self.assertRaisesRegex(ValueError, "uncompressed component asset"): + self.validate() + + def test_delivered_asset_symlink_is_rejected(self) -> None: + artifact = self.components / self.asset_name + direct = self.components / "direct-qualification.json" + artifact.rename(direct) + artifact.symlink_to(direct.name) + self.publish() + with self.assertRaisesRegex(ValueError, "indirect or empty"): + self.validate() + + def test_catalog_symlink_is_rejected_before_signature_verification(self) -> None: + self.publish() + direct = self.components / "catalog-direct.json" + self.catalog_path.rename(direct) + self.catalog_path.symlink_to(direct.name) + with self.assertRaisesRegex(ValueError, "missing or indirect"): + self.validate() + + def test_qualification_must_use_the_pinned_signing_key(self) -> None: + self.catalog["virtualMachineQualification"]["signingKeyID"] = "e" * 64 + self.publish() + with self.assertRaisesRegex(ValueError, "trust root"): + self.validate() + + def test_current_appcast_must_declare_catalog_schema_two(self) -> None: + update = self.build / "Dory-9.8.7-app-update.zip" + update.write_bytes(b"fixture") + + def write_appcast(component_schema: int) -> None: + (self.build / "appcast.xml").write_text( + textwrap.dedent( + f"""\ + + + + Dory + https://augani.github.io/dory/appcast.xml + Updates for Dory - native Docker and Linux containers for macOS. + en + + 9.8.7 + Fri, 21 Aug 2026 01:02:03 +0000 + 42 + 9.8.7 + 14.0 + 1 + 1 + 1 + {component_schema} + + + + + """ + ), + encoding="utf-8", + ) + + write_appcast(1) + with self.assertRaisesRegex(ValueError, "component schema"): + VALIDATOR.validate_appcast( + self.build, + "9.8.7", + "42", + "appcast.xml", + "Dory", + "https://augani.github.io/dory/appcast.xml", + update.name, + ) + write_appcast(2) + VALIDATOR.validate_appcast( + self.build, + "9.8.7", + "42", + "appcast.xml", + "Dory", + "https://augani.github.io/dory/appcast.xml", + update.name, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-release-orchestrator.py b/.github/scripts/test-release-orchestrator.py new file mode 100755 index 00000000..caa18918 --- /dev/null +++ b/.github/scripts/test-release-orchestrator.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Offline authority tests for the public release orchestrator.""" + +from __future__ import annotations + +import os +import pathlib +import shlex +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +RELEASE = ROOT / "scripts" / "release.sh" + + +class ReleaseOrchestratorTests(unittest.TestCase): + @staticmethod + def run_bash(program: str) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment["PYTHONOPTIMIZE"] = "2" + return subprocess.run( + ["bash", "-c", program], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + def test_source_is_shell_valid_and_binds_public_release_authority(self) -> None: + subprocess.run(["bash", "-n", str(RELEASE)], cwd=ROOT, check=True) + source = RELEASE.read_text(encoding="utf-8") + self.assertNotIn("assert ", source) + for contract in ( + "release build directory must be a direct child of the checkout", + "release build authority is not owned by this user", + "public releases must use Dory signing team 864H636QW4", + "scripts/verify-clean-release-source.sh", + "scripts/verify-macos-deployment-targets.sh", + "scripts/validate-app-update-payload.sh", + "source=Notarized Developer ID", + "scripts/generate-release-sbom.py", + "scripts/verify-release-sbom.py", + "scripts/generate-appcast.sh", + "write_release_manifest", + ): + self.assertIn(contract, source) + + def test_missing_metadata_fails_before_release_mutation(self) -> None: + result = subprocess.run( + [str(RELEASE)], + cwd=ROOT, + env={**os.environ, "PYTHONOPTIMIZE": "2"}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + self.assertEqual(result.returncode, 64) + self.assertIn("usage: scripts/release.sh", result.stdout) + + def test_recursive_build_cleanup_is_confined_to_checkout(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-release-orchestrator.") as raw: + outside = pathlib.Path(raw).resolve() / "release-build-escape" + result = self.run_bash( + "set -euo pipefail; " + "DORY_RELEASE_SOURCE_ONLY=1 " + f"DORY_RELEASE_BUILD_DIR={shlex.quote(str(outside))} " + "source scripts/release.sh 1.2.3 4; validate_release_build_dir" + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("direct child of the checkout", result.stdout) + + def test_public_release_rejects_replaceable_signing_team(self) -> None: + result = self.run_bash( + "set -euo pipefail; " + "DORY_RELEASE_SOURCE_ONLY=1 NOTARY_TEAM_ID=TESTTEAM " + "source scripts/release.sh 1.2.3 4; " + "DORY_PUBLIC_RELEASE=1 preflight_public_release" + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("must use Dory signing team 864H636QW4", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-release-output-validator.py b/.github/scripts/test-release-output-validator.py new file mode 100755 index 00000000..2107ccf1 --- /dev/null +++ b/.github/scripts/test-release-output-validator.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Focused clean-checkout tests for the public release-output gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +VALIDATOR = ROOT / "scripts" / "validate-release-outputs.sh" + + +class ReleaseOutputValidatorTests(unittest.TestCase): + def run_validator(self, build_directory: pathlib.Path) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment["PYTHONOPTIMIZE"] = "2" + environment["DORY_RELEASE_OUTPUTS_SKIP_PLATFORM_VALIDATION"] = "1" + return subprocess.run( + [str(VALIDATOR), str(build_directory), "9.8.7", "42"], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + def test_validator_is_shell_valid_and_uses_the_strict_metadata_boundary(self) -> None: + subprocess.run(["bash", "-n", str(VALIDATOR)], cwd=ROOT, check=True) + source = VALIDATOR.read_text(encoding="utf-8") + + self.assertNotIn("assert ", source) + self.assertNotIn("DORY_RELEASE_OUTPUTS_SKIP_COMPONENT_SIGNATURE", source) + self.assertIn("scripts/validate-release-metadata.py", source) + self.assertIn("scripts/verify-release-sbom.py", source) + self.assertIn("scripts/verify-distribution-signatures.sh", source) + self.assertIn("duplicate ZIP member", source) + self.assertIn("traversing ZIP member", source) + self.assertIn("contains a symlink that escapes Dory.app", source) + self.assertIn("build directory has an indirect ancestor", source) + + metadata = source.index("scripts/validate-release-metadata.py") + platform_skip = source.index( + 'if [ "${DORY_RELEASE_OUTPUTS_SKIP_PLATFORM_VALIDATION:-0}" != "1" ]' + ) + self.assertLess(metadata, platform_skip) + + def test_missing_build_directory_fails_closed_under_python_optimize(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-release-output-test.") as root: + missing = pathlib.Path(root).resolve() / "missing" + result = self.run_validator(missing) + self.assertNotEqual(result.returncode, 0) + self.assertIn("build directory is missing or indirect", result.stdout) + + def test_symlinked_build_directory_is_rejected(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-release-output-test.") as root: + directory = pathlib.Path(root).resolve() + target = directory / "target" + target.mkdir() + link = directory / "release-build-link" + link.symlink_to(target, target_is_directory=True) + result = self.run_validator(link) + self.assertNotEqual(result.returncode, 0) + self.assertIn("build directory is missing or indirect", result.stdout) + + def test_empty_direct_build_directory_rejects_missing_public_artifacts(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-release-output-test.") as root: + result = self.run_validator(pathlib.Path(root).resolve()) + self.assertNotEqual(result.returncode, 0) + self.assertIn("required public artifact is missing or empty", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-release-qualification-verifier.py b/.github/scripts/test-release-qualification-verifier.py new file mode 100644 index 00000000..76e427ae --- /dev/null +++ b/.github/scripts/test-release-qualification-verifier.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Offline contract tests for durable release-qualification verification.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import pathlib +import re +import subprocess +import sys +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SHELL_VERIFIER = ROOT / "scripts/verify-release-qualification.sh" +AUTHORITY_VALIDATOR = ROOT / "scripts/validate-release-qualification.py" + + +def load_validator(): + spec = importlib.util.spec_from_file_location("release_qualification_validator", AUTHORITY_VALIDATOR) + if spec is None or spec.loader is None: + raise RuntimeError("could not load release qualification validator") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class ReleaseQualificationVerifierTests(unittest.TestCase): + def test_verifier_is_explicit_schema_two_fail_closed_authority(self) -> None: + subprocess.run(["bash", "-n", str(SHELL_VERIFIER)], check=True) + subprocess.run([sys.executable, "-m", "py_compile", str(AUTHORITY_VALIDATOR)], check=True) + shell = SHELL_VERIFIER.read_text(encoding="utf-8") + validator = AUTHORITY_VALIDATOR.read_text(encoding="utf-8") + for proof in ( + "validate-release-qualification.py", + "durable schema-2 qualification authority is invalid", + "checked-out source does not match --source-commit", + "tracked qualification verifier differs from the checked-out source commit", + "qualification verifier bytes changed during verification", + "durable qualification authority changed during semantic verification", + "testcontainers_version=", + "ryuk_image=", + "runner_image=", + ): + self.assertIn(proof, shell, proof) + self.assertEqual(shell.count("python3 scripts/validate-release-qualification.py"), 2) + for proof in ( + '"schemaVersion"', + '"dev.dory.release-qualification"', + '"candidateBindingSha256"', + '"componentCatalogSchemaVersion"', + '"componentCatalogSignatureSha256"', + "validate_evidence_manifest", + "evidence manifest does not cover the exact retained evidence set", + "candidate binding shape is invalid", + "qualificationHarnessSha256", + "metadataValidatorSha256", + "testcontainersRyukImage", + "actRunnerImage", + ): + self.assertIn(proof, validator, proof) + self.assertNotRegex(shell, r"\bassert\s") + self.assertNotRegex(validator, r"\bassert\s") + self.assertNotIn('schemaVersion"] == 1', shell) + self.assertNotIn('schemaVersion"] == 1', validator) + + references = set( + re.findall(r"(? None: + validator = load_validator() + with tempfile.TemporaryDirectory() as temporary: + qualification = pathlib.Path(temporary) + evidence = qualification / "evidence" + evidence.mkdir() + retained = evidence / "retained.txt" + retained.write_text("safe evidence\n", encoding="utf-8") + digest = hashlib.sha256(retained.read_bytes()).hexdigest() + manifest = evidence / "evidence-sha256.txt" + manifest.write_text(f"{digest} evidence/retained.txt\n", encoding="ascii") + self.assertEqual(validator.validate_evidence_manifest(qualification), manifest) + + indirect = evidence / "indirect.txt" + indirect.symlink_to(retained) + with self.assertRaisesRegex(ValueError, "contains a symlink"): + validator.validate_evidence_manifest(qualification) + indirect.unlink() + + unlisted = evidence / "unlisted.txt" + unlisted.write_text("not authenticated\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "exact retained evidence set"): + validator.validate_evidence_manifest(qualification) + + def test_completion_and_candidate_binding_shapes_are_closed(self) -> None: + validator = load_validator() + self.assertEqual(len(validator.COMPLETION_KEYS), 71) + self.assertEqual(len(validator.BINDING_KEYS), 36) + self.assertIn("componentCatalogSignatureSha256", validator.COMPLETION_KEYS) + self.assertIn("candidateBindingSha256", validator.COMPLETION_KEYS) + self.assertIn("component_catalog_digest_file_sha256", validator.BINDING_KEYS) + self.assertIn("host_facts_sha256", validator.BINDING_KEYS) + self.assertIn("qualifier_sha256", validator.BINDING_KEYS) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-release-sbom.py b/.github/scripts/test-release-sbom.py new file mode 100644 index 00000000..bbb969d4 --- /dev/null +++ b/.github/scripts/test-release-sbom.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Offline security regressions for exact Dory.app CycloneDX evidence.""" + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GENERATOR = ROOT / "scripts" / "generate-release-sbom.py" +VERIFIER = ROOT / "scripts" / "verify-release-sbom.py" +VERSION = "9.8.7" +COMMIT = "a" * 40 + + +class ReleaseSBOMTests(unittest.TestCase): + def setUp(self) -> None: + self.directory = tempfile.TemporaryDirectory(prefix="dory-release-sbom-test.") + self.root = pathlib.Path(self.directory.name) + self.app = self.root / "Dory.app" + executable = self.app / "Contents" / "MacOS" / "Dory" + resource = self.app / "Contents" / "Resources" / "payload.txt" + executable.parent.mkdir(parents=True) + resource.parent.mkdir(parents=True) + executable.write_bytes(b"candidate executable\n") + executable.chmod(0o755) + resource.write_bytes(b"candidate payload\n") + os.symlink("payload.txt", resource.parent / "payload-current.txt") + self.sbom = self.root / "Dory.cdx.json" + + def tearDown(self) -> None: + self.directory.cleanup() + + def run_generator(self, output: pathlib.Path | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + "python3", + str(GENERATOR), + "--app", + str(self.app), + "--version", + VERSION, + "--source-commit", + COMMIT, + "--output", + str(output or self.sbom), + ], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + def run_verifier(self) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + "python3", + str(VERIFIER), + "--sbom", + str(self.sbom), + "--app", + str(self.app), + "--version", + VERSION, + "--source-commit", + COMMIT, + ], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + def test_exact_tree_round_trip_is_deterministic_and_portable(self) -> None: + generated = self.run_generator() + self.assertEqual(generated.returncode, 0, generated.stderr) + first = self.sbom.read_bytes() + second_path = self.root / "second.cdx.json" + regenerated = self.run_generator(second_path) + self.assertEqual(regenerated.returncode, 0, regenerated.stderr) + self.assertEqual(first, second_path.read_bytes()) + self.assertNotIn(str(self.root).encode(), first) + verified = self.run_verifier() + self.assertEqual(verified.returncode, 0, verified.stderr) + self.assertIn("PASS", verified.stdout) + + def test_file_mutation_invalidates_the_inventory(self) -> None: + self.assertEqual(self.run_generator().returncode, 0) + with (self.app / "Contents" / "MacOS" / "Dory").open("ab") as handle: + handle.write(b"tampered\n") + verified = self.run_verifier() + self.assertNotEqual(verified.returncode, 0) + self.assertIn("does not exactly inventory", verified.stderr) + + def test_symlink_mutation_invalidates_the_inventory(self) -> None: + self.assertEqual(self.run_generator().returncode, 0) + link = self.app / "Contents" / "Resources" / "payload-current.txt" + link.unlink() + os.symlink("../MacOS/Dory", link) + verified = self.run_verifier() + self.assertNotEqual(verified.returncode, 0) + self.assertIn("does not exactly inventory", verified.stderr) + + def test_symlink_that_escapes_the_app_is_rejected(self) -> None: + link = self.app / "Contents" / "Resources" / "payload-current.txt" + link.unlink() + os.symlink(str(self.root / "outside"), link) + (self.root / "outside").write_bytes(b"outside\n") + generated = self.run_generator() + self.assertNotEqual(generated.returncode, 0) + self.assertIn("absolute symlink", generated.stderr) + + def test_unknown_sbom_field_is_rejected(self) -> None: + self.assertEqual(self.run_generator().returncode, 0) + document = json.loads(self.sbom.read_text(encoding="utf-8")) + document["unexpected"] = True + self.sbom.write_text(json.dumps(document) + "\n", encoding="utf-8") + verified = self.run_verifier() + self.assertNotEqual(verified.returncode, 0) + self.assertIn("shape is invalid", verified.stderr) + + def test_output_inside_the_app_is_rejected(self) -> None: + output = self.app / "Contents" / "Resources" / "Dory.cdx.json" + generated = self.run_generator(output) + self.assertNotEqual(generated.returncode, 0) + self.assertIn("outside Dory.app", generated.stderr) + self.assertFalse(output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-renderer-production-tuple.py b/.github/scripts/test-renderer-production-tuple.py new file mode 100644 index 00000000..e1acf225 --- /dev/null +++ b/.github/scripts/test-renderer-production-tuple.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +"""Focused static tests for Dory's schema-3 dual-Metal renderer tuple.""" + +from __future__ import annotations + +import hashlib +import json +import pathlib +import runpy +import subprocess +import tempfile +import unittest +from unittest import mock + + +REPO = pathlib.Path(__file__).resolve().parents[2] +DEFINITION = REPO / "Config/DoryRendererProductionTuple.json" +VERIFIER = REPO / "scripts/renderer-production-tuple.py" +PACKAGE = REPO / "scripts/package-renderer-production-bundle.py" +ASSEMBLER = REPO / "scripts/assemble-renderer-production-worker.sh" +QUALIFICATION_VERIFIER = REPO / "scripts/verify-renderer-bootstrap-qualification.py" + + +def run(*arguments: object, ok: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + [str(argument) for argument in arguments], + cwd=REPO, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + if ok and result.returncode != 0: + raise AssertionError(result.stdout) + if not ok and result.returncode == 0: + raise AssertionError(f"command unexpectedly succeeded: {arguments}\n{result.stdout}") + return result + + +def tuple_command( + *arguments: object, + definition: pathlib.Path = DEFINITION, + ok: bool = True, +) -> subprocess.CompletedProcess[str]: + return run("python3", VERIFIER, "--definition", definition, *arguments, ok=ok) + + +class StaticTupleTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.definition = json.loads(DEFINITION.read_text(encoding="utf-8")) + + def test_definition_is_exact_schema_3_dual_metal_architecture(self) -> None: + result = tuple_command("verify-definition", "--repo-root", REPO) + canonical = ( + json.dumps( + self.definition, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + + "\n" + ).encode() + digest = hashlib.sha256(canonical).hexdigest() + self.assertEqual(result.stdout.strip(), f"definition.sha256={digest}") + self.assertEqual(tuple_command("definition-sha256").stdout.strip(), digest) + self.assertEqual(self.definition["schemaVersion"], 3) + self.assertEqual(self.definition["sourceTuple"], "dory-dual-metal-20260826") + self.assertEqual( + set(self.definition["sources"]), + {"angle", "libepoxy", "mesa", "moltenVK", "virglrenderer"}, + ) + profiles = self.definition["artifactProfiles"] + self.assertEqual( + set(profiles), + { + "rendererBundle", + "rendererQualificationEvidence", + "rendererReleaseQualificationEvidence", + "staticDependencies", + "staticLinkClosure", + }, + ) + self.assertEqual( + profiles["rendererBundle"], + { + "angleMetal": [ + "XPCServices/DoryRendererWorker.xpc/Contents/Frameworks/libEGL.dylib", + "XPCServices/DoryRendererWorker.xpc/Contents/Frameworks/libGLESv2.dylib", + ], + "rendererWorker": [ + "XPCServices/DoryRendererWorker.xpc/Contents/MacOS/DoryRendererWorker" + ], + }, + ) + for component in profiles["rendererBundle"].values(): + self.assertNotIn( + "Resources/renderer-bootstrap-qualification.json", component + ) + self.assertEqual( + profiles["rendererQualificationEvidence"], + {"qualification": ["Resources/renderer-bootstrap-qualification.json"]}, + ) + self.assertEqual( + profiles["rendererReleaseQualificationEvidence"], + { + "qualification": ["Resources/renderer-bootstrap-qualification.json"], + "releaseSignature": [ + "Resources/renderer-bootstrap-qualification.json.sig" + ], + }, + ) + policy = self.definition["virglBuildPolicy"] + self.assertEqual(policy["classicRenderer"], "virgl2-angle-metal") + self.assertEqual(policy["platforms"], ["egl"]) + self.assertEqual(policy["requiredCapsets"], [2, 4]) + self.assertFalse(policy["venusOnly"]) + self.assertTrue(policy["venus"]) + self.assertFalse(policy["vulkanDynamicLoad"]) + + def test_old_schema_and_venus_only_policy_fail_closed(self) -> None: + mutated = json.loads(json.dumps(self.definition)) + mutated["schemaVersion"] = 2 + mutated["virglBuildPolicy"]["venusOnly"] = True + with tempfile.TemporaryDirectory() as temporary: + path = pathlib.Path(temporary) / "old.json" + path.write_text(json.dumps(mutated), encoding="utf-8") + result = tuple_command( + "verify-definition", "--repo-root", REPO, + definition=path, ok=False, + ) + self.assertIn("schema is unsupported", result.stdout) + + def test_every_profile_round_trips_and_tampering_is_rejected(self) -> None: + profiles = self.definition["artifactProfiles"] + for profile, components in profiles.items(): + with self.subTest(profile=profile), tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) / "root" + root.mkdir() + for paths in components.values(): + for relative in paths: + artifact = root.joinpath(*pathlib.PurePosixPath(relative).parts) + artifact.parent.mkdir(parents=True, exist_ok=True) + artifact.write_bytes(f"{profile}:{relative}".encode()) + inventory = pathlib.Path(temporary) / "inventory.json" + tuple_command( + "create-inventory", "--profile", profile, "--root", root, + "--output", inventory, + ) + value = json.loads(inventory.read_text(encoding="utf-8")) + self.assertEqual(value["schemaVersion"], 3) + tuple_command( + "verify-inventory", "--profile", profile, "--root", root, + "--inventory", inventory, + ) + first = next(iter(next(iter(components.values())))) + root.joinpath(*pathlib.PurePosixPath(first).parts).write_bytes(b"tampered") + result = tuple_command( + "verify-inventory", "--profile", profile, "--root", root, + "--inventory", inventory, ok=False, + ) + self.assertIn("bytes differ", result.stdout) + + def test_inventory_rejects_symlink_artifact(self) -> None: + components = self.definition["artifactProfiles"]["staticDependencies"] + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + target = root / "target" + target.write_bytes(b"archive") + symlinked = False + for paths in components.values(): + for relative in paths: + artifact = root.joinpath(*pathlib.PurePosixPath(relative).parts) + artifact.parent.mkdir(parents=True, exist_ok=True) + if not symlinked: + artifact.symlink_to(target) + symlinked = True + else: + artifact.write_bytes(relative.encode()) + result = tuple_command( + "create-inventory", "--profile", "staticDependencies", + "--root", root, "--output", root / "inventory.json", ok=False, + ) + self.assertIn("non-symlink regular file", result.stdout) + + def test_reviewed_meson_graph_requires_classic_and_venus(self) -> None: + options = { + "buildtype": "release", "check-gl-errors": False, + "default_library": "static", "drm-renderers": [], "fuzzer": False, + "minigbm_allocation": False, "neptune": False, "platforms": ["egl"], + "render-server-mode": "thread", "render-server-worker": "thread", + "tests": False, "unstable-apis": True, "venus": True, + "venus-only": False, "video": False, "vtest": False, + "vulkan-dload": False, "vulkan-preload": False, + } + classic = [ + "src/vrend/vrend_renderer.c", + "src/vrend/vrend_winsys.c", + "src/vrend/vrend_winsys_egl.c", + ] + venus = ["src/virglrenderer.c", "src/venus/vkr_renderer.c"] + transport = [ + "server/render_client.c", "server/render_common.c", + "server/render_context.c", "server/render_server.c", + "server/render_socket.c", "server/render_state.c", + "server/render_worker.c", "src/proxy/proxy_client.c", + "src/proxy/proxy_common.c", "src/proxy/proxy_context.c", + "src/proxy/proxy_renderer.c", "src/proxy/proxy_server.c", + "src/proxy/proxy_socket.c", + ] + with tempfile.TemporaryDirectory() as temporary: + build = pathlib.Path(temporary) + (build / "meson-info").mkdir() + (build / "meson-logs").mkdir() + (build / "meson-info/intro-buildoptions.json").write_text( + json.dumps([{"name": name, "value": value} for name, value in options.items()]), + encoding="utf-8", + ) + (build / "config.h").write_text( + "\n".join( + f"#define {macro} 1" for macro in ( + "ENABLE_RENDER_SERVER", "ENABLE_RENDER_SERVER_WORKER_THREAD", + "ENABLE_SAME_PROCESS_RENDER_SERVER", "ENABLE_VENUS", + "HAVE_EPOXY_EGL_H", + ) + ), + encoding="utf-8", + ) + (build / "meson-logs/meson-log.txt").write_text( + "Run-time dependency epoxy found: YES 1.5.11\n" + "Run-time dependency vulkan found: YES 1.4.0\n", + encoding="utf-8", + ) + commands = build / "compile_commands.json" + graph = [ + {"file": f"../virglrenderer/{source}"} + for source in classic + venus + transport + ] + commands.write_text(json.dumps(graph), encoding="utf-8") + tuple_command("verify-meson", "--build-dir", build) + commands.write_text( + json.dumps([ + entry for entry in graph + if not entry["file"].endswith(classic[0]) + ]), + encoding="utf-8", + ) + result = tuple_command("verify-meson", "--build-dir", build, ok=False) + self.assertIn("missing required classic VirGL2/ANGLE source", result.stdout) + commands.write_text( + json.dumps( + graph + [{"file": "../virglrenderer/src/vtest/vtest_renderer.c"}] + ), + encoding="utf-8", + ) + result = tuple_command("verify-meson", "--build-dir", build, ok=False) + self.assertIn("forbidden source fragment", result.stdout) + + def test_build_packaging_and_qualification_consumers_are_dual_and_closed(self) -> None: + dependencies = ( + REPO / "scripts/build-renderer-production-dependencies.sh" + ).read_text() + virgl = (REPO / "scripts/build-virglrenderer.sh").read_text() + package = PACKAGE.read_text() + assembler = ASSEMBLER.read_text() + xcode = (REPO / "scripts/xcode-package-renderer-production.sh").read_text() + abi = (REPO / "scripts/verify-virgl-resource-info-abi.c").read_text() + + for authority in ( + 'install_name_tool -id "@loader_path/$angle_name"', + "libEGL.dylib", "libGLESv2.dylib", "lib/libepoxy.a", + "cmd LC_RPATH", "@loader_path/../Frameworks/libEGL.dylib", + "@loader_path/../Frameworks/libGLESv2.dylib", + ): + self.assertIn(authority, dependencies) + self.assertIn("Source/ThirdParty/ANGLE", dependencies) + self.assertIn("verify_angle_runtime_library", dependencies) + self.assertIn("MVK_HIDE_VULKAN_SYMBOLS=1", dependencies) + + self.assertIn("-Dplatforms=egl", virgl) + self.assertIn("-Dvenus-only=false", virgl) + self.assertNotIn("-Dvenus-only=true", virgl) + self.assertIn("-DDORY_VIRGL_RENDERER_STATIC_LINKED", virgl) + self.assertIn("-DDORY_VIRGL_RENDERER_DUAL_METAL", virgl) + self.assertEqual(virgl.count("-Wl,-force_load"), 3) + self.assertIn('"requiredVirGLCapsets": [2, 4]', virgl) + + self.assertIn("-Xcc -DDORY_VIRGL_RENDERER_STATIC_LINKED", assembler) + self.assertIn("-Xcc -DDORY_VIRGL_RENDERER_DUAL_METAL", assembler) + self.assertEqual(assembler.count("-Xlinker -force_load"), 3) + self.assertLess( + assembler.index( + "for angle_name in libEGL.dylib libGLESv2.dylib; do\n" + " /usr/bin/codesign" + ), + assembler.index('"${CODESIGN_ARGUMENTS[@]}" "$WORKER_BUNDLE"'), + ) + self.assertIn("verify_angle_runtime_closure", package) + self.assertIn("if macho_rpaths(library)", package) + self.assertIn("non-system/non-sibling dependency", package) + self.assertIn("DORY_VIRGL_RENDERER_DUAL_METAL", package) + self.assertIn("rendererReleaseQualificationEvidence", package) + self.assertIn("--require-release-signature", package) + self.assertIn("verify-renderer-bootstrap-qualification.py", package) + self.assertIn("--allow-unsealed-staging", package) + self.assertIn("renderer-virgl-metal-shared-texture-probe.m", virgl) + shareable_scanout_patch = ( + REPO / "patches/virglrenderer-metal-shareable-scanout.patch" + ).read_text() + self.assertIn("newSharedTextureWithDescriptor", shareable_scanout_patch) + self.assertIn("desc->bind & VIRGL_RES_BIND_SCANOUT", shareable_scanout_patch) + self.assertNotIn("desc->bind & PIPE_BIND_SCANOUT", shareable_scanout_patch) + shared_texture_probe = ( + REPO / "scripts/renderer-virgl-metal-shared-texture-probe.m" + ).read_text() + self.assertIn(".bind = VIRGL_RES_BIND_RENDER_TARGET", shared_texture_probe) + self.assertIn("VIRGL_RES_BIND_SCANOUT != PIPE_BIND_SCANOUT", shared_texture_probe) + venus_transport_patch = ( + REPO / "patches/virglrenderer-venus-only-static.patch" + ).read_text() + self.assertIn( + "+ uint32_t proxy_flags = flags | VIRGL_RENDERER_NO_VIRGL;", + venus_transport_patch, + ) + self.assertIn( + '+ if (!getenv("VIRGL_DISABLE_MT"))', + venus_transport_patch, + ) + self.assertIn( + "+ proxy_flags |= VIRGL_RENDERER_THREAD_SYNC;", + venus_transport_patch, + ) + self.assertIn( + "+ ret = proxy_renderer_init(&proxy_cbs, proxy_flags);", + venus_transport_patch, + ) + self.assertIn( + "+#if defined(ENABLE_VENUS_ONLY) && defined(__APPLE__)", + venus_transport_patch, + ) + self.assertNotIn( + "+#if defined(__APPLE__)\n+ has_thread_sync_notification = true;", + venus_transport_patch, + ) + self.assertIn( + "+ if (!has_thread_sync_notification || getenv(\"VIRGL_DISABLE_MT\"))\n" + " flags &= ~VIRGL_RENDERER_THREAD_SYNC;", + venus_transport_patch, + ) + self.assertNotIn("+ flags |= VIRGL_RENDERER_THREAD_SYNC;", venus_transport_patch) + + order = [ + '"$ROOT/scripts/assemble-renderer-production-worker.sh"', + 'python3 "$ROOT/scripts/package-renderer-production-bundle.py" package', + "/usr/bin/codesign \\", + '"$RUNNER_APP/Contents/MacOS/dory-hv" renderer-qualify', + 'install -m0644 "$STAGED_RECEIPT"', + 'python3 "$ROOT/scripts/package-renderer-production-bundle.py" seal-evidence', + ] + positions = [xcode.index(fragment) for fragment in order] + self.assertEqual(positions, sorted(positions)) + self.assertIn("DORY_RENDERER_MANAGED_KERNEL_SHA256", xcode) + self.assertIn("DORY_RENDERER_MANAGED_KERNEL", xcode) + self.assertIn("DORY_RENDERER_QUALIFICATION_MODE", xcode) + self.assertIn("--require-release-signature", xcode) + + for constant in ( + "DORY_VIRGL_RENDERER_RESOURCE_BIND_RENDER_TARGET", + "DORY_VIRGL_RENDERER_RESOURCE_BIND_SAMPLER_VIEW", + "DORY_VIRGL_RENDERER_RESOURCE_BIND_SCANOUT", + ): + self.assertIn(constant, abi) + + def test_cdhash_shape_is_strict(self) -> None: + namespace = runpy.run_path(str(PACKAGE)) + validate_cdhash = namespace["code_directory_hash"] + error = namespace["PackagingError"] + self.assertEqual(validate_cdhash("a" * 40, "fixture"), "a" * 40) + for invalid in ("a" * 39, "a" * 41, "0" * 40, "g" * 40): + with self.subTest(invalid=invalid), self.assertRaises(error): + validate_cdhash(invalid, "fixture") + + def test_qualification_codesign_requirement_is_an_expression(self) -> None: + namespace = runpy.run_path(str(QUALIFICATION_VERIFIER)) + completed = subprocess.CompletedProcess([], 0, "", "") + with mock.patch.object(namespace["subprocess"], "run", return_value=completed) as run_mock: + namespace["verify_code_identity"]( + pathlib.Path("/tmp/DoryHVRunner.app"), + 'anchor apple generic and identifier "com.pythonxi.Dory.HVRunner"', + check_nested=True, + ) + command = run_mock.call_args.args[0] + requirement_index = command.index("-R") + 1 + self.assertEqual( + command[requirement_index], + '=anchor apple generic and identifier "com.pythonxi.Dory.HVRunner"', + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/.github/scripts/test-renderer-release-identity.py b/.github/scripts/test-renderer-release-identity.py new file mode 100644 index 00000000..7be925a6 --- /dev/null +++ b/.github/scripts/test-renderer-release-identity.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Focused tests for the directed renderer release-identity packaging chain.""" + +from __future__ import annotations + +import argparse +import copy +import importlib.util +import pathlib +import plistlib +import shutil +import subprocess +import sys +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +HELPER = ROOT / "scripts" / "renderer-release-identity.py" +BUNDLE_ENGINE = ROOT / "scripts" / "bundle-engine.sh" +RELEASE = ROOT / "scripts" / "release.sh" +RELEASE_WORKFLOW = ROOT / ".github" / "workflows" / "release.yml" + + +def load_helper(): + spec = importlib.util.spec_from_file_location("renderer_release_identity", HELPER) + if spec is None or spec.loader is None: + raise RuntimeError("could not load renderer release identity helper") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +identity = load_helper() + + +def production_details( + *, + identifier: str = identity.RUNNER_IDENTIFIER, + team: str = identity.PRODUCTION_TEAM_IDENTIFIER, + cdhash: str = "a1" * 20, +) -> str: + return "\n".join(( + "Executable=/fixture/DoryHVRunner.app/Contents/MacOS/dory-hv", + f"Identifier={identifier}", + "Format=app bundle with Mach-O thin (arm64)", + "CodeDirectory v=20500 size=1234 flags=0x10000(runtime) hashes=30+7 location=embedded", + "Signature size=9050", + "Authority=Developer ID Application: Dory Fixture (864H636QW4)", + "Authority=Developer ID Certification Authority", + "Authority=Apple Root CA", + f"TeamIdentifier={team}", + "Runtime Version=26.0.0", + "Timestamp=Aug 23, 2026 at 10:00:00 PM", + f"CDHash={cdhash}", + "", + )) + + +class RendererReleaseIdentityTests(unittest.TestCase): + def test_canonical_entitlement_has_exact_shape_and_types(self) -> None: + expected = identity.release_identity_entitlements( + runner_cdhash="a1" * 20, + worker_cdhash="ab" * 20, + tuple_digest="cd" * 32, + ) + self.assertEqual(set(expected), {identity.ENTITLEMENT_NAME}) + nested = expected[identity.ENTITLEMENT_NAME] + self.assertEqual(set(nested), identity.IDENTITY_KEYS) + self.assertIs(type(nested["schema-version"]), int) + for field in ( + "runner-cdhash", + "renderer-worker-cdhash", + "tuple-definition-sha256", + ): + self.assertIs(type(nested[field]), str) + + raw = identity.canonical_entitlement_bytes(expected) + self.assertEqual(raw, identity.canonical_entitlement_bytes(expected)) + self.assertEqual(plistlib.loads(raw), expected) + with tempfile.TemporaryDirectory() as temporary: + output = pathlib.Path(temporary) / "doryd.entitlements" + identity.write_entitlements(output, expected) + self.assertEqual(output.read_bytes(), raw) + self.assertEqual(output.stat().st_mode & 0o777, 0o600) + + def test_entitlement_rejects_extra_missing_and_mistyped_fields(self) -> None: + expected = identity.release_identity_entitlements( + runner_cdhash="a1" * 20, + worker_cdhash="ab" * 20, + tuple_digest="cd" * 32, + ) + fixtures: list[dict[str, object]] = [] + + extra_top = copy.deepcopy(expected) + extra_top["unreviewed"] = True + fixtures.append(extra_top) + + extra_nested = copy.deepcopy(expected) + extra_nested[identity.ENTITLEMENT_NAME]["unreviewed"] = "value" + fixtures.append(extra_nested) + + missing = copy.deepcopy(expected) + del missing[identity.ENTITLEMENT_NAME]["runner-cdhash"] + fixtures.append(missing) + + for wrong in (True, 1.0, "1"): + mistyped = copy.deepcopy(expected) + mistyped[identity.ENTITLEMENT_NAME]["schema-version"] = wrong + fixtures.append(mistyped) + + mistyped_hash = copy.deepcopy(expected) + mistyped_hash[identity.ENTITLEMENT_NAME]["runner-cdhash"] = b"a1" * 20 + fixtures.append(mistyped_hash) + + for fixture in fixtures: + with self.subTest(fixture=fixture): + with self.assertRaises(identity.ReleaseIdentityError): + identity.validate_release_identity_entitlements(fixture, expected) + + def test_signature_parser_requires_exact_production_identity(self) -> None: + cdhash = identity.parse_production_signature_details( + production_details(), + label="runner", + expected_identifier=identity.RUNNER_IDENTIFIER, + expected_team=identity.PRODUCTION_TEAM_IDENTIFIER, + ) + self.assertEqual(cdhash, "a1" * 20) + + corruptions = { + "wrong identifier": production_details(identifier="unreviewed"), + "wrong team": production_details(team="ABCDEFGHIJ"), + "ad hoc": production_details().replace( + "Authority=Developer ID Application: Dory Fixture (864H636QW4)\n", + "Signature=adhoc\n", + ), + "no hardened runtime": production_details().replace("(runtime)", "(none)"), + "no timestamp": production_details().replace( + "Timestamp=Aug 23, 2026 at 10:00:00 PM\n", "" + ), + "short hash": production_details(cdhash="a1" * 19), + "uppercase hash": production_details(cdhash=("a1" * 20).upper()), + "zero hash": production_details(cdhash="0" * 40), + "two hashes": production_details() + f"CDHash={'ab' * 20}\n", + } + for label, details in corruptions.items(): + with self.subTest(label=label): + with self.assertRaises(identity.ReleaseIdentityError): + identity.parse_production_signature_details( + details, + label="runner", + expected_identifier=identity.RUNNER_IDENTIFIER, + expected_team=identity.PRODUCTION_TEAM_IDENTIFIER, + ) + + def test_tuple_digest_must_come_back_as_one_exact_verifier_value(self) -> None: + digest = "cd" * 32 + self.assertEqual( + identity.parse_tuple_definition_digest(f"definition.sha256={digest}\n"), + digest, + ) + for output in ( + "", + f"definition.sha256={digest.upper()}\n", + f"definition.sha256={digest}\ndefinition.sha256={digest}\n", + f"definition.sha256={'0' * 64}\n", + ): + with self.subTest(output=output): + with self.assertRaises(identity.ReleaseIdentityError): + identity.parse_tuple_definition_digest(output) + + def test_helper_reads_the_live_digest_through_the_tuple_verifier(self) -> None: + expected = subprocess.run( + [ + sys.executable, + str(ROOT / "scripts" / "renderer-production-tuple.py"), + "verify-definition", + "--repo-root", + str(ROOT), + ], + check=True, + stdout=subprocess.PIPE, + text=True, + ).stdout + self.assertEqual( + identity.tuple_definition_digest(ROOT), + identity.parse_tuple_definition_digest(expected), + ) + + def test_bundle_and_release_order_are_fail_closed(self) -> None: + subprocess.run(["bash", "-n", str(BUNDLE_ENGINE)], check=True) + subprocess.run(["bash", "-n", str(RELEASE)], check=True) + bundle = BUNDLE_ENGINE.read_text(encoding="utf-8") + release = RELEASE.read_text(encoding="utf-8") + workflow = RELEASE_WORKFLOW.read_text(encoding="utf-8") + execution = bundle.split("\nbundle_doryd_helpers\n", 1)[1] + self.assertLess( + execution.index("bundle_venus_renderer"), + execution.index("finalize_doryd_signature"), + ) + self.assertLess( + execution.index("finalize_doryd_signature"), + execution.index("PAYLOAD_DIGESTS="), + ) + doryd_build = bundle.split("bundle_doryd_helpers() {", 1)[1].split( + "\ninject_debug_toolbox_into_initfs()", 1 + )[0] + self.assertIn("assemble_doryd_for_release_identity", doryd_build) + self.assertNotIn( + 'bundle_swiftpm_executable "dory-core-swift" "$configuration" "doryd"', + doryd_build, + ) + self.assertIn("verify-absent", bundle) + self.assertIn("RawHV hardware 3D fails closed", bundle) + production_signer = bundle.split( + "codesign_production_release_identity() {", 1 + )[1].split("\nfinalize_doryd_signature()", 1)[0] + self.assertIn("/usr/bin/codesign", production_signer) + self.assertIn("--identifier doryd", production_signer) + self.assertNotIn("DORY_ALLOW_ADHOC_SIGN", production_signer) + self.assertNotIn("--sign -", production_signer) + self.assertIn("renderer-release-identity.py\" verify", release) + self.assertIn( + "public releases require the production doryd renderer release identity", + release, + ) + self.assertIn("scripts/renderer-release-identity.py verify", workflow) + self.assertIn( + '--doryd "$extracted/Dory.app/Contents/Helpers/doryd"', workflow + ) + self.assertIn("dual VirGL2 + Venus renderer", bundle) + self.assertIn("dual VirGL2 + Venus renderer", release) + for stale in ("libvirglrenderer.dylib", "libMoltenVK.dylib"): + self.assertNotIn(stale, bundle) + self.assertNotIn(stale, release) + + def test_cli_rejects_nonproduction_team_before_reading_artifacts(self) -> None: + result = subprocess.run( + [ + sys.executable, + str(HELPER), + "create-entitlements", + "--runner-app", + "/nonexistent/DoryHVRunner.app", + "--output", + "/nonexistent/doryd.entitlements", + "--expected-team", + "ABCDEFGHIJ", + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("can only bind Dory production team 864H636QW4", result.stdout) + + @unittest.skipUnless(sys.platform == "darwin", "codesign fixture requires macOS") + def test_ad_hoc_doryd_has_no_fabricated_release_identity(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + doryd = pathlib.Path(temporary) / "doryd" + shutil.copyfile("/usr/bin/true", doryd) + doryd.chmod(0o755) + subprocess.run( + ["/usr/bin/codesign", "--force", "--options", "runtime", "--sign", "-", str(doryd)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + identity.verify_absent(argparse.Namespace(doryd=doryd)) + + @unittest.skipUnless(sys.platform == "darwin", "codesign fixture requires macOS") + def test_codesign_preserves_exact_custom_entitlement_shape(self) -> None: + expected = identity.release_identity_entitlements( + runner_cdhash="a1" * 20, + worker_cdhash="ab" * 20, + tuple_digest="cd" * 32, + ) + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + doryd = root / "doryd" + entitlements = root / "doryd.entitlements" + shutil.copyfile("/usr/bin/true", doryd) + doryd.chmod(0o755) + identity.write_entitlements(entitlements, expected) + subprocess.run( + [ + "/usr/bin/codesign", + "--force", + "--options", + "runtime", + "--entitlements", + str(entitlements), + "--sign", + "-", + str(doryd), + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + actual = identity.read_signed_entitlements(doryd, "fixture doryd") + identity.validate_release_identity_entitlements(actual, expected) + with self.assertRaises(identity.ReleaseIdentityError): + identity.verify_absent(argparse.Namespace(doryd=doryd)) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-sandbox-security-gate.py b/.github/scripts/test-sandbox-security-gate.py new file mode 100644 index 00000000..23eb1fed --- /dev/null +++ b/.github/scripts/test-sandbox-security-gate.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the physical sandbox security gate.""" + +from __future__ import annotations + +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "sandbox-security-gate.sh" + + +class SandboxSecurityGateTests(unittest.TestCase): + def test_security_contract_is_complete_and_assert_free(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "test \"$(id -u)\" -ne 0", + "! touch /input/write-must-fail", + 'test -z "${SSH_AUTH_SOCK:-}"', + "secretEnvironmentNames", + "secret in manifest_text", + "169.254.169.254", + "network none", + "network outbound", + "--rollback", + "--ttl-seconds 2", + "daemon_ttl=PASS", + "egressFilterEnforced", + ): + self.assertIn(proof, text, proof) + self.assertNotIn("assert ", text) + + def test_invalid_network_authority_fails_before_sandbox_creation(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + dory = root / "dory" + dory.write_text("#!/bin/sh\nexit 99\n", encoding="utf-8") + dory.chmod(0o700) + result = subprocess.run( + [ + str(GATE), + "--dory", + str(dory), + "--workroot", + str(root / "evidence"), + "--allowed-network", + "not a host:70000", + ], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("allowed network port is outside", result.stderr) + self.assertFalse((root / "evidence").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-security-contracts.sh b/.github/scripts/test-security-contracts.sh index 4ace321e..c1f2a983 100644 --- a/.github/scripts/test-security-contracts.sh +++ b/.github/scripts/test-security-contracts.sh @@ -43,8 +43,23 @@ grep -F 'DorydXPCSecurity.productionDaemonRequirement' \ dory-core-swift/Sources/dorydctl/main.swift >/dev/null \ || fail "production dorydctl does not pin doryd's signature" -grep -F 'static let attachSupported = false' Dory/Net/UsbAttachmentStore.swift >/dev/null \ - || fail "USB passthrough can be advertised before the guest RPC exists" +for usb_ui_contract in \ + 'static func attachSupported(for status: DorydMachineStatus?) -> Bool' \ + 'status.state == "running"' \ + 'status.runtimeIdentity.backend == "dory-hypervisor"' \ + 'status.runtimeIdentity.authorizesRemovableUSBHotplug'; do + grep -F "$usb_ui_contract" Dory/Net/UsbAttachmentStore.swift >/dev/null \ + || fail "USB passthrough UI lost fail-closed runtime contract: $usb_ui_contract" +done +grep -F 'public func machineUSBAttach(' \ + dory-core-swift/Sources/DorydKit/DorydService.swift >/dev/null \ + || fail "doryd lost the authenticated USB attach RPC" +grep -F 'public func attachResolvedUSBDevice(' \ + dory-core-swift/Sources/DorydKit/MachineManager.swift >/dev/null \ + || fail "machine manager lost resolved-plan USB authorization" +grep -F 'try await ensureSupported()' \ + Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlHandler.swift >/dev/null \ + || fail "host USB can be opened before the guest usb-vhci capability is proved" for kernel_contract in \ 'CONFIG_NETFILTER_XT_MATCH_OWNER=y' \ @@ -59,8 +74,13 @@ for agent_contract in DORY_AGENT_RUN_UID DORY_AGENT_MAX_PROCESSES DORY_AGENT_MAX done grep -F 'mode = "ro"' scripts/dory >/dev/null \ || fail "sandbox mounts no longer default read-only" -grep -F 'DORY_SANDBOX_EXPIRES_AT' \ - scripts/dory dory-core-swift/Sources/DorydKit/SandboxTTLReconciler.swift >/dev/null \ +grep -F -- '--sandbox-expires-at "$expires_epoch"' scripts/dory >/dev/null \ + || fail "sandbox CLI no longer sends its absolute expiry through the typed create contract" +grep -F 'let expiration = try takeOption("--sandbox-expires-at", from: &arguments)' \ + dory-core-swift/Sources/DorydKit/DoryMachineSandboxPolicyWriteAuthority.swift >/dev/null \ + || fail "doryd no longer parses sandbox expiry through the typed policy authority" +grep -F 'let expiration = policy.expiresAtUnixSeconds' \ + dory-core-swift/Sources/DorydKit/SandboxTTLReconciler.swift >/dev/null \ || fail "sandbox expiry is not persisted and daemon reconciled" grep -F 'sandboxSSHAgentDenied' dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift >/dev/null \ || fail "sandbox VMM does not fail closed for ambient SSH-agent forwarding" diff --git a/.github/scripts/test-source-preserving-lan-gate.py b/.github/scripts/test-source-preserving-lan-gate.py new file mode 100755 index 00000000..b36462c1 --- /dev/null +++ b/.github/scripts/test-source-preserving-lan-gate.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Non-mutating authority tests for physical source-preserving networking.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "source-preserving-lan-gate.sh" + + +class SourcePreservingLANGateTests(unittest.TestCase): + def invoke( + self, + app: pathlib.Path, + runtime: pathlib.Path, + docker: pathlib.Path, + workroot: pathlib.Path, + temporary_root: pathlib.Path, + *, + address: str = "192.0.2.10", + confirmation: str = "PHYSICAL-SOURCE-PRESERVATION", + ssh_options: tuple[str, ...] = ("StrictHostKeyChecking=yes",), + ) -> subprocess.CompletedProcess[str]: + command = [ + str(GATE), + "--app", str(app), + "--runtime", str(runtime), + "--docker", str(docker), + "--host-address", address, + "--peer-ssh", "release-peer@example.invalid", + "--mode", "lan", + "--server-image", "example.invalid/server@sha256:" + "a" * 64, + "--workroot", str(workroot), + ] + for option in ssh_options: + command.extend(("--ssh-option", option)) + command.extend(("--confirm", confirmation)) + environment = os.environ.copy() + environment["RUNNER_TEMP"] = str(temporary_root) + environment["PYTHONOPTIMIZE"] = "2" + return subprocess.run( + command, + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + @staticmethod + def fixture(temporary: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path, pathlib.Path]: + app = temporary / "Dory.app" + helpers = app / "Contents" / "Helpers" + helpers.mkdir(parents=True) + docker = helpers / "docker" + docker.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + docker.chmod(0o755) + runtime = temporary / "dory-engine-runtime" + runtime.mkdir() + launcher = runtime / "dory-engine" + launcher.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + launcher.chmod(0o755) + return app, runtime, docker + + def test_source_is_shell_valid_and_closes_privileged_helper_ownership(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], cwd=ROOT, check=True) + source = GATE.read_text(encoding="utf-8") + self.assertNotIn("assert ", source) + for contract in ( + "StrictHostKeyChecking=yes", + '"$DOCKER" = "$APP/Contents/Helpers/docker"', + "source=Notarized Developer ID", + "a pre-existing Dory network helper would be replaced", + "--unregister-network-helper", + "Dory network helper survived final cleanup", + "network_helper_unregistered=PASS", + "host_boot_session_unchanged=PASS", + ): + self.assertIn(contract, source) + + def test_confirmation_host_and_ssh_authorities_fail_before_mutation(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-source-lan-test.") as raw: + temporary = pathlib.Path(raw).resolve() + app, runtime, docker = self.fixture(temporary) + workroot = temporary / "dory-source-lan-lan" + confirmation = self.invoke( + app, runtime, docker, workroot, temporary, confirmation="wrong" + ) + loopback = self.invoke( + app, runtime, docker, workroot, temporary, address="127.0.0.1" + ) + missing_host_key = self.invoke( + app, runtime, docker, workroot, temporary, ssh_options=() + ) + disabled_host_key = self.invoke( + app, + runtime, + docker, + workroot, + temporary, + ssh_options=("StrictHostKeyChecking=no",), + ) + self.assertIn("confirmation token is required", confirmation.stdout) + self.assertIn("valid unicast IPv4", loopback.stdout) + self.assertIn("exactly one --ssh-option", missing_host_key.stdout) + self.assertIn("StrictHostKeyChecking must be yes", disabled_host_key.stdout) + + def test_indirect_app_and_non_candidate_docker_are_rejected(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-source-lan-test.") as raw: + temporary = pathlib.Path(raw).resolve() + app, runtime, docker = self.fixture(temporary) + app_link = temporary / "linked-Dory.app" + app_link.symlink_to(app, target_is_directory=True) + indirect = self.invoke( + app_link, + runtime, + docker, + temporary / "dory-source-lan-lan", + temporary, + ) + foreign = temporary / "foreign-docker" + foreign.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + foreign.chmod(0o755) + wrong_docker = self.invoke( + app, + runtime, + foreign, + temporary / "dory-source-lan-lan", + temporary, + ) + self.assertIn("candidate app must be a direct Dory.app directory", indirect.stdout) + self.assertIn("Docker CLI is not the exact candidate helper", wrong_docker.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-sparkle-install-relaunch-gate.py b/.github/scripts/test-sparkle-install-relaunch-gate.py new file mode 100755 index 00000000..64b79ff1 --- /dev/null +++ b/.github/scripts/test-sparkle-install-relaunch-gate.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Non-mutating arming tests for the exact-candidate Sparkle install gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "sparkle-install-relaunch-gate.sh" + + +class SparkleInstallRelaunchGateTests(unittest.TestCase): + def invoke( + self, + paths: dict[str, pathlib.Path], + temporary_root: pathlib.Path, + *, + confirmation: str = "CLEAN-RELEASE-USER-SPARKLE-INSTALL", + clean_user: bool = True, + build_only: bool = False, + ) -> subprocess.CompletedProcess[str]: + command = [ + str(GATE), + "--candidate-app", str(paths["app"]), + "--update-zip", str(paths["update"]), + "--appcast", str(paths["appcast"]), + "--release-manifest", str(paths["manifest"]), + "--sbom", str(paths["sbom"]), + "--sparkle-source", str(paths["sparkle"]), + "--version", "9.8.7", + "--build", "42", + "--source-commit", "a" * 40, + "--workroot", str(temporary_root / "dory-release-live-sparkle"), + ] + if build_only: + command.append("--build-only") + else: + command.extend(("--confirm", confirmation)) + environment = os.environ.copy() + environment["RUNNER_TEMP"] = str(temporary_root) + environment["PYTHONOPTIMIZE"] = "2" + if clean_user: + environment["DORY_RELEASE_CLEAN_USER"] = "1" + else: + environment.pop("DORY_RELEASE_CLEAN_USER", None) + return subprocess.run( + command, + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + @staticmethod + def fixture(temporary: pathlib.Path) -> dict[str, pathlib.Path]: + paths = { + "app": temporary / "Dory.app", + "update": temporary / "Dory-9.8.7-app-update.zip", + "appcast": temporary / "appcast.xml", + "manifest": temporary / "release-manifest.json", + "sbom": temporary / "Dory-9.8.7.cdx.json", + "sparkle": temporary / "Sparkle", + } + paths["app"].mkdir() + paths["sparkle"].mkdir() + for key in ("update", "appcast", "manifest", "sbom"): + paths[key].write_text("fixture\n", encoding="utf-8") + return paths + + def test_source_is_shell_valid_optimizer_safe_and_exactly_bound(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], cwd=ROOT, check=True) + source = GATE.read_text(encoding="utf-8") + self.assertNotIn("assert ", source) + for contract in ( + "scripts/validate-release-metadata.py", + "scripts/verify-sparkle-update.sh", + "scripts/verify-release-sbom.py", + "--untracked-files=all", + "source=Notarized Developer ID", + "candidate metadata source commit mismatch", + "run evidence authority already exists", + "atomic_install_swap=PASS", + "different_relaunch_pid=PASS", + "docker_context_removed=PASS", + "daemon_processes_stopped=PASS", + "initial_clean_user_state_restored=PASS", + ): + self.assertIn(contract, source) + + def test_live_execution_requires_confirmation_and_clean_release_user(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-sparkle-gate-test.") as raw: + temporary = pathlib.Path(raw).resolve() + paths = self.fixture(temporary) + confirmation = self.invoke(paths, temporary, confirmation="wrong") + clean_user = self.invoke(paths, temporary, clean_user=False) + self.assertIn("--confirm CLEAN-RELEASE-USER-SPARKLE-INSTALL", confirmation.stdout) + self.assertIn("DORY_RELEASE_CLEAN_USER=1", clean_user.stdout) + + def test_build_only_still_rejects_indirect_candidate_input(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-sparkle-gate-test.") as raw: + temporary = pathlib.Path(raw).resolve() + paths = self.fixture(temporary) + linked = temporary / "linked-Dory.app" + linked.symlink_to(paths["app"], target_is_directory=True) + paths["app"] = linked + result = self.invoke(paths, temporary, build_only=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("required path is unavailable or indirect", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-ssh-agent-forwarding-gate.py b/.github/scripts/test-ssh-agent-forwarding-gate.py new file mode 100644 index 00000000..910dfeb7 --- /dev/null +++ b/.github/scripts/test-ssh-agent-forwarding-gate.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the physical SSH-agent forwarding gate.""" + +from __future__ import annotations + +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "ssh-agent-forwarding-gate.sh" + + +class SSHAgentForwardingGateTests(unittest.TestCase): + def test_forwarding_contract_retains_only_public_listing_digests(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "SSH_AUTH_SOCK is not owned by the release user", + "Dory socket is not owned by the release user", + "/run/host-services/ssh-auth.sock:/agent.sock", + "ssh-add -L", + "--mount=type=ssh,required=true", + "--network=none", + 'single_hash" = "$host_hash', + 'buildkit_hash" = "$host_hash', + "rm -f \"$WORKDIR\"/client-*.out", + "public_key_listing_sha256", + "bundled_buildx=PASS", + ): + self.assertIn(proof, text, proof) + for leak in ("ssh-add -l", "ssh-add -D", "cat ~/.ssh", "assert "): + self.assertNotIn(leak, text, leak) + + def test_excessive_concurrency_fails_before_socket_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + result = subprocess.run( + [ + str(GATE), + "--socket", + str(pathlib.Path(temporary) / "missing.sock"), + "--docker", + "/missing/docker", + "--image", + "example.invalid/ssh@sha256:" + "a" * 64, + "--workroot", + str(pathlib.Path(temporary) / "evidence"), + "--concurrency", + "65", + ], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("concurrency must be between 1 and 64", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-supabase-compatibility-gate.py b/.github/scripts/test-supabase-compatibility-gate.py new file mode 100755 index 00000000..ab386677 --- /dev/null +++ b/.github/scripts/test-supabase-compatibility-gate.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Offline contracts for the exact Supabase CLI and service-image qualification gate.""" + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "supabase-compatibility-gate.sh" +INVENTORY = ROOT / ".github" / "fixtures" / "supabase-cli-2.109.1-images.json" + + +class SupabaseCompatibilityGateTests(unittest.TestCase): + def test_inventory_is_closed_and_digest_pinned(self) -> None: + document = json.loads(INVENTORY.read_text(encoding="utf-8")) + self.assertEqual( + set(document), {"schemaVersion", "cliVersion", "registry", "services"} + ) + self.assertEqual(document["schemaVersion"], 1) + self.assertEqual(document["cliVersion"], "2.109.1") + self.assertEqual(document["registry"], "public.ecr.aws") + services = document["services"] + self.assertEqual(len(services), 14) + self.assertEqual(sum(item["enabledByDefault"] for item in services), 13) + self.assertEqual(len({item["service"] for item in services}), 14) + self.assertEqual(len({item["runtime"] for item in services}), 14) + for service in services: + self.assertEqual( + set(service), + {"service", "source", "runtime", "digest", "enabledByDefault"}, + ) + self.assertRegex( + service["runtime"], + r"^public\.ecr\.aws/supabase/[A-Za-z0-9._-]+:[A-Za-z0-9._-]+$", + ) + self.assertRegex(service["digest"], r"^sha256:[0-9a-f]{64}$") + self.assertEqual( + {item["service"] for item in services if not item["enabledByDefault"]}, + {"pooler"}, + ) + + def test_gate_binds_cli_inventory_runtime_behavior_and_secret_cleanup(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-SUPABASE", + "socket is not owned by the release user", + "Docker CLI is unavailable or indirect", + "--inventory is required for a non-default Supabase version", + "Supabase image inventory has an unexpected top-level shape", + "Supabase default stack must contain exactly 13 enabled service images", + "Docker-Content-Digest", + "Supabase registry digest changed", + 'docker_e pull "$runtime@$digest"', + 'docker_e tag "$runtime@$digest" "$runtime"', + "verified Supabase archive did not contain direct CLI executables", + "full Supabase default stack did not create exactly 13 owned containers", + "Supabase service image is not bound to its approved digest", + "com.supabase.cli.project", + "Supabase REST round-trip returned unexpected rows", + "required Supabase default host port is already in use", + "Supabase port $port was widened to all host interfaces", + '> /dev/null 2> "$EVIDENCE/start.stderr"', + "secret_free_evidence=PASS", + "exact_service_image_inventory=PASS", + "owned_project_cleanup=PASS", + "exact_baseline_cleanup=PASS", + "supabase_binary_sha256=", + "image_inventory_sha256=", + "docker_cli_sha256=", + ): + self.assertIn(proof, text, proof) + for stale in ( + "ids=\"$(docker_e ps -aq)\"", + "docker_e volume ls -q);", + "assert ", + ): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_registry_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + str(GATE), + "--socket", str(pathlib.Path(temporary) / "missing.sock"), + "--docker", "/missing/docker", + "--inventory", str(INVENTORY), + "--workroot", str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-testcontainers-compatibility-gate.py b/.github/scripts/test-testcontainers-compatibility-gate.py new file mode 100755 index 00000000..b1ef426d --- /dev/null +++ b/.github/scripts/test-testcontainers-compatibility-gate.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the exact Testcontainers and Ryuk release gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "testcontainers-compatibility-gate.sh" + + +class TestcontainersCompatibilityGateTests(unittest.TestCase): + def test_contract_binds_package_images_ryuk_and_cleanup(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-TESTCONTAINERS", + "socket is not owned by the release user", + "Docker CLI is unavailable or indirect", + "workload and Ryuk images must be exact digest references", + "required offline workload image is missing", + "required offline Ryuk image is missing", + "dist.integrity", + "registry.npmjs.org", + "Testcontainers npm integrity differs from the pinned release value", + "--npm-integrity is required when --version differs", + "downloaded Testcontainers tarball failed its SHA-512 integrity check", + "installed Testcontainers version", + 'RYUK_CONTAINER_IMAGE="$RYUK_IMAGE"', + "TESTCONTAINERS_RYUK_DISABLED=false", + "TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock", + 'socketMount.Source !== "/var/run/docker.sock"', + 'withPullPolicy({ shouldPull: () => false })', + 'Wait.forHttp("/", 8080)', + "workload is not bound to the exact Ryuk session", + "dory.release.testcontainers.run", + "engine has pre-existing named volumes", + "engine has pre-existing custom networks", + "exact_baseline_cleanup=PASS", + "docker_cli_sha256=", + "node_sha256=", + "npm_sha256=", + "package_lock_sha256=", + ): + self.assertIn(proof, text, proof) + for stale in ("alpine:3.20", "npm install testcontainers@", "assert "): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_registry_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + str(GATE), + "--socket", str(pathlib.Path(temporary) / "missing.sock"), + "--docker", "/missing/docker", + "--image", "example.invalid/workload@sha256:" + "a" * 64, + "--ryuk-image", "example.invalid/ryuk@sha256:" + "b" * 64, + "--workroot", str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-tilt-compose-compatibility-gate.py b/.github/scripts/test-tilt-compose-compatibility-gate.py new file mode 100755 index 00000000..9c2131e7 --- /dev/null +++ b/.github/scripts/test-tilt-compose-compatibility-gate.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the exact Tilt and Docker Compose compatibility gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "tilt-compose-compatibility-gate.sh" + + +class TiltComposeCompatibilityGateTests(unittest.TestCase): + def test_contract_binds_tilt_candidate_compose_image_and_project(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], check=True) + text = GATE.read_text(encoding="utf-8") + for proof in ( + "ISOLATED-ENGINE-TILT", + "socket is not owned by the release user", + "$helper_name helper is unavailable or indirect", + "--image must be an exact digest reference", + "shasum -a 256 -c -", + "verified Tilt archive did not contain a direct executable", + "private Compose plugin differs from the candidate helper", + "Tilt would not discover the exact candidate Docker CLI", + "Tilt would not discover the exact candidate Compose helper", + "exact candidate Compose plugin is not loadable", + "required offline Tilt workload image is missing", + "pull_policy: never", + "com.docker.compose.project=$PROJECT_NAME", + "Tilt Compose service did not use the exact workload image", + "Tilt Compose workspace bind is not exact and writable", + "host_to_service_workspace=PASS", + "service_to_host_workspace=PASS", + "owned_project_cleanup=PASS", + "exact_baseline_cleanup=PASS", + "tilt_binary_sha256=", + "docker_cli_sha256=", + "compose_plugin_sha256=", + ): + self.assertIn(proof, text, proof) + for stale in ("image: alpine:", "docker_e pull", "ids=\"$(docker_e ps -aq)\"", "assert "): + self.assertNotIn(stale, text, stale) + + def test_confirmation_fails_before_socket_download_or_workroot_access(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workroot = pathlib.Path(temporary) / "must-not-exist" + result = subprocess.run( + [ + str(GATE), + "--socket", str(pathlib.Path(temporary) / "missing.sock"), + "--docker", "/missing/docker", + "--compose", "/missing/docker-compose", + "--image", "example.invalid/alpine@sha256:" + "a" * 64, + "--workroot", str(workroot), + ], + cwd=ROOT, + env={**os.environ, "HOME": temporary}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("requires --confirm", result.stderr) + self.assertFalse(workroot.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test-vz-native-ipv6-gate.py b/.github/scripts/test-vz-native-ipv6-gate.py new file mode 100755 index 00000000..b03f9524 --- /dev/null +++ b/.github/scripts/test-vz-native-ipv6-gate.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Non-mutating arming tests for the physical VZ native-IPv6 gate.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +GATE = ROOT / "scripts" / "vz-native-ipv6-gate.sh" + + +class VZNativeIPv6GateTests(unittest.TestCase): + @staticmethod + def fixture(temporary: pathlib.Path) -> dict[str, pathlib.Path]: + paths = { + name: temporary / name + for name in ("dory-vmm", "gvproxy", "kernel", "rootfs", "docker") + } + for name, path in paths.items(): + path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + if name in {"dory-vmm", "gvproxy", "docker"}: + path.chmod(0o755) + return paths + + @staticmethod + def invoke( + paths: dict[str, pathlib.Path], + temporary: pathlib.Path, + *, + workroot_name: str = "dory-vz-native-ipv6-evidence", + extra: tuple[str, ...] = (), + ) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment["RUNNER_TEMP"] = str(temporary) + environment["PYTHONOPTIMIZE"] = "2" + return subprocess.run( + [ + str(GATE), + "--dory-vmm", str(paths["dory-vmm"]), + "--gvproxy", str(paths["gvproxy"]), + "--kernel", str(paths["kernel"]), + "--rootfs", str(paths["rootfs"]), + "--docker", str(paths["docker"]), + "--workroot", str(temporary / workroot_name), + *extra, + ], + cwd=ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + def test_source_is_shell_valid_optimizer_safe_and_closes_authority(self) -> None: + subprocess.run(["bash", "-n", str(GATE)], cwd=ROOT, check=True) + source = GATE.read_text(encoding="utf-8") + self.assertNotIn("assert ", source) + for contract in ( + "physical Apple-silicon macOS is required", + "nested virtualization does not qualify", + "input has an indirect ancestor", + '"$VMM" = "$SOURCE_APP/Contents/Helpers/dory-vmm"', + "source=Notarized Developer ID", + "TeamIdentifier=864H636QW4", + "a pre-existing network helper would be replaced", + "pre-existing PF authority marker", + "pre-existing forwarding authority marker", + "--unregister-network-helper", + "network helper survived final cleanup", + "run authority already exists", + "source_network_helper_unregistered=PASS", + 'if [ "$SOURCE_ENABLED" != 1 ] || [ "$SOURCE_RESULT" != PASS ]', + "host_boot_session_unchanged=PASS", + "host_panic_report_absence=PASS", + ): + self.assertIn(contract, source) + + def test_indirect_input_fails_before_physical_host_probe(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-vz-ipv6-test.") as raw: + temporary = pathlib.Path(raw).resolve() + paths = self.fixture(temporary) + linked = temporary / "linked-dory-vmm" + linked.symlink_to(paths["dory-vmm"]) + paths["dory-vmm"] = linked + result = self.invoke(paths, temporary) + self.assertNotEqual(result.returncode, 0) + self.assertIn("input must be a direct file", result.stdout) + + def test_source_confirmation_and_workroot_authority_fail_before_mutation(self) -> None: + with tempfile.TemporaryDirectory(prefix="dory-vz-ipv6-test.") as raw: + temporary = pathlib.Path(raw).resolve() + paths = self.fixture(temporary) + confirmation = self.invoke( + paths, + temporary, + extra=("--app", str(temporary / "Dory.app"), "--source-confirm", "wrong"), + ) + workroot = self.invoke(paths, temporary, workroot_name="unscoped") + self.assertIn("exact confirmation token", confirmation.stdout) + self.assertIn("dedicated VZ gate name", workroot.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/verify-ed25519-signature.swift b/.github/scripts/verify-ed25519-signature.swift new file mode 100644 index 00000000..ece2a4d7 --- /dev/null +++ b/.github/scripts/verify-ed25519-signature.swift @@ -0,0 +1,46 @@ +#!/usr/bin/env swift + +import CryptoKit +import Foundation + +func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("Ed25519 verification error: \(message)\n".utf8)) + exit(2) +} + +let arguments = Array(CommandLine.arguments.dropFirst()) +guard arguments.count == 3 else { + fail("usage: verify-ed25519-signature.swift PUBLIC_KEY_BASE64 SIGNATURE_FILE MESSAGE_FILE") +} + +guard let publicKeyData = Data(base64Encoded: arguments[0]), publicKeyData.count == 32 else { + fail("public key must be one canonical 32-byte base64 value") +} + +let signatureURL = URL(fileURLWithPath: arguments[1]) +let messageURL = URL(fileURLWithPath: arguments[2]) +let signatureText: String +let message: Data +do { + signatureText = try String(contentsOf: signatureURL, encoding: .utf8) + message = try Data(contentsOf: messageURL, options: [.mappedIfSafe]) +} catch { + fail("input could not be read") +} + +let trimmedSignature = signatureText.trimmingCharacters(in: .whitespacesAndNewlines) +guard !trimmedSignature.isEmpty, + signatureText == trimmedSignature + "\n", + let signature = Data(base64Encoded: trimmedSignature), + signature.count == 64 else { + fail("signature must be one canonical 64-byte base64 line") +} + +do { + let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData) + guard publicKey.isValidSignature(signature, for: message) else { + fail("signature does not authenticate the message") + } +} catch { + fail("public key is invalid") +} diff --git a/.github/scripts/verify-pages-release-metadata.py b/.github/scripts/verify-pages-release-metadata.py new file mode 100644 index 00000000..75080d11 --- /dev/null +++ b/.github/scripts/verify-pages-release-metadata.py @@ -0,0 +1,615 @@ +#!/usr/bin/env python3 +"""Verify and transactionally preserve Dory's signed Pages release metadata.""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import hashlib +import json +import os +import pathlib +import re +import shutil +import stat +import subprocess +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET +from collections.abc import Callable + + +SPARKLE = "http://www.andymatuschak.org/xml-namespaces/sparkle" +DORY = "https://augani.github.io/dory/appcast" +PUBLIC_KEY_BASE64 = "AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4=" +CATALOG_NAMES = ("catalog.json", "catalog.json.sha256", "catalog.json.sig") +COMPONENT_IDS = { + "docker-core", + "kubernetes", + "linux-machines", + "linux-desktop", + "desktop-debian", + "desktop-ubuntu", + "desktop-kali", +} +SEMVER_PATTERN = re.compile( + r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" +) + + +def fail(message: str) -> None: + raise SystemExit(f"Pages release metadata error: {message}") + + +def require(condition: bool, message: str) -> None: + if not condition: + fail(message) + + +def direct_regular_file(path: pathlib.Path, maximum_bytes: int) -> bytes: + try: + info = path.lstat() + except OSError as error: + fail(f"could not inspect {path}: {error}") + require(stat.S_ISREG(info.st_mode), f"{path} is not a direct regular file") + require(0 < info.st_size <= maximum_bytes, f"{path} has an invalid size") + try: + return path.read_bytes() + except OSError as error: + fail(f"could not read {path}: {error}") + + +def semantic_version(value: object, label: str) -> tuple[int, int, int]: + match = SEMVER_PATTERN.fullmatch(value if isinstance(value, str) else "") + require(match is not None, f"{label} is not a canonical stable semantic version: {value!r}") + return tuple(int(part) for part in match.groups()) + + +def unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + value: dict[str, object] = {} + for key, item in pairs: + require(key not in value, f"catalog JSON repeats key {key!r}") + value[key] = item + return value + + +def sha256_file(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def appcast_current_identity( + payload: bytes, label: str +) -> tuple[tuple[int, int, int], int]: + require(0 < len(payload) <= 2 * 1024 * 1024, f"{label} has an invalid size") + try: + item = ET.fromstring(payload).find("./channel/item") + except ET.ParseError as error: + fail(f"{label} is invalid XML: {error}") + require(item is not None, f"{label} has no current release item") + version = item.findtext(f"{{{SPARKLE}}}shortVersionString") + build = item.findtext(f"{{{SPARKLE}}}version") + version_value = semantic_version(version, f"{label} current version") + require( + build is not None and re.fullmatch(r"[1-9][0-9]*", build) is not None, + f"{label} has an invalid current build", + ) + return version_value, int(build) + + +class GitHubReleaseAuthority: + """Binds appcast bytes and their Sparkle signature to exact GitHub release assets.""" + + def __init__(self, repository: str = "Augani/dory") -> None: + self.repository = repository + self.token = os.environ.get("GH_TOKEN", "") + self.cache: dict[str, dict[str, object]] = {} + self.appcast_cache: dict[str, bytes] = {} + self.ledger: tuple[tuple[int, int, int], int] | None = None + cache_value = os.environ.get("DORY_SPARKLE_AUTHORITY_CACHE", "") + if cache_value: + self.cache_root = pathlib.Path(cache_value) + self.cache_root.mkdir(parents=True, exist_ok=True) + require( + self.cache_root.is_dir() and not self.cache_root.is_symlink(), + f"Sparkle authority cache is indirect: {self.cache_root}", + ) + else: + self.cache_root = pathlib.Path(tempfile.mkdtemp(prefix="dory-sparkle-authority-")) + + def request(self, url: str, *, api: bool, destination: pathlib.Path | None = None) -> bytes: + headers = {"User-Agent": "Dory-Pages-release-metadata-verifier"} + if api: + headers.update( + { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + ) + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + separator = "&" if "?" in url else "?" + request = urllib.request.Request( + f"{url}{separator}dory_metadata={time.time_ns()}", headers=headers + ) + try: + with urllib.request.urlopen(request, timeout=90) as response: + if destination is None: + return response.read() + with destination.open("wb") as output: + shutil.copyfileobj(response, output, length=1024 * 1024) + return b"" + except (OSError, urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as error: + fail(f"authoritative GitHub release request failed closed for {url}: {error}") + + def release(self, version: str) -> dict[str, object]: + if version in self.cache: + return self.cache[version] + encoded_tag = urllib.parse.quote(f"v{version}", safe="") + url = f"https://api.github.com/repos/{self.repository}/releases/tags/{encoded_tag}" + try: + release = json.loads(self.request(url, api=True).decode("utf-8")) + except (UnicodeError, json.JSONDecodeError) as error: + fail(f"GitHub release v{version} is invalid JSON: {error}") + require(isinstance(release, dict), f"GitHub release v{version} is not an object") + require(release.get("tag_name") == f"v{version}", f"GitHub release v{version} has the wrong tag") + require(release.get("draft") is False, f"GitHub release v{version} is a draft") + require(release.get("prerelease") is False, f"GitHub release v{version} is a prerelease") + assets = release.get("assets") + require( + isinstance(assets, list) and all(isinstance(asset, dict) for asset in assets), + f"GitHub release v{version} has an invalid asset list", + ) + self.cache[version] = release + return release + + @staticmethod + def exact_asset(release: dict[str, object], name: str, version: str) -> dict[str, object]: + assets = release.get("assets") + require(isinstance(assets, list), f"GitHub release v{version} has no asset list") + matches = [asset for asset in assets if isinstance(asset, dict) and asset.get("name") == name] + require(len(matches) == 1, f"GitHub release v{version} does not have exactly one {name}") + return matches[0] + + def small_asset_bytes( + self, asset: dict[str, object], version: str, name: str + ) -> bytes: + url = asset.get("browser_download_url") + require(isinstance(url, str), f"v{version} {name} has no download URL") + parsed = urllib.parse.urlsplit(url) + expected_path = f"/{self.repository}/releases/download/v{version}/{name}" + require( + parsed.scheme == "https" + and parsed.netloc == "github.com" + and parsed.path == expected_path + and not parsed.query + and not parsed.fragment + and parsed.username is None + and parsed.password is None, + f"v{version} {name} has a non-canonical download URL", + ) + payload = self.request(url, api=False) + size = asset.get("size") + require( + isinstance(size, int) and 0 < size <= 2 * 1024 * 1024, + f"v{version} {name} has an invalid size", + ) + require(size == len(payload), f"v{version} {name} size differs from GitHub") + digest = asset.get("digest") + actual = hashlib.sha256(payload).hexdigest() + require(digest == f"sha256:{actual}", f"v{version} {name} digest differs from GitHub") + return payload + + def stable_ledger(self) -> tuple[tuple[int, int, int], int]: + if self.ledger is not None: + return self.ledger + rows: list[dict[str, object]] = [] + page = 1 + while True: + url = ( + f"https://api.github.com/repos/{self.repository}/releases" + f"?per_page=100&page={page}" + ) + try: + payload = json.loads(self.request(url, api=True).decode("utf-8")) + except (UnicodeError, json.JSONDecodeError) as error: + fail(f"GitHub stable release page {page} is invalid JSON: {error}") + require( + isinstance(payload, list) and all(isinstance(row, dict) for row in payload), + f"GitHub stable release page {page} has an invalid shape", + ) + rows.extend(payload) + if len(payload) < 100: + break + page += 1 + require(page <= 100, "GitHub stable release pagination exceeded its safety bound") + + stable: list[tuple[tuple[int, int, int], str, dict[str, object]]] = [] + seen: set[tuple[int, int, int]] = set() + for release in rows: + draft = release.get("draft") + prerelease = release.get("prerelease") + require( + isinstance(draft, bool) and isinstance(prerelease, bool), + "GitHub release has ambiguous draft/prerelease state", + ) + if draft or prerelease: + continue + tag = release.get("tag_name") + if not isinstance(tag, str) or not tag.startswith("v"): + continue + match = SEMVER_PATTERN.fullmatch(tag[1:]) + if match is None: + continue + version_value = tuple(int(part) for part in match.groups()) + require(version_value not in seen, f"GitHub repeats stable release {tag}") + seen.add(version_value) + assets = release.get("assets") + require( + isinstance(assets, list) and all(isinstance(asset, dict) for asset in assets), + f"GitHub release {tag} has an invalid asset list", + ) + stable.append((version_value, tag[1:], release)) + self.cache[tag[1:]] = release + require(stable, "GitHub has no canonical stable release authority") + + counts: dict[tuple[int, int, int], int] = {} + for version_value, _, release in stable: + assets = release.get("assets") + require(isinstance(assets, list), "GitHub release lost its asset list") + counts[version_value] = sum( + isinstance(asset, dict) and asset.get("name") == "appcast.xml" + for asset in assets + ) + require(counts[version_value] <= 1, "stable release repeats appcast.xml") + appcast_versions = [version for version, count in counts.items() if count == 1] + require(appcast_versions, "no stable release has an authoritative appcast.xml") + floor = min(appcast_versions) + builds: list[int] = [] + for version_value, version, release in sorted(stable): + count = counts[version_value] + if version_value >= floor: + require(count == 1, f"stable release v{version} lacks one appcast.xml") + if count == 0: + continue + asset = self.exact_asset(release, "appcast.xml", version) + appcast = self.small_asset_bytes(asset, version, "appcast.xml") + self.appcast_cache[version] = appcast + appcast_version, appcast_build = appcast_current_identity( + appcast, f"v{version} appcast.xml" + ) + require(appcast_version == version_value, f"v{version} appcast disagrees with its tag") + builds.append(appcast_build) + self.ledger = (max(version for version, _, _ in stable), max(builds)) + return self.ledger + + def cached_update_asset(self, asset: dict[str, object], version: str) -> pathlib.Path: + size = asset.get("size") + digest_value = asset.get("digest") + require(isinstance(size, int) and 0 < size <= 4 * 1024 * 1024 * 1024, "update size is invalid") + require( + isinstance(digest_value, str) + and re.fullmatch(r"sha256:[0-9a-f]{64}", digest_value) is not None, + "update asset has no canonical GitHub SHA-256", + ) + expected_digest = digest_value.removeprefix("sha256:") + destination = self.cache_root / f"{expected_digest}.app-update.zip" + if destination.exists(): + require(destination.is_file() and not destination.is_symlink(), "cached update is indirect") + if destination.stat().st_size == size and sha256_file(destination) == expected_digest: + return destination + fail(f"cached update differs from GitHub authority: {destination}") + url = asset.get("browser_download_url") + require(isinstance(url, str), "update asset has no download URL") + temporary = self.cache_root / f".{expected_digest}.{os.getpid()}.partial" + require(not temporary.exists(), f"update download staging path already exists: {temporary}") + self.request(url, api=False, destination=temporary) + require(temporary.stat().st_size == size, "downloaded update size differs from GitHub") + require(sha256_file(temporary) == expected_digest, "downloaded update digest differs from GitHub") + os.replace(temporary, destination) + return destination + + def verify(self, version: str, appcast_bytes: bytes, signature: bytes, enclosure: ET.Element) -> None: + maximum_version, maximum_build = self.stable_ledger() + current_version, current_build = appcast_current_identity( + appcast_bytes, f"v{version} appcast.xml" + ) + require(current_version == maximum_version, f"v{version} is not the maximum stable release") + require(current_build == maximum_build, f"v{version} does not carry the maximum stable build") + release = self.release(version) + appcast_asset = self.exact_asset(release, "appcast.xml", version) + authoritative = self.appcast_cache.get(version) + if authoritative is None: + authoritative = self.small_asset_bytes(appcast_asset, version, "appcast.xml") + require(appcast_bytes == authoritative, f"appcast.xml differs from authoritative v{version} release asset") + + update_name = f"Dory-{version}-app-update.zip" + update_asset = self.exact_asset(release, update_name, version) + require(enclosure.get("length") == str(update_asset.get("size")), "Sparkle length differs from release asset") + update_path = self.cached_update_asset(update_asset, version) + public_key = base64.b64decode(PUBLIC_KEY_BASE64, validate=True) + with tempfile.TemporaryDirectory(prefix="dory-sparkle-ed25519-") as temporary_value: + temporary = pathlib.Path(temporary_value) + key_path = temporary / "public-key.der" + signature_path = temporary / "signature.raw" + key_path.write_bytes(bytes.fromhex("302a300506032b6570032100") + public_key) + signature_path.write_bytes(signature) + result = subprocess.run( + [ + "openssl", + "pkeyutl", + "-verify", + "-pubin", + "-inkey", + str(key_path), + "-keyform", + "DER", + "-rawin", + "-in", + str(update_path), + "-sigfile", + str(signature_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + require( + result.returncode == 0, + f"Sparkle Ed25519 signature does not authenticate {update_name}: {result.stdout.strip()}", + ) + + +Authority = Callable[[str, bytes, bytes, ET.Element], None] + + +def release_item(path: pathlib.Path, authority: Authority) -> tuple[int, str, int]: + raw = direct_regular_file(path, 2 * 1024 * 1024) + try: + item = ET.fromstring(raw).find("./channel/item") + except ET.ParseError as error: + fail(f"{path} is invalid XML: {error}") + require(item is not None, f"{path} has no release item") + build = item.findtext(f"{{{SPARKLE}}}version") + require( + build is not None and re.fullmatch(r"[1-9][0-9]*", build) is not None, + f"{path} has an invalid monotonic build", + ) + version = item.findtext(f"{{{SPARKLE}}}shortVersionString") + semantic_version(version, f"{path} release version") + require(isinstance(version, str), f"{path} has no release version") + require( + item.findtext(f"{{{SPARKLE}}}minimumSystemVersion") == "14.0", + f"{path} has an invalid macOS floor", + ) + for name in ("dataSchemaVersion", "minimumReadableDataSchema", "maximumReadableDataSchema"): + require(item.findtext(f"{{{DORY}}}{name}") == "1", f"{path} has an invalid Dory data schema contract") + schema = item.findtext(f"{{{DORY}}}componentCatalogSchema") + require(schema in {"1", "2"}, f"{path} has an invalid component catalog schema") + enclosures = item.findall("enclosure") + require(len(enclosures) == 1, f"{path} does not have one Sparkle enclosure") + enclosure = enclosures[0] + parsed = urllib.parse.urlsplit(enclosure.get("url", "")) + expected_path = f"/Augani/dory/releases/download/v{version}/Dory-{version}-app-update.zip" + require( + parsed.scheme == "https" + and parsed.netloc == "github.com" + and parsed.path == expected_path + and not parsed.query + and not parsed.fragment + and parsed.username is None + and parsed.password is None, + f"{path} has an invalid Sparkle release asset URL", + ) + length = enclosure.get("length") + require(length is not None and re.fullmatch(r"[1-9][0-9]*", length) is not None, f"{path} has an invalid Sparkle enclosure length") + require(enclosure.get("type") == "application/octet-stream", f"{path} has an invalid Sparkle enclosure type") + try: + signature = base64.b64decode(enclosure.get(f"{{{SPARKLE}}}edSignature", ""), validate=True) + except (ValueError, binascii.Error) as error: + fail(f"{path} has a malformed Sparkle signature: {error}") + require(len(signature) == 64, f"{path} has an invalid Sparkle signature") + authority(version, raw, signature, enclosure) + return int(build), version, int(schema) + + +def verify_catalog(root: pathlib.Path) -> tuple[str, int]: + catalog_dir = root / "components" / "arm64" + catalog_path = catalog_dir / "catalog.json" + digest_path = catalog_dir / "catalog.json.sha256" + signature_path = catalog_dir / "catalog.json.sig" + catalog_bytes = direct_regular_file(catalog_path, 4 * 1024 * 1024) + digest_bytes = direct_regular_file(digest_path, 65) + signature_bytes = direct_regular_file(signature_path, 128) + try: + digest_text = digest_bytes.decode("ascii") + except UnicodeError as error: + fail(f"{digest_path} is not ASCII: {error}") + require(re.fullmatch(r"[0-9a-f]{64}\n", digest_text) is not None, f"{digest_path} is not one canonical SHA-256 line") + require(hashlib.sha256(catalog_bytes).hexdigest() == digest_text.rstrip("\n"), f"{digest_path} does not authenticate catalog.json") + try: + signature_text = signature_bytes.decode("ascii") + signature = base64.b64decode(signature_text.rstrip("\n"), validate=True) + public_key = base64.b64decode(PUBLIC_KEY_BASE64, validate=True) + except (UnicodeError, ValueError, binascii.Error) as error: + fail(f"{signature_path} is malformed: {error}") + require(signature_text == signature_text.rstrip("\n") + "\n", f"{signature_path} is not canonical") + require(len(signature) == 64 and len(public_key) == 32, f"{signature_path} is not Ed25519") + with tempfile.TemporaryDirectory(prefix="dory-pages-ed25519-") as temporary_value: + temporary = pathlib.Path(temporary_value) + key_path = temporary / "public-key.der" + signature_raw_path = temporary / "signature.raw" + key_path.write_bytes(bytes.fromhex("302a300506032b6570032100") + public_key) + signature_raw_path.write_bytes(signature) + result = subprocess.run( + [ + "openssl", "pkeyutl", "-verify", "-pubin", "-inkey", str(key_path), + "-keyform", "DER", "-rawin", "-in", str(catalog_path), + "-sigfile", str(signature_raw_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + require(result.returncode == 0, f"{catalog_path} production signature is invalid: {result.stdout.strip()}") + try: + catalog = json.loads(catalog_bytes, object_pairs_hook=unique_object) + except (UnicodeError, json.JSONDecodeError) as error: + fail(f"{catalog_path} is invalid JSON: {error}") + canonical = (json.dumps(catalog, indent=2, sort_keys=True, ensure_ascii=False) + "\n").encode("utf-8") + require(canonical == catalog_bytes, f"{catalog_path} is not canonical JSON") + require(catalog.get("kind") == "dev.dory.component-catalog", f"{catalog_path} has the wrong kind") + schema = catalog.get("schemaVersion") + require(schema in {1, 2}, f"{catalog_path} has an unsupported schema") + require(catalog.get("architecture") == "arm64", f"{catalog_path} has the wrong architecture") + release_version = catalog.get("releaseVersion") + semantic_version(release_version, f"{catalog_path} releaseVersion") + semantic_version(catalog.get("minimumAppVersion"), f"{catalog_path} minimumAppVersion") + components = catalog.get("components") + require(isinstance(components, list) and len(components) == len(COMPONENT_IDS), f"{catalog_path} component list is invalid") + identifiers = [component.get("id") for component in components if isinstance(component, dict)] + require(len(identifiers) == len(components) and set(identifiers) == COMPONENT_IDS, f"{catalog_path} component identities are not exact") + require(len(identifiers) == len(set(identifiers)), f"{catalog_path} repeats a component") + asset_paths: set[str] = set() + for component in components: + require(isinstance(component, dict), f"{catalog_path} contains an invalid component") + identifier = component["id"] + dependencies = component.get("dependencies") + require(isinstance(dependencies, list) and len(dependencies) == len(set(dependencies)), f"{identifier} dependencies are invalid") + require(all(dependency in COMPONENT_IDS and dependency != identifier for dependency in dependencies), f"{identifier} has an invalid dependency") + assets = component.get("assets") + require(isinstance(assets, list), f"{identifier} assets are invalid") + for asset in assets: + require(isinstance(asset, dict), f"{identifier} has an invalid asset") + path = asset.get("path") + require( + isinstance(path, str) + and path + and "\\" not in path + and not pathlib.PurePosixPath(path).is_absolute() + and "." not in pathlib.PurePosixPath(path).parts + and ".." not in pathlib.PurePosixPath(path).parts, + f"{identifier} has an unsafe asset path", + ) + require(path not in asset_paths, f"catalog repeats installed asset path {path}") + asset_paths.add(path) + for field in ("sha256", "installedSHA256"): + require(re.fullmatch(r"[0-9a-f]{64}", asset.get(field, "")) is not None, f"{identifier}/{path} has an invalid {field}") + for field in ("downloadBytes", "installedBytes"): + require(isinstance(asset.get(field), int) and asset[field] > 0, f"{identifier}/{path} has invalid {field}") + url = asset.get("url") + parsed = urllib.parse.urlsplit(url or "") + expected_prefix = f"/Augani/dory/releases/download/v{release_version}/" + require( + parsed.scheme == "https" + and parsed.netloc == "github.com" + and parsed.path.startswith(expected_prefix) + and not parsed.query + and not parsed.fragment + and parsed.username is None + and parsed.password is None, + f"{identifier}/{path} has an invalid release asset URL", + ) + if identifier != "docker-core": + require(assets, f"{identifier} has no downloadable assets") + require(component.get("downloadBytes") == sum(asset["downloadBytes"] for asset in assets), f"{identifier} download total is invalid") + require(component.get("installedBytes") == sum(asset["installedBytes"] for asset in assets), f"{identifier} installed total is invalid") + visiting: set[str] = set() + visited: set[str] = set() + by_id = {component["id"]: component for component in components} + + def visit(identifier: str) -> None: + require(identifier not in visiting, f"catalog dependency cycle includes {identifier}") + if identifier in visited: + return + visiting.add(identifier) + for dependency in by_id[identifier]["dependencies"]: + visit(dependency) + visiting.remove(identifier) + visited.add(identifier) + + for identifier in identifiers: + visit(identifier) + if schema == 2: + qualification = catalog.get("virtualMachineQualification") + require(isinstance(qualification, dict), f"{catalog_path} lacks schema-2 qualification metadata") + require(qualification.get("component") == "linux-desktop", f"{catalog_path} qualification component is invalid") + require(qualification.get("path") == "virtual-machine-qualification.json", f"{catalog_path} qualification path is invalid") + require(isinstance(qualification.get("signingKeyID"), str) and qualification["signingKeyID"], f"{catalog_path} qualification key is missing") + require(isinstance(by_id["linux-desktop"].get("qualification"), list) and by_id["linux-desktop"]["qualification"], f"{catalog_path} has no qualified VM identities") + require(isinstance(release_version, str), f"{catalog_path} has no release version") + require(isinstance(schema, int), f"{catalog_path} has no numeric schema") + return release_version, schema + + +def verify_root(root: pathlib.Path, label: str, authority: Authority) -> tuple[tuple[int, str, int], tuple[str, int]]: + appcast = release_item(root / "appcast.xml", authority) + catalog = verify_catalog(root) + require(appcast[1] == catalog[0], f"{label} appcast and catalog versions differ") + require(appcast[2] == catalog[1], f"{label} appcast and catalog schemas differ") + return appcast, catalog + + +def transaction_files(root: pathlib.Path) -> tuple[bytes, ...]: + return ( + direct_regular_file(root / "appcast.xml", 2 * 1024 * 1024), + *(direct_regular_file(root / "components" / "arm64" / name, 4 * 1024 * 1024) for name in CATALOG_NAMES), + ) + + +def preserve_metadata(live_root: pathlib.Path, checked_root: pathlib.Path, authority: Authority) -> None: + live_appcast, live_catalog = verify_root(live_root, "live", authority) + checked_appcast, checked_catalog = verify_root(checked_root, "checked-in", authority) + live_semver = semantic_version(live_catalog[0], "live catalog release") + checked_semver = semantic_version(checked_catalog[0], "checked-in catalog release") + if live_appcast[0] > checked_appcast[0]: + require(live_semver > checked_semver, "newer live appcast does not carry a newer catalog release") + preserve_live = True + elif live_appcast[0] == checked_appcast[0]: + require(live_semver == checked_semver, "equal appcast builds disagree on catalog release") + require( + transaction_files(live_root) == transaction_files(checked_root), + "equal release identity has two different signed metadata transactions", + ) + preserve_live = True + else: + require(live_semver < checked_semver, "checked-in appcast build is newer but its catalog is not") + preserve_live = False + if preserve_live: + shutil.copyfile(live_root / "appcast.xml", checked_root / "appcast.xml") + for name in CATALOG_NAMES: + shutil.copyfile(live_root / "components" / "arm64" / name, checked_root / "components" / "arm64" / name) + selected_appcast, selected_catalog = verify_root(checked_root, "preserved live", authority) + print(f"Preserved live signed release metadata for {selected_catalog[0]} ({selected_appcast[0]}).") + else: + print(f"Checked-in release metadata {checked_catalog[0]} ({checked_appcast[0]}) is newer; retaining it.") + + +def main() -> None: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="mode", required=True) + verify_parser = subparsers.add_parser("verify") + verify_parser.add_argument("root", type=pathlib.Path) + verify_parser.add_argument("label") + preserve_parser = subparsers.add_parser("preserve") + preserve_parser.add_argument("live_root", type=pathlib.Path) + preserve_parser.add_argument("checked_root", type=pathlib.Path) + arguments = parser.parse_args() + github_authority = GitHubReleaseAuthority() + authority: Authority = github_authority.verify + if arguments.mode == "verify": + appcast, catalog = verify_root(arguments.root, arguments.label, authority) + print(f"Verified {arguments.label} signed release metadata for {catalog[0]} ({appcast[0]}).") + else: + preserve_metadata(arguments.live_root, arguments.checked_root, authority) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/verify-public-release.py b/.github/scripts/verify-public-release.py index 65c620b1..e6914168 100755 --- a/.github/scripts/verify-public-release.py +++ b/.github/scripts/verify-public-release.py @@ -130,7 +130,7 @@ def main() -> None: f"Dory-{version}-app-update.zip", f"dory-engine-{version}-arm64.tar.gz", f"Dory-{version}.cdx.json", - f"Dory-{version}-performance-evidence.zip", + f"Dory-{version}-container-engine-performance-evidence.zip", f"Dory-{version}-reliability-evidence.zip", f"Dory-{version}-reliability-evidence.zip.sha256", "release-manifest.json", diff --git a/.github/scripts/verify-release-identity.py b/.github/scripts/verify-release-identity.py new file mode 100644 index 00000000..3c99e99c --- /dev/null +++ b/.github/scripts/verify-release-identity.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""Validate one canonical release identity against every published stable release.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import pathlib +import re +import urllib.error +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ET +from collections.abc import Callable, Iterable + + +SPARKLE = "http://www.andymatuschak.org/xml-namespaces/sparkle" +SEMVER_PATTERN = re.compile( + r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" +) +POSITIVE_BUILD_PATTERN = re.compile(r"[1-9][0-9]*") + + +def fail(message: str) -> None: + raise SystemExit(f"release identity error: {message}") + + +def semantic_version(value: object, label: str) -> tuple[int, int, int]: + match = SEMVER_PATTERN.fullmatch(value if isinstance(value, str) else "") + if match is None: + fail(f"{label} is not a canonical stable semantic version: {value!r}") + return tuple(int(part) for part in match.groups()) + + +def positive_build(value: object, label: str) -> int: + text = value if isinstance(value, str) else "" + if POSITIVE_BUILD_PATTERN.fullmatch(text) is None: + fail(f"{label} is not one canonical positive integer: {value!r}") + return int(text) + + +def unique_project_value(project: str, setting: str) -> str: + values = sorted( + set(re.findall(rf"^\s*{re.escape(setting)} = ([^;]+);", project, re.MULTILINE)) + ) + if len(values) != 1: + fail(f"project {setting} values are not unique: {values!r}") + return values[0] + + +def current_appcast_identity(payload: bytes, label: str) -> tuple[tuple[int, int, int], int]: + if not 0 < len(payload) <= 2 * 1024 * 1024: + fail(f"{label} has an invalid size") + try: + root = ET.fromstring(payload) + except ET.ParseError as error: + fail(f"{label} is not valid XML: {error}") + item = root.find("./channel/item") + if item is None: + fail(f"{label} has no current release item") + version_text = item.findtext(f"{{{SPARKLE}}}shortVersionString") + build_text = item.findtext(f"{{{SPARKLE}}}version") + return ( + semantic_version(version_text, f"{label} current version"), + positive_build(build_text, f"{label} current build"), + ) + + +def validate_release_history( + releases: Iterable[dict[str, object]], + fetch_asset: Callable[[dict[str, object], str], bytes], + requested_version: str, + requested_build: str, +) -> tuple[tuple[int, int, int] | None, int | None]: + requested_version_value = semantic_version(requested_version, "requested release version") + requested_build_value = positive_build(requested_build, "requested release build") + + stable: list[tuple[tuple[int, int, int], str, dict[str, object], list[dict[str, object]]]] = [] + seen_versions: set[tuple[int, int, int]] = set() + for release in releases: + if not isinstance(release, dict): + fail("GitHub release listing contains a non-object entry") + draft = release.get("draft") + prerelease = release.get("prerelease") + if not isinstance(draft, bool) or not isinstance(prerelease, bool): + fail("GitHub release listing has an ambiguous draft/prerelease state") + if draft or prerelease: + continue + tag = release.get("tag_name") + if not isinstance(tag, str) or not tag.startswith("v"): + continue + match = SEMVER_PATTERN.fullmatch(tag[1:]) + if match is None: + continue + version_value = tuple(int(part) for part in match.groups()) + if version_value in seen_versions: + fail(f"GitHub has more than one stable release for {tag}") + seen_versions.add(version_value) + assets_value = release.get("assets") + if not isinstance(assets_value, list) or not all( + isinstance(asset, dict) for asset in assets_value + ): + fail(f"GitHub release {tag} has an invalid asset listing") + stable.append((version_value, tag, release, list(assets_value))) + + if any(version == requested_version_value for version, _, _, _ in stable): + fail(f"release v{requested_version} already exists") + previous_version = max((version for version, _, _, _ in stable), default=None) + if previous_version is not None and requested_version_value <= previous_version: + rendered = ".".join(str(part) for part in previous_version) + fail( + f"release version {requested_version} is not newer than published version {rendered}" + ) + + appcast_counts: dict[tuple[int, int, int], int] = {} + for version, _, _, assets in stable: + appcast_counts[version] = sum(asset.get("name") == "appcast.xml" for asset in assets) + if appcast_counts[version] > 1: + fail(f"stable release v{'.'.join(map(str, version))} repeats appcast.xml") + + appcast_versions = [version for version, count in appcast_counts.items() if count == 1] + if stable and not appcast_versions: + fail("no published stable release carries the authoritative appcast build ledger") + appcast_floor = min(appcast_versions, default=None) + published_builds: list[int] = [] + for version, tag, _, assets in sorted(stable): + count = appcast_counts[version] + if appcast_floor is not None and version >= appcast_floor and count != 1: + fail(f"stable release {tag} is missing its unique authoritative appcast.xml") + if count == 0: + continue + asset = next(asset for asset in assets if asset.get("name") == "appcast.xml") + payload = fetch_asset(asset, tag) + size = asset.get("size") + if not isinstance(size, int) or size != len(payload): + fail(f"stable release {tag} appcast size differs from GitHub metadata") + digest = asset.get("digest") + actual_digest = hashlib.sha256(payload).hexdigest() + if digest != f"sha256:{actual_digest}": + fail(f"stable release {tag} appcast digest differs from GitHub metadata") + appcast_version, appcast_build = current_appcast_identity(payload, f"{tag} appcast.xml") + if appcast_version != version: + fail(f"stable release {tag} appcast current version disagrees with its tag") + published_builds.append(appcast_build) + + previous_build = max(published_builds, default=None) + if previous_build is not None and requested_build_value <= previous_build: + fail( + f"release build {requested_build} is not newer than published build {previous_build}" + ) + return previous_version, previous_build + + +class GitHubClient: + def __init__(self, repository: str, token: str) -> None: + if re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository) is None: + fail(f"invalid GitHub repository: {repository!r}") + if not token: + fail("GH_TOKEN is required to prove the complete GitHub release history") + self.repository = repository + self.headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "User-Agent": "Dory-release-identity-verifier", + "X-GitHub-Api-Version": "2022-11-28", + } + + def request(self, url: str) -> bytes: + request = urllib.request.Request(url, headers=self.headers) + try: + with urllib.request.urlopen(request, timeout=60) as response: + return response.read() + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as error: + fail(f"GitHub request failed closed for {url}: {error}") + + def releases(self) -> list[dict[str, object]]: + releases: list[dict[str, object]] = [] + page = 1 + while True: + url = ( + f"https://api.github.com/repos/{self.repository}/releases" + f"?per_page=100&page={page}" + ) + try: + payload = json.loads(self.request(url).decode("utf-8")) + except (UnicodeError, json.JSONDecodeError) as error: + fail(f"GitHub release page {page} is invalid JSON: {error}") + if not isinstance(payload, list) or not all(isinstance(row, dict) for row in payload): + fail(f"GitHub release page {page} has an invalid shape") + releases.extend(payload) + if len(payload) < 100: + return releases + page += 1 + if page > 100: + fail("GitHub release pagination exceeded the fail-closed safety bound") + + def fetch_asset(self, asset: dict[str, object], tag: str) -> bytes: + url = asset.get("browser_download_url") + if not isinstance(url, str): + fail("GitHub appcast asset has no browser download URL") + parsed = urllib.parse.urlsplit(url) + expected_path = f"/{self.repository}/releases/download/{tag}/appcast.xml" + if ( + parsed.scheme != "https" + or parsed.netloc != "github.com" + or parsed.path != expected_path + or parsed.query + or parsed.fragment + or parsed.username is not None + or parsed.password is not None + ): + fail(f"GitHub appcast browser download URL is not canonical: {url!r}") + request = urllib.request.Request( + url, headers={"User-Agent": "Dory-release-identity-verifier"} + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: + return response.read() + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as error: + fail(f"public GitHub appcast download failed closed for {url}: {error}") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--repository", default="Augani/dory") + parser.add_argument("--project", type=pathlib.Path, required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--build", required=True) + parser.add_argument("--github-output", type=pathlib.Path) + arguments = parser.parse_args() + + version_value = semantic_version(arguments.version, "requested release version") + build_value = positive_build(arguments.build, "requested release build") + try: + project = arguments.project.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + fail(f"could not read project build settings: {error}") + project_version = unique_project_value(project, "MARKETING_VERSION") + project_build = unique_project_value(project, "CURRENT_PROJECT_VERSION") + if semantic_version(project_version, "project MARKETING_VERSION") != version_value: + fail( + f"project MARKETING_VERSION {project_version!r} does not match {arguments.version}" + ) + if positive_build(project_build, "project CURRENT_PROJECT_VERSION") != build_value: + fail(f"project CURRENT_PROJECT_VERSION {project_build!r} does not match {arguments.build}") + + client = GitHubClient(arguments.repository, os.environ.get("GH_TOKEN", "")) + previous_version, previous_build = validate_release_history( + client.releases(), client.fetch_asset, arguments.version, arguments.build + ) + if arguments.github_output is not None: + with arguments.github_output.open("a", encoding="utf-8") as output: + output.write(f"version={arguments.version}\n") + output.write(f"build={arguments.build}\n") + previous_text = ( + "" if previous_version is None else ".".join(str(part) for part in previous_version) + ) + output.write(f"previous_version={previous_text}\n") + previous_version_text = ( + "none" if previous_version is None else ".".join(str(part) for part in previous_version) + ) + print( + f"Release identity {arguments.version} ({arguments.build}) is newer than all " + f"published stable releases (previous {previous_version_text}/{previous_build or 'none'})." + ) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/verify-release-workflow-contract.py b/.github/scripts/verify-release-workflow-contract.py index 03ef1f25..e6b7fd40 100755 --- a/.github/scripts/verify-release-workflow-contract.py +++ b/.github/scripts/verify-release-workflow-contract.py @@ -1,7 +1,17 @@ #!/usr/bin/env python3 """Static regression contract for the one-command public release path.""" +import base64 +import contextlib +import hashlib +import importlib.util +import io +import json +import re +import shutil +import tempfile from pathlib import Path +from types import ModuleType def require(text: str, value: str, message: str) -> None: @@ -9,8 +19,44 @@ def require(text: str, value: str, message: str) -> None: raise SystemExit(f"release workflow contract: {message}") +def load_module(path: Path, name: str) -> ModuleType: + specification = importlib.util.spec_from_file_location(name, path) + if specification is None or specification.loader is None: + raise SystemExit(f"release workflow contract: could not import {path}") + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +def expect_failure(callback, expected: str, message: str) -> None: + try: + callback() + except (RuntimeError, SystemExit) as error: + if expected not in str(error): + raise SystemExit( + f"release workflow contract: {message}; unexpected error: {error}" + ) from error + else: + raise SystemExit(f"release workflow contract: {message}") + + workflow = Path(".github/workflows/release.yml").read_text(encoding="utf-8") +pages_workflow = Path(".github/workflows/pages.yml").read_text(encoding="utf-8") publisher = Path("scripts/publish-release.sh").read_text(encoding="utf-8") +release_script = Path("scripts/release.sh").read_text(encoding="utf-8") +component_tests = Path("scripts/test-build-components.sh").read_text(encoding="utf-8") +identity_verifier_path = Path(".github/scripts/verify-release-identity.py") +identity_verifier_source = identity_verifier_path.read_text(encoding="utf-8") +pages_verifier_path = Path(".github/scripts/verify-pages-release-metadata.py") +pages_verifier_source = pages_verifier_path.read_text(encoding="utf-8") +release_publisher_path = Path(".github/scripts/publish-github-release.py") +release_publisher_source = release_publisher_path.read_text(encoding="utf-8") + +for name, source in (("release", workflow), ("pages", pages_workflow)): + if "assert " in source: + raise SystemExit( + f"release workflow contract: {name} workflow uses optimization-sensitive Python assert" + ) candidate = workflow.split(" - name: Stage immutable public candidate", 1)[1].split( "\n homebrew_install_certification:", 1 @@ -19,13 +65,563 @@ def require(text: str, value: str, message: str) -> None: pages = workflow.split(" publish-pages:", 1)[1].split("\n # Keeps the Homebrew", 1)[0] bump = workflow.split(" bump-cask:", 1)[1].split("\n verify-public-release:", 1)[0] final = workflow.split(" verify-public-release:", 1)[1] +guest_upload = workflow.split(" - name: Upload same-commit arm64 guest payload", 1)[1].split( + "\n\n prepublication-quality:", 1 +)[0] +guest_download_verification = workflow.split( + " - name: Independently verify every downloaded guest payload", 1 +)[1].split("\n - name: Prove the tracked release source exactly matches the commit", 1)[0] + +if "github.run_number" in workflow: + raise SystemExit( + "release workflow contract: workflow run number is used as release artifact metadata" + ) +require(workflow, "build:\n description: 'Monotonic CURRENT_PROJECT_VERSION", "release dispatch has no explicit monotonic build input") +require(workflow, "release-metadata:", "release identity is not validated before artifact work") +require( + workflow, + ".github/scripts/verify-release-identity.py", + "release workflow does not run the shared complete-history identity verifier", +) +release_metadata = re.search( + r"(?ms)^ release-metadata:\n(.*?)(?=^ [A-Za-z0-9_-]+:\n|\Z)", workflow +) +if release_metadata is None: + raise SystemExit("release workflow contract: missing release-metadata job") +release_metadata_source = release_metadata.group(1) +require( + release_metadata_source, + 'test "$GITHUB_SHA" = "$(git rev-parse origin/main)"', + "release identity accepts an ancestor instead of the exact current main commit", +) +if "merge-base --is-ancestor" in release_metadata_source: + raise SystemExit("release workflow contract: release identity still accepts stale main ancestry") +require( + release_metadata_source, + "'^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$'", + "release workflow does not reject leading-zero semantic versions", +) +require(identity_verifier_source, "?per_page=100&page={page}", "identity verifier does not paginate every release") +require(identity_verifier_source, "page > 100", "identity verifier has no fail-closed pagination bound") +require(identity_verifier_source, "appcast_floor", "identity verifier trusts only one mutable appcast") +require(identity_verifier_source, "digest != f\"sha256:{actual_digest}\"", "identity verifier does not authenticate release appcasts") +require(identity_verifier_source, "previous_version={previous_text}", "identity verifier does not expose the maximum stable release") +require(workflow, 'git show-ref --verify --quiet "refs/tags/v$RELEASE_VERSION"', "release identity does not reject an existing tag") +require(workflow, 'releases/tags/v$RELEASE_VERSION', "release identity does not prove the GitHub Release is absent") +require(workflow, 'could not prove release v$RELEASE_VERSION is absent', "release identity treats an indeterminate GitHub response as absence") +release_configuration = re.search( + r"(?ms)^ release-configuration:\n(.*?)(?=^ [A-Za-z0-9_-]+:\n|\Z)", workflow +) +if release_configuration is None: + raise SystemExit("release workflow contract: missing release-configuration job") +require( + release_configuration.group(1), + "needs: release-metadata", + "credential/infrastructure preflight can run before release identity is proven", +) +for job in ("rust-workspace", "guest-assets-arm64", "prepublication-quality"): + match = re.search( + rf"(?ms)^ {re.escape(job)}:\n(.*?)(?=^ [A-Za-z0-9_-]+:\n|\Z)", + workflow, + ) + if match is None: + raise SystemExit(f"release workflow contract: missing job {job}") + section = match.group(1) + require(section, "release-metadata", f"{job} can start before release identity validation") +require(workflow, 'scripts/release.sh "${{ inputs.version }}" "${{ inputs.build }}"', "release build does not use validated dispatch metadata") +require(workflow, 'BUILD: ${{ inputs.build }}', "downstream evidence is not bound to the dispatch build") +if "releases/latest/download/appcast.xml" in workflow: + raise SystemExit("release workflow contract: candidate history trusts the mutable latest release") +require( + workflow, + "releases/download/v$PREVIOUS_RELEASE_VERSION/appcast.xml", + "candidate history is not loaded from the maximum stable release", +) + +identity_verifier = load_module(identity_verifier_path, "dory_release_identity_contract") + + +def fixture_appcast(version: str, build: int) -> bytes: + return ( + '\n' + '' + f"{version}" + f"{build}\n" + ).encode("utf-8") + + +def fixture_release(version: str, build: int | None) -> dict[str, object]: + assets: list[dict[str, object]] = [] + if build is not None: + payload = fixture_appcast(version, build) + assets.append( + { + "name": "appcast.xml", + "size": len(payload), + "digest": f"sha256:{hashlib.sha256(payload).hexdigest()}", + "fixture": payload, + } + ) + return { + "tag_name": f"v{version}", + "draft": False, + "prerelease": False, + "assets": assets, + } + + +def fetch_fixture_asset(asset: dict[str, object], tag: str) -> bytes: + payload = asset.get("fixture") + if not isinstance(payload, bytes): + raise SystemExit("release workflow contract: malformed identity fixture") + return payload + + +identity_verifier.validate_release_history( + [fixture_release("0.2.0", None), fixture_release("0.4.5", 49)], + fetch_fixture_asset, + "0.4.6", + "52", +) +expect_failure( + lambda: identity_verifier.validate_release_history([], fetch_fixture_asset, "00.4.6", "52"), + "not a canonical stable semantic version", + "identity verifier accepted leading-zero SemVer", +) +expect_failure( + lambda: identity_verifier.validate_release_history( + [{"tag_name": "v0.4.5", "draft": "false", "prerelease": False, "assets": []}], + fetch_fixture_asset, + "0.4.6", + "52", + ), + "ambiguous draft/prerelease state", + "identity verifier accepted ambiguous GitHub release state", +) +expect_failure( + lambda: identity_verifier.validate_release_history( + [fixture_release("0.4.5", 49)], fetch_fixture_asset, "0.4.5", "52" + ), + "already exists", + "identity verifier accepted an existing release", +) +expect_failure( + lambda: identity_verifier.validate_release_history( + [fixture_release("0.4.5", 49), fixture_release("0.5.0", 51)], + fetch_fixture_asset, + "0.4.6", + "52", + ), + "not newer than published version 0.5.0", + "identity verifier trusted list order/latest instead of every stable release", +) +expect_failure( + lambda: identity_verifier.validate_release_history( + [fixture_release("0.4.5", 49), fixture_release("0.4.6", None)], + fetch_fixture_asset, + "0.4.7", + "52", + ), + "missing its unique authoritative appcast.xml", + "identity verifier tolerated a hole in the authoritative build ledger", +) + +for distro in ("debian", "ubuntu", "kali"): + require( + workflow, + f'guest/desktop/build.sh arm64 "$distro"', + f"release workflow does not build the {distro} desktop from its commit", + ) + require( + workflow, + f"guest/out/dory-desktop-{distro}-rootfs-arm64.ext4.zst", + f"same-commit guest artifact omits the {distro} desktop", + ) + require( + workflow, + f'guest/desktop/verify-build.sh arm64 "$distro"', + f"release candidate does not verify the {distro} desktop", + ) +require(workflow, "guest/out/Image-desktop.zst", "same-commit guest artifact omits the desktop kernel") +for mesa_artifact in ( + "guest/out/dory-mesa-venus-arm64.tar.zst", + "guest/out/dory-mesa-venus-build-arm64.stamp", +): + require( + guest_upload, + mesa_artifact, + f"same-commit guest artifact omits required Mesa payload {mesa_artifact}", + ) +require( + guest_download_verification, + "guest/mesa/verify-build.sh arm64", + "downloaded guest payload does not independently verify its exact Mesa runtime", +) +require( + workflow, + "DORY_KERNEL_PROFILE=accelerated-desktop guest/kernel/verify-build.sh arm64", + "release candidate does not verify its desktop kernel", +) +require( + workflow, + "DORY_RELEASE_DESKTOP_KERNEL: ${{ github.workspace }}/guest/out/Image-desktop", + "physical release gate does not receive its same-commit desktop kernel", +) +require( + workflow, + "DORY_RELEASE_COMPONENT_DIR: ${{ github.workspace }}/release-build/components/arm64", + "physical release gate does not receive its signed component candidate", +) +live_smoke = Path("scripts/release-candidate-live-smoke.sh").read_text(encoding="utf-8") +desktop_gate_path = Path("scripts/desktop-linux-live-gate.sh") +if not desktop_gate_path.stat().st_mode & 0o100: + raise SystemExit("release workflow contract: desktop live gate is not executable") +desktop_gate = desktop_gate_path.read_text(encoding="utf-8") +require( + live_smoke, + "scripts/desktop-linux-live-gate.sh", + "physical release smoke does not boot and exercise the managed desktops", +) +for distro in ("debian", "ubuntu", "kali"): + require( + live_smoke, + f'--{distro}-rootfs "$DESKTOP_{distro.upper()}_ROOTFS"', + f"physical desktop gate omits {distro}", + ) + require( + live_smoke, + f'--{distro}-update "$DESKTOP_{distro.upper()}_UPDATE"', + f"physical desktop gate omits the {distro} in-place update payload", + ) +for proof in ( + "browser-running", + "grep -q 'capture' /proc/asound/pcm", + "grep -q 'playback' /proc/asound/pcm", + "persistence-pass", + "Dory Wired", + "com.apple.security.device.audio-input", + 'grep -F "$VMM"', + "machine desktop-update", + 'body.get("snapshotID")', + '"provenance": "verified-update-bundle"', + 'machine snapshot "$machine"', + 'machine restore-snapshot "$machine"', + "recovery-exact-bytes-restored", + "restore reused the snapshot's stale launch plan", + "snapshot_restore_exact_bytes=PASS", +): + require(desktop_gate, proof, f"desktop live gate omits required proof: {proof}") require(candidate, "release-build/components/arm64/*", "candidate omits component payloads") +require( + release_script, + "public component publication is blocked: no physical Linux VM campaign producer is wired after immutable candidate assembly and SBOM generation", + "public component publication is not fail-closed on the missing physical producer", +) +require( + release_script, + "$BUILD_DIR/component-candidate-verification.receipt", + "local component candidate assembly is not independently verified", +) +if "DORY_COMPONENT_QUALIFICATION_DIR" in workflow: + raise SystemExit( + "release workflow contract: workflow accepts pre-candidate component qualification evidence" + ) +if "scripts/build-components.py finalize" in release_script: + raise SystemExit( + "release workflow contract: monolithic release script finalizes before a post-candidate physical producer exists" + ) +release_execution = release_script.split( + 'if [ "${DORY_RELEASE_SOURCE_ONLY:-0}" = "1" ]; then', 1 +)[1] +preflight_offset = release_execution.find("\npreflight_release\n") +assemble_offset = release_execution.find("scripts/build-components.py assemble") +verify_offset = release_execution.find("scripts/build-components.py verify-candidate") +if min(preflight_offset, assemble_offset, verify_offset) < 0: + raise SystemExit("release workflow contract: release component/preflight orchestration is incomplete") +if not preflight_offset < assemble_offset < verify_offset: + raise SystemExit( + "release workflow contract: release preflight, candidate assembly, and candidate verification are out of order" + ) +require( + component_tests, + "dummy pre-candidate evidence bypassed the public stop line", + "component preflight contract does not reject synthetic pre-candidate evidence", +) +require( + component_tests, + "pre-candidate or synthetic qualification evidence cannot authorize schema-2 finalization", + "component preflight contract does not verify the stop-line reason", +) +require( + component_tests, + "public component stop line blocked a local non-public build", + "component preflight contract does not preserve local non-public release builds", +) +require( + workflow, + "DORY_COMPONENT_CATALOG_SCHEMA: '2'", + "signed release build does not stamp component catalog schema 2 into the appcast", +) require(publication, "release-build/components/arm64/*", "GitHub release omits component payloads") for name in ("catalog.json", "catalog.json.sha256", "catalog.json.sig"): require(publication, f"release-build/components/arm64/{name}", f"metadata artifact omits {name}") require(pages, f"component-catalog-artifact/{name}", f"Pages does not deploy {name}") require(pages, f"live/components/arm64/{name}", f"Pages does not verify live {name}") + require(pages_workflow, name, f"normal Pages deploy does not enumerate live {name}") +require( + pages_workflow, + ".github/scripts/verify-pages-release-metadata.py", + "normal Pages deploy does not use the shared release metadata verifier", +) +require( + pages, + ".github/scripts/verify-pages-release-metadata.py", + "release-specific Pages deploy bypasses the shared release metadata verifier", +) +require(pages_verifier_source, '"openssl",\n "pkeyutl",\n "-verify"', "Pages verifier does not invoke Ed25519 verification for Sparkle") +require(pages_verifier_source, '"-in",\n str(update_path)', "Sparkle verification is not over the authoritative update archive") +require(pages_verifier_source, "appcast_bytes == authoritative", "Pages verifier does not bind appcast bytes to the exact release asset") +require(pages_verifier_source, "digest == f\"sha256:{actual}\"", "Pages verifier does not authenticate authoritative GitHub assets") +require(pages_verifier_source, "self.stable_ledger()", "Pages verifier does not compare against all stable release maxima") +require(pages_verifier_source, "AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4=", "Pages verifier does not pin the production key") +require(pages_verifier_source, "catalog JSON repeats key", "Pages verifier does not reject duplicate catalog keys") +require(pages_verifier_source, "appcast and catalog versions differ", "Pages verifier does not bind appcast and catalog release identity") +require(pages_verifier_source, "Dory-{version}-app-update.zip", "Pages verifier does not constrain the Sparkle enclosure to the release asset") +require(pages_verifier_source, "equal release identity has two different signed metadata transactions", "equal release identity can preserve different bytes") +normal_build = pages_workflow.split(" - run: npm run build", 1)[1].split( + " - uses: actions/configure-pages", 1 +)[0] +require( + normal_build, + "cmp website/public/appcast.xml docs-build/appcast.xml", + "normal Pages build does not compare appcast.xml in its final artifact", +) +require( + normal_build, + "for name in catalog.json catalog.json.sha256 catalog.json.sig; do", + "normal Pages build does not enumerate the complete signed catalog transaction", +) +require( + normal_build, + 'cmp "website/public/components/arm64/$name" "docs-build/components/arm64/$name"', + "normal Pages build does not compare every signed catalog file in its final artifact", +) +require( + normal_build, + 'verify docs-build docs-build', + "normal Pages build does not rerun signature and appcast/catalog binding verification", +) +post_deploy = pages_workflow.split( + " - name: Verify all live signed metadata converges to the deployed transaction", 1 +)[1] +for relative in ( + "appcast.xml", + "components/arm64/catalog.json", + "components/arm64/catalog.json.sha256", + "components/arm64/catalog.json.sig", +): + require( + post_deploy, + f'docs-build/{relative}', + f"normal Pages deploy does not prove live {relative} converged byte-for-byte", + ) +require(post_deploy, 'verify "$live" deployed', "post-deploy metadata is not cryptographically reverified") + +pages_verifier = load_module(pages_verifier_path, "dory_pages_metadata_contract") +with tempfile.TemporaryDirectory(prefix="dory-pages-contract-") as temporary: + temporary_root = Path(temporary) + checked_root = temporary_root / "checked" + shutil.copytree("website/public", checked_root) + authoritative_appcast = (checked_root / "appcast.xml").read_bytes() + + def exact_fixture_authority(version, raw, signature, enclosure): + if version != "0.4.5" or raw != authoritative_appcast: + raise SystemExit("fixture appcast differs from authoritative release bytes") + if len(signature) != 64 or enclosure.get("length") != "237419015": + raise SystemExit("fixture Sparkle envelope is invalid") + + pages_verifier.verify_root(checked_root, "contract-fixture", exact_fixture_authority) + + catalog_tamper_root = temporary_root / "catalog-tamper" + shutil.copytree(checked_root, catalog_tamper_root) + catalog_path = catalog_tamper_root / "components" / "arm64" / "catalog.json" + catalog_path.write_bytes(catalog_path.read_bytes() + b"\n") + expect_failure( + lambda: pages_verifier.verify_root( + catalog_tamper_root, "catalog-tamper", exact_fixture_authority + ), + "does not authenticate catalog.json", + "real Pages verifier accepted catalog tampering", + ) + + appcast_tamper_root = temporary_root / "appcast-tamper" + shutil.copytree(checked_root, appcast_tamper_root) + appcast_path = appcast_tamper_root / "appcast.xml" + zero_signature = base64.b64encode(bytes(64)) + tampered_appcast, replacements = re.subn( + rb'sparkle:edSignature="[^"]+"', + b'sparkle:edSignature="' + zero_signature + b'"', + appcast_path.read_bytes(), + count=1, + ) + if replacements != 1: + raise SystemExit("release workflow contract: could not construct Sparkle tamper fixture") + appcast_path.write_bytes(tampered_appcast) + expect_failure( + lambda: pages_verifier.verify_root( + appcast_tamper_root, "appcast-tamper", exact_fixture_authority + ), + "differs from authoritative release bytes", + "Pages verifier accepted a shape-correct random Sparkle signature", + ) + expect_failure( + lambda: pages_verifier.preserve_metadata( + appcast_tamper_root, checked_root, lambda version, raw, signature, enclosure: None + ), + "equal release identity has two different signed metadata transactions", + "Pages preservation accepted two byte-distinct equal-identity transactions", + ) + + +def pages_authority_fixture(releases): + serializable = [] + appcasts = {} + for release in releases: + release_copy = dict(release) + copied_assets = [] + for asset in release.get("assets", []): + asset_copy = dict(asset) + fixture = asset_copy.pop("fixture", None) + if isinstance(fixture, bytes): + appcasts[release["tag_name"][1:]] = fixture + copied_assets.append(asset_copy) + release_copy["assets"] = copied_assets + serializable.append(release_copy) + authority = pages_verifier.GitHubReleaseAuthority.__new__( + pages_verifier.GitHubReleaseAuthority + ) + authority.repository = "Augani/dory" + authority.token = "fixture" + authority.cache = {} + authority.appcast_cache = {} + authority.ledger = None + authority.request = lambda url, api, destination=None: json.dumps(serializable).encode( + "utf-8" + ) + authority.small_asset_bytes = lambda asset, version, name: appcasts[version] + return authority + + +nonlatest_authority = pages_authority_fixture( + [fixture_release("0.4.5", 49), fixture_release("0.5.0", 51)] +) +if nonlatest_authority.stable_ledger() != ((0, 5, 0), 51): + raise SystemExit( + "release workflow contract: Pages authority trusts release order/latest instead of maxima" + ) +missing_appcast_authority = pages_authority_fixture( + [fixture_release("0.4.5", 49), fixture_release("0.5.0", None)] +) +expect_failure( + missing_appcast_authority.stable_ledger, + "lacks one appcast.xml", + "Pages authority tolerated a hole in the stable appcast ledger", +) + +if "softprops/action-gh-release" in publication: + raise SystemExit( + "release workflow contract: mutable find-or-update release action remains in publication" + ) +require( + publication, + "git fetch --force origin main", + "publication does not refresh main immediately before creating public state", +) +require( + publication, + 'test "$GITHUB_SHA" = "$(git rev-parse origin/main)"', + "publication accepts a stale qualified commit after main advances", +) +require( + publication, + ".github/scripts/verify-release-identity.py", + "publication does not revalidate complete release history after long-running gates", +) +require( + publication, + ".github/scripts/publish-github-release.py", + "publication does not use the create-only private release transaction", +) +require( + publication, + "RELEASE_ID: ${{ steps.published_release.outputs.id }}", + "post-publication verification is not bound to the newly created release ID", +) +require(release_publisher_source, '"POST",\n "git/refs"', "publisher does not create the tag with create-only API semantics") +require(release_publisher_source, '"draft": True', "publisher exposes an incomplete release") +require(release_publisher_source, "expected_status=201", "publisher does not require GitHub create semantics") +require(release_publisher_source, 'body={"draft": False}', "publisher does not publish the exact verified draft") +require(release_publisher_source, "actual_assets == expected_assets", "publisher does not verify the exact private asset set") +require(release_publisher_source, "release_asset_state(public_release) == expected_assets", "publisher does not reverify assets after publication") +require(release_publisher_source, "exact_ref_target(github, tag) == arguments.source_commit", "publisher does not bind the release tag to the qualified source") +require(release_publisher_source, "revalidate_publication_authority(", "publisher does not revalidate main/history after uploading the private draft") +require(release_publisher_source, "Automatic GitHub cleanup is disabled", "publisher does not explain failed private state reconciliation") +if 'github.delete(' in release_publisher_source or '"DELETE"' in release_publisher_source: + raise SystemExit( + "release workflow contract: failure cleanup can destructively race another publisher" + ) + +draft_verified_offset = release_publisher_source.find("actual_assets == expected_assets") +authority_recheck_offset = release_publisher_source.rfind("revalidate_publication_authority(") +publish_offset = release_publisher_source.find('"PATCH", f"releases/{release_id}"') +if min(draft_verified_offset, authority_recheck_offset, publish_offset) < 0: + raise SystemExit("release workflow contract: private publication transaction is incomplete") +if not draft_verified_offset < authority_recheck_offset < publish_offset: + raise SystemExit( + "release workflow contract: main/history is not revalidated after upload and before publish" + ) + +release_publisher = load_module(release_publisher_path, "dory_github_release_publisher_contract") +with tempfile.TemporaryDirectory(prefix="dory-release-publisher-contract-") as temporary: + upload_fixture = Path(temporary) / "asset.zip" + upload_fixture.write_bytes(b"release asset fixture") + github_fixture = release_publisher.GitHub("Augani/dory", "fixture-token") + with upload_fixture.open("rb") as handle: + expect_failure( + lambda: github_fixture.upload( + "https://uploads.github.com/repos/Augani/dory/releases/999/assets{?name,label}", + 123, + "asset.zip", + handle, + upload_fixture.stat().st_size, + ), + "non-canonical release upload URL", + "publisher accepted an upload URL for a different release ID", + ) + + +class InterveningReleaseFixture: + def release_for_tag(self, tag): + return {"id": 9001, "tag_name": tag, "draft": False} + + def json_request(self, method, path): + return {"object": {"type": "commit", "sha": "0" * 40}} + + +intervening_fixture = InterveningReleaseFixture() +cleanup_output = io.StringIO() +with contextlib.redirect_stderr(cleanup_output): + release_publisher.report_unpublished_state( + intervening_fixture, + "v0.4.6", + "0" * 40, + None, + ) +if "Automatic GitHub cleanup is disabled" not in cleanup_output.getvalue(): + raise SystemExit("release workflow contract: failed publication does not fail closed") +if "release_id=9001" not in cleanup_output.getvalue(): + raise SystemExit("release workflow contract: failed publication omits intervening release ID") require(bump, "needs: [publish_release, publish-pages]", "Homebrew can publish before assets/Pages") require(bump, "git add Casks/dory.rb", "in-repository Homebrew cask is not updated") @@ -33,8 +629,23 @@ def require(text: str, value: str, message: str) -> None: require(bump, 'grep -qF "sha256 \\"$S\\"" <<< "$remote"', "standalone tap checksum is not verified") require(final, "needs: [publish_release, publish-pages, bump-cask]", "no terminal publication gate") require(final, ".github/scripts/verify-public-release.py", "terminal publication verifier is not run") +require( + pages, + 'componentCatalogSchema\") == \"2\"', + "published appcast does not require component catalog schema 2", +) require(publisher, 'gh workflow run "$WORKFLOW"', "publisher does not dispatch the release workflow") +require(publisher, "CURRENT_PROJECT_VERSION", "publisher does not parse the authoritative project build") +require(publisher, '--field "build=$PROJECT_BUILD"', "publisher does not dispatch the project build") +require(publisher, ".github/scripts/verify-release-identity.py", "publisher does not enforce complete-history monotonic identity") +require( + publisher, + "'^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$'", + "publisher accepts leading-zero semantic versions", +) +require(publisher, "case \"$tag_status\" in", "publisher treats every git lookup failure as tag absence") +require(publisher, "could not prove release v$VERSION is absent", "publisher treats ambiguous GitHub responses as absence") require(publisher, 'gh run watch "$RUN_ID"', "publisher does not wait for the complete workflow") require(publisher, ".github/scripts/verify-public-release.py", "publisher skips independent live verification") diff --git a/.github/workflows/intel-engine.yml b/.github/workflows/intel-engine.yml index 4a618e53..2674c845 100644 --- a/.github/workflows/intel-engine.yml +++ b/.github/workflows/intel-engine.yml @@ -67,12 +67,15 @@ jobs: test "$(sysctl -in hw.optional.arm64 2>/dev/null || printf 0)" != 1 case "$(sysctl -n hw.model)" in VirtualMac*) exit 1 ;; esac + - name: Install Protocol Buffers compiler + run: brew install protobuf + - name: Build shared Rust guest-control client run: scripts/build-dory-ffi-xcframework.sh - name: DoryHV logic tests working-directory: Packages/ContainerizationEngine - run: swift test + run: SWT_EXPERIMENTAL_MAXIMUM_PARALLELIZATION_WIDTH=1 swift test --no-parallel - name: Build Intel helper slice working-directory: Packages/ContainerizationEngine diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 5f6aa878..9b15833e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -7,7 +7,10 @@ name: Deploy Pages on: push: branches: [main] - paths: ['website/**', '.github/workflows/pages.yml'] + paths: + - 'website/**' + - '.github/workflows/pages.yml' + - '.github/scripts/verify-pages-release-metadata.py' workflow_dispatch: permissions: @@ -24,46 +27,30 @@ concurrency: jobs: deploy: runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - name: Preserve the currently deployed Sparkle feed + - name: Configure the job-scoped Sparkle authority cache + run: echo "DORY_SPARKLE_AUTHORITY_CACHE=$RUNNER_TEMP/dory-sparkle-authority" >> "$GITHUB_ENV" + - name: Preserve newer live signed release metadata as one transaction run: | - live="$RUNNER_TEMP/live-appcast.xml" - if curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 60 \ - "https://augani.github.io/dory/appcast.xml?preserve=${{ github.run_id }}" \ - -o "$live" \ - && python3 - "$live" website/public/appcast.xml <<'PY' - import sys - import xml.etree.ElementTree as ET - - sparkle = "http://www.andymatuschak.org/xml-namespaces/sparkle" - dory = "https://augani.github.io/dory/appcast" - def release_item(path): - item = ET.parse(path).getroot().find("./channel/item") - assert item is not None, f"{path} has no release item" - assert item.findtext(f"{{{sparkle}}}minimumSystemVersion") == "14.0", \ - f"{path} has an invalid macOS floor" - for name in ("dataSchemaVersion", "minimumReadableDataSchema", \ - "maximumReadableDataSchema", "componentCatalogSchema"): - assert item.findtext(f"{{{dory}}}{name}") == "1", \ - f"{path} has an invalid Dory upgrade schema contract" - return item - - live_item = release_item(sys.argv[1]) - checked_item = release_item(sys.argv[2]) - live_build = int(live_item.findtext(f"{{{sparkle}}}version")) - checked_build = int(checked_item.findtext(f"{{{sparkle}}}version")) - assert live_build > checked_build, \ - f"live appcast build {live_build} is not newer than checked-in build {checked_build}" - PY - then - cp "$live" website/public/appcast.xml - else - echo "Live appcast is missing, invalid, or not newer; retaining the checked-in macOS 14 bootstrap feed." - fi + set -euo pipefail + live="$RUNNER_TEMP/live-release-metadata" + mkdir -p "$live/components/arm64" + curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 60 \ + "https://augani.github.io/dory/appcast.xml?preserve=${{ github.run_id }}" \ + -o "$live/appcast.xml" + for name in catalog.json catalog.json.sha256 catalog.json.sig; do + curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 60 \ + "https://augani.github.io/dory/components/arm64/$name?preserve=${{ github.run_id }}" \ + -o "$live/components/arm64/$name" + done + python3 .github/scripts/verify-pages-release-metadata.py \ + preserve "$live" website/public - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 @@ -75,9 +62,47 @@ jobs: working-directory: website - run: npm run build working-directory: website + - name: Prove the normal site build retained one exact signed metadata transaction + run: | + set -euo pipefail + cmp website/public/appcast.xml docs-build/appcast.xml + for name in catalog.json catalog.json.sha256 catalog.json.sig; do + cmp "website/public/components/arm64/$name" "docs-build/components/arm64/$name" + done + python3 .github/scripts/verify-pages-release-metadata.py \ + verify docs-build docs-build - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: path: docs-build - id: deployment uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 + - name: Verify all live signed metadata converges to the deployed transaction + run: | + set -euo pipefail + live="$RUNNER_TEMP/deployed-release-metadata" + mkdir -p "$live/components/arm64" + for attempt in $(seq 1 18); do + complete=1 + curl -fsSL --connect-timeout 10 --max-time 30 \ + "https://augani.github.io/dory/appcast.xml?pages=${{ github.run_id }}&attempt=$attempt" \ + -o "$live/appcast.xml" || complete=0 + for name in catalog.json catalog.json.sha256 catalog.json.sig; do + curl -fsSL --connect-timeout 10 --max-time 30 \ + "https://augani.github.io/dory/components/arm64/$name?pages=${{ github.run_id }}&attempt=$attempt" \ + -o "$live/components/arm64/$name" || complete=0 + done + if [ "$complete" = 1 ] \ + && cmp docs-build/appcast.xml "$live/appcast.xml" \ + && cmp docs-build/components/arm64/catalog.json "$live/components/arm64/catalog.json" \ + && cmp docs-build/components/arm64/catalog.json.sha256 "$live/components/arm64/catalog.json.sha256" \ + && cmp docs-build/components/arm64/catalog.json.sig "$live/components/arm64/catalog.json.sig"; then + python3 .github/scripts/verify-pages-release-metadata.py \ + verify "$live" deployed + echo "All four signed release metadata files converged byte-for-byte." + exit 0 + fi + [ "$attempt" -eq 18 ] || sleep 5 + done + echo "GitHub Pages did not converge to the exact deployed release metadata transaction" >&2 + exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cadde46..ab36837f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,6 +58,11 @@ on: version: description: 'Version to release (e.g. 0.1.0)' required: true + type: string + build: + description: 'Monotonic CURRENT_PROJECT_VERSION to release (e.g. 52)' + required: true + type: string permissions: contents: read @@ -67,8 +72,69 @@ concurrency: cancel-in-progress: false jobs: + release-metadata: + name: Validate authoritative release version and build + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + version: ${{ steps.identity.outputs.version }} + build: ${{ steps.identity.outputs.build }} + previous_version: ${{ steps.identity.outputs.previous_version }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + - name: Prove project identity and monotonic build before artifact work + id: identity + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_BUILD: ${{ inputs.build }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + test "$GITHUB_REF" = refs/heads/main || { + echo "Manual public releases must run from main, not $GITHUB_REF" >&2 + exit 1 + } + printf '%s\n' "$RELEASE_VERSION" \ + | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' || { + echo "release version must be a stable semantic version" >&2 + exit 1 + } + printf '%s\n' "$RELEASE_BUILD" | grep -Eq '^[1-9][0-9]*$' || { + echo "release build must be a positive integer" >&2 + exit 1 + } + git fetch --force --tags origin main + test "$GITHUB_SHA" = "$(git rev-parse origin/main)" || { + echo "Release commit $GITHUB_SHA is not the exact current origin/main commit" >&2 + exit 1 + } + if git show-ref --verify --quiet "refs/tags/v$RELEASE_VERSION"; then + echo "release tag v$RELEASE_VERSION already exists" >&2 + exit 1 + fi + release_status="$(curl -sS --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 60 \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + -o "$RUNNER_TEMP/requested-release.json" -w '%{http_code}' \ + "https://api.github.com/repos/$GITHUB_REPOSITORY/releases/tags/v$RELEASE_VERSION")" + case "$release_status" in + 404) ;; + 200) echo "release v$RELEASE_VERSION already exists" >&2; exit 1 ;; + *) echo "could not prove release v$RELEASE_VERSION is absent (GitHub HTTP $release_status)" >&2; exit 1 ;; + esac + python3 .github/scripts/verify-release-identity.py \ + --repository "$GITHUB_REPOSITORY" \ + --project Dory.xcodeproj/project.pbxproj \ + --version "$RELEASE_VERSION" \ + --build "$RELEASE_BUILD" \ + --github-output "$GITHUB_OUTPUT" + release-configuration: name: Required release credentials and tap access + needs: release-metadata runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -214,7 +280,8 @@ jobs: with open(sys.argv[1], encoding="utf-8") as handle: metadata = json.load(handle) keys = metadata.get("ssh_keys", []) - assert keys and all(key.startswith("ssh-") for key in keys), "GitHub SSH metadata is missing" + if not keys or not all(isinstance(key, str) and key.startswith("ssh-") for key in keys): + raise SystemExit("GitHub SSH metadata is missing") with open(sys.argv[2], "w", encoding="utf-8") as handle: for key in keys: handle.write(f"github.com {key}\n") @@ -227,7 +294,7 @@ jobs: rust-workspace: name: Linux full Rust quality gate - needs: release-configuration + needs: [release-configuration, release-metadata] runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -247,7 +314,7 @@ jobs: guest-assets-arm64: name: Build verified arm64 guest assets - needs: release-configuration + needs: [release-configuration, release-metadata] runs-on: ubuntu-24.04-arm timeout-minutes: 180 steps: @@ -270,12 +337,23 @@ jobs: DORY_EXPERIMENTAL_GPU=0 guest/kernel/verify-build.sh arm64 DORY_EXPERIMENTAL_GPU=1 guest/kernel/verify-build.sh arm64 guest/initfs/verify-build.sh arm64 + - name: Build and verify every Linux desktop payload from this commit + run: | + DORY_KERNEL_PROFILE=accelerated-desktop guest/kernel/build.sh arm64 + for distro in debian ubuntu kali; do + guest/desktop/build.sh arm64 "$distro" + done + DORY_KERNEL_PROFILE=accelerated-desktop guest/kernel/verify-build.sh arm64 + for distro in debian ubuntu kali; do + guest/desktop/verify-build.sh arm64 "$distro" + done - name: Upload same-commit arm64 guest payload uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: dory-guest-arm64-${{ github.sha }} retention-days: 30 if-no-files-found: error + compression-level: 0 path: | guest/out/Image guest/out/Image.zst @@ -288,10 +366,27 @@ jobs: guest/out/initfs-arm64.ext4 guest/out/dory-agent-arm64 guest/out/initfs-build-arm64.stamp + guest/out/Image-desktop.zst + guest/out/config-arm64-desktop + guest/out/kernel-build-arm64-desktop.stamp + guest/out/dory-mesa-venus-arm64.tar.zst + guest/out/dory-mesa-venus-build-arm64.stamp + guest/out/dory-desktop-debian-rootfs-arm64.ext4.zst + guest/out/dory-desktop-debian-packages-arm64.txt + guest/out/dory-desktop-debian-update-arm64.tar + guest/out/dory-desktop-debian-build-arm64.stamp + guest/out/dory-desktop-ubuntu-rootfs-arm64.ext4.zst + guest/out/dory-desktop-ubuntu-packages-arm64.txt + guest/out/dory-desktop-ubuntu-update-arm64.tar + guest/out/dory-desktop-ubuntu-build-arm64.stamp + guest/out/dory-desktop-kali-rootfs-arm64.ext4.zst + guest/out/dory-desktop-kali-packages-arm64.txt + guest/out/dory-desktop-kali-update-arm64.tar + guest/out/dory-desktop-kali-build-arm64.stamp prepublication-quality: name: macOS pre-publication quality gate - needs: release-configuration + needs: [release-configuration, release-metadata] runs-on: macos-latest timeout-minutes: 120 steps: @@ -304,18 +399,84 @@ jobs: echo "DEVELOPER_DIR=$newest/Contents/Developer" >> "$GITHUB_ENV" - name: Download Metal toolchain (Xcode 26 ships without it) run: xcodebuild -downloadComponent MetalToolchain || true + - name: Install Protocol Buffers compiler + run: brew install protobuf - name: Build shared Rust guest-control client run: scripts/build-dory-ffi-xcframework.sh --if-needed + - name: Signed release metadata contract + run: | + python3 .github/scripts/test-release-metadata.py + python3 .github/scripts/test-release-output-validator.py + python3 .github/scripts/test-homebrew-install-gate.py + python3 .github/scripts/test-container-engine-performance-gate.py + python3 .github/scripts/test-linux-vm-performance-evidence.py + python3 -B scripts/test-linux-vm-performance-bundle.py -v + python3 .github/scripts/test-renderer-production-tuple.py + python3 -B .github/scripts/test-renderer-release-identity.py -v + scripts/test-build-renderer-components.sh + python3 .github/scripts/test-source-preserving-lan-gate.py + python3 .github/scripts/test-sparkle-install-relaunch-gate.py + python3 .github/scripts/test-vz-native-ipv6-gate.py + bash scripts/test-make-dmg.sh + bash scripts/test-app-update-payload.sh + bash scripts/test-verify-macos-deployment-targets.sh + python3 .github/scripts/test-release-orchestrator.py + bash scripts/test-dmg-distribution-signing.sh + python3 .github/scripts/test-ci-umbrella.py + python3 .github/scripts/test-interrupted-upgrade-rollback-gate.py + python3 .github/scripts/test-generate-appcast.py + python3 .github/scripts/test-release-sbom.py + python3 .github/scripts/test-direct-dmg-install-gate.py + python3 .github/scripts/test-desktop-linux-live-gate.py + python3 .github/scripts/test-graphics-pack-installer.py + python3 .github/scripts/test-graphics-backend-resolver.py + python3 .github/scripts/test-machine-resource-reconfiguration-gate.py + python3 .github/scripts/test-sandbox-security-gate.py + python3 .github/scripts/test-ssh-agent-forwarding-gate.py + python3 .github/scripts/test-bind-advisory-lock-gate.py + python3 .github/scripts/test-nonnative-build-smoke.py + python3 .github/scripts/test-external-volume-bind-gate.py + python3 .github/scripts/test-host-network-integrity-gate.py + python3 .github/scripts/test-readiness-gate.py + python3 .github/scripts/test-release-candidate-live-smoke.py + python3 .github/scripts/test-live-migration-gate.py + python3 .github/scripts/test-devcontainers-compatibility-gate.py + python3 .github/scripts/test-act-compatibility-gate.py + python3 .github/scripts/test-testcontainers-compatibility-gate.py + python3 .github/scripts/test-localstack-compatibility-gate.py + python3 .github/scripts/test-tilt-compose-compatibility-gate.py + python3 .github/scripts/test-supabase-compatibility-gate.py + python3 .github/scripts/test-kubernetes-tooling-compatibility-gate.py + python3 .github/scripts/test-default-platform-image-gate.py + python3 .github/scripts/test-bind-file-coherence-gate.py + python3 .github/scripts/test-prune-safety-gate.py + python3 .github/scripts/test-data-drive-volume-identity-gate.py + python3 .github/scripts/test-data-disk-growth-gate.py + python3 .github/scripts/test-ecr-registry-retry-gate.py + python3 .github/scripts/test-private-registry-auth-gate.py + python3 .github/scripts/test-offline-bundled-boot-gate.py + python3 .github/scripts/test-competitor-runtime-regression-gate.py + python3 .github/scripts/test-managed-data-drive-gate.py + python3 .github/scripts/test-native-ipv6-gate.py + python3 .github/scripts/test-nonnative-exec-conformance-gate.py + python3 .github/scripts/test-nonnative-nix-gc-gate.py + python3 .github/scripts/test-nonnative-arch-pacman-gate.py + python3 .github/scripts/test-nonnative-mmdebstrap-gate.py + python3 .github/scripts/test-gvproxy-qemu-switch-gate.py + python3 .github/scripts/test-long-lived-network-soak.py + python3 .github/scripts/test-endurance-reliability-soak.py + python3 .github/scripts/test-release-candidate-qualifier.py + python3 .github/scripts/test-release-qualification-verifier.py - name: P0 smoke harness regression tests run: bash scripts/test-p0-smoke.sh - name: Dory app and offline quality suite run: bash scripts/ci-test.sh - name: Dory core Swift tests working-directory: dory-core-swift - run: swift test + run: swift test --no-parallel - name: DoryHV tests working-directory: Packages/ContainerizationEngine - run: swift test + run: SWT_EXPERIMENTAL_MAXIMUM_PARALLELIZATION_WIDTH=1 swift test --no-parallel - name: Dory UI tests run: | ui_derived_data="$RUNNER_TEMP/dory-ui-derived-data" @@ -347,14 +508,15 @@ jobs: CODE_SIGN_IDENTITY=- release_candidate: name: Build, sign, notarize, and stage immutable candidate - needs: [rust-workspace, prepublication-quality, guest-assets-arm64] + needs: [release-metadata, rust-workspace, prepublication-quality, guest-assets-arm64] # Publication is intentionally bound to the dedicated physical Apple-silicon release host. # Hosted/nested runners are not eligible. Dory launches from one clean v1 data-drive schema; # pre-release Dory formats are not release fixtures. runs-on: [self-hosted, macOS, arm64, dory, release] timeout-minutes: 720 outputs: - version: ${{ steps.ver.outputs.version }} + version: ${{ needs.release-metadata.outputs.version }} + build: ${{ needs.release-metadata.outputs.build }} sha256: ${{ steps.build.outputs.sha256 }} dmg: ${{ steps.build.outputs.dmg }} zip: ${{ steps.build.outputs.zip }} @@ -375,9 +537,20 @@ jobs: path: guest/out - name: Independently verify every downloaded guest payload run: | + zstd -q -d --sparse -f guest/out/Image-desktop.zst -o guest/out/Image-desktop + for distro in debian ubuntu kali; do + zstd -q -d --sparse -f \ + "guest/out/dory-desktop-$distro-rootfs-arm64.ext4.zst" \ + -o "guest/out/dory-desktop-$distro-rootfs-arm64.ext4" + done DORY_EXPERIMENTAL_GPU=0 guest/kernel/verify-build.sh arm64 DORY_EXPERIMENTAL_GPU=1 guest/kernel/verify-build.sh arm64 guest/initfs/verify-build.sh arm64 + DORY_KERNEL_PROFILE=accelerated-desktop guest/kernel/verify-build.sh arm64 + guest/mesa/verify-build.sh arm64 + for distro in debian ubuntu kali; do + guest/desktop/verify-build.sh arm64 "$distro" + done - name: Prove the tracked release source exactly matches the commit run: | @@ -385,25 +558,6 @@ jobs: test -z "$(git status --porcelain --untracked-files=no)" git rev-parse HEAD | grep -qx "$GITHUB_SHA" - - name: Resolve version - id: ver - run: | - if [ -n "${{ github.event.inputs.version }}" ]; then - [ "$GITHUB_REF" = refs/heads/main ] || { - echo "Manual public releases must run from main, not $GITHUB_REF" >&2 - exit 1 - } - V="${{ github.event.inputs.version }}" - else - V="${GITHUB_REF_NAME#v}" - fi - git fetch --no-tags origin main - git merge-base --is-ancestor "$GITHUB_SHA" origin/main || { - echo "Release commit $GITHUB_SHA is not reachable from main" >&2 - exit 1 - } - echo "version=$V" >> "$GITHUB_OUTPUT" - - name: Select the pinned Xcode 26.6 release toolchain run: | xcode_app=/Applications/Xcode-26.6.0-Release.Candidate.app @@ -432,12 +586,18 @@ jobs: - name: Ensure Metal toolchain (SwiftTerm ships Metal shaders) run: xcodebuild -downloadComponent MetalToolchain || true + - name: Install pinned GPU renderer build prerequisites + run: brew install meson ninja pkgconf molten-vk libepoxy + - name: Preserve previous released appcast history + env: + PREVIOUS_RELEASE_VERSION: ${{ needs.release-metadata.outputs.previous_version }} run: | previous="$RUNNER_TEMP/previous-appcast.xml" - if curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 60 \ - https://github.com/Augani/dory/releases/latest/download/appcast.xml \ - -o "$previous"; then + if [ -n "$PREVIOUS_RELEASE_VERSION" ]; then + curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 60 \ + "https://github.com/Augani/dory/releases/download/v$PREVIOUS_RELEASE_VERSION/appcast.xml" \ + -o "$previous" python3 - "$previous" <<'PY' import sys import xml.etree.ElementTree as ET @@ -448,10 +608,11 @@ jobs: ET.register_namespace("dory", dory) tree = ET.parse(sys.argv[1]) items = tree.getroot().findall("./channel/item") - assert items, "previous release appcast has no item" + if not items: + raise SystemExit("previous release appcast has no item") for item in items: - assert item.findtext(f"{{{sparkle}}}minimumSystemVersion") == "14.0", \ - "previous release appcast would regress the macOS 14 floor" + if item.findtext(f"{{{sparkle}}}minimumSystemVersion") != "14.0": + raise SystemExit("previous release appcast would regress the macOS 14 floor") # Releases before 0.4 all use data/component schema 1. Normalize their # historical items once so the first transactional updater can enforce an # exact contract without dropping the signed enclosure history. @@ -463,7 +624,7 @@ jobs: PY cp "$previous" website/public/appcast.xml else - echo "No prior release appcast asset exists; using the checked-in bootstrap history." + echo "No prior stable release exists; using the checked-in bootstrap history." fi - name: Import Developer ID certificate @@ -500,17 +661,18 @@ jobs: DORY_RELEASE_VARIANTS: 'arm64' DORY_BUILD_APPCAST: '1' DORY_BUILD_APP_UPDATE: '1' - DORY_RELEASE_ASSET_BASE_URL: https://github.com/Augani/dory/releases/download/v${{ steps.ver.outputs.version }} + DORY_COMPONENT_CATALOG_SCHEMA: '2' + DORY_RELEASE_ASSET_BASE_URL: https://github.com/Augani/dory/releases/download/v${{ inputs.version }} DORY_SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY || secrets.SPARKLE_ED_PRIVATE_KEY }} DORY_RELEASE_SOURCE_COMMIT: ${{ github.sha }} - run: scripts/release.sh "${{ steps.ver.outputs.version }}" "${{ github.run_number }}" + run: scripts/release.sh "${{ inputs.version }}" "${{ inputs.build }}" - name: Validate public release outputs run: | scripts/validate-release-outputs.sh \ release-build \ - "${{ steps.ver.outputs.version }}" \ - "${{ github.run_number }}" + "${{ inputs.version }}" \ + "${{ inputs.build }}" - name: Extract the exact signed Sparkle update candidate id: sparkle_candidate @@ -545,9 +707,13 @@ jobs: with open(manifest_path, encoding="utf-8") as handle: manifest = json.load(handle) expected_name = pathlib.Path(archive_path).name - records = {record["name"]: record for record in manifest["artifacts"]} - record = records[expected_name] - assert record["sha256"] == digest.hexdigest(), "Sparkle candidate ZIP differs from release manifest" + artifacts = manifest.get("artifacts") if isinstance(manifest, dict) else None + if not isinstance(artifacts, list): + raise SystemExit("release manifest artifact list is invalid") + matches = [record for record in artifacts + if isinstance(record, dict) and record.get("name") == expected_name] + if len(matches) != 1 or matches[0].get("sha256") != digest.hexdigest(): + raise SystemExit("Sparkle candidate ZIP differs from release manifest") PY ditto -x -k "$UPDATE_ZIP" "$candidate_root/extracted" test -d "$candidate_root/extracted/Dory.app" @@ -570,8 +736,10 @@ jobs: with open("Dory.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", encoding="utf-8") as handle: payload = json.load(handle) pins = [pin for pin in payload["pins"] if pin["identity"] == "sparkle"] - assert len(pins) == 1, "expected one Sparkle package pin" - assert pins[0]["state"].get("version") == "2.9.4", "unexpected Sparkle release pin" + if len(pins) != 1: + raise SystemExit("expected one Sparkle package pin") + if pins[0]["state"].get("version") != "2.9.4": + raise SystemExit("unexpected Sparkle release pin") print(pins[0]["state"]["revision"]) PY )" @@ -591,7 +759,7 @@ jobs: printf 'path=%s\n' "$sparkle_source" >> "$GITHUB_OUTPUT" - name: Exercise the notarized direct-download candidate on a clean physical Mac - timeout-minutes: 30 + timeout-minutes: 60 env: DORY_RELEASE_CLEAN_USER: '1' DORY_RELEASE_EXTERNAL_VOLUME_ROOT: ${{ vars.DORY_EXTERNAL_VOLUME_TEST_ROOT }} @@ -604,13 +772,22 @@ jobs: DORY_RELEASE_CORPORATE_VPN_PROBE_HOST: ${{ vars.DORY_CORPORATE_VPN_PROBE_HOST }} DORY_RELEASE_CORPORATE_VPN_PROBE_URL: ${{ vars.DORY_CORPORATE_VPN_PROBE_URL }} DORY_RELEASE_TAILSCALE_EXIT_NODE: ${{ vars.DORY_TAILSCALE_EXIT_NODE }} + DORY_RELEASE_COMPONENT_DIR: ${{ github.workspace }}/release-build/components/arm64 + DORY_RELEASE_DESKTOP_KERNEL: ${{ github.workspace }}/guest/out/Image-desktop + DORY_RELEASE_DESKTOP_DEBIAN_ROOTFS: ${{ github.workspace }}/guest/out/dory-desktop-debian-rootfs-arm64.ext4 + DORY_RELEASE_DESKTOP_UBUNTU_ROOTFS: ${{ github.workspace }}/guest/out/dory-desktop-ubuntu-rootfs-arm64.ext4 + DORY_RELEASE_DESKTOP_KALI_ROOTFS: ${{ github.workspace }}/guest/out/dory-desktop-kali-rootfs-arm64.ext4 + DORY_RELEASE_DESKTOP_DEBIAN_UPDATE: ${{ github.workspace }}/guest/out/dory-desktop-debian-update-arm64.tar + DORY_RELEASE_DESKTOP_UBUNTU_UPDATE: ${{ github.workspace }}/guest/out/dory-desktop-ubuntu-update-arm64.tar + DORY_RELEASE_DESKTOP_KALI_UPDATE: ${{ github.workspace }}/guest/out/dory-desktop-kali-update-arm64.tar + DORY_RELEASE_DESKTOP_VERSION: ${{ inputs.version }} run: | scripts/direct-dmg-install-gate.sh \ --dmg "${{ steps.build.outputs.dmg }}" \ - --sbom "release-build/Dory-${{ steps.ver.outputs.version }}.cdx.json" \ + --sbom "release-build/Dory-${{ inputs.version }}.cdx.json" \ --release-manifest release-build/release-manifest.json \ - --version "${{ steps.ver.outputs.version }}" \ - --build "${{ github.run_number }}" \ + --version "${{ inputs.version }}" \ + --build "${{ inputs.build }}" \ --source-commit "$GITHUB_SHA" \ --workroot "$RUNNER_TEMP/dory-release-direct-dmg" \ --confirm CLEAN-RELEASE-USER-DMG-INSTALL @@ -625,10 +802,10 @@ jobs: --update-zip "${{ steps.build.outputs.app_update }}" \ --appcast release-build/appcast.xml \ --release-manifest release-build/release-manifest.json \ - --sbom "release-build/Dory-${{ steps.ver.outputs.version }}.cdx.json" \ + --sbom "release-build/Dory-${{ inputs.version }}.cdx.json" \ --sparkle-source "${{ steps.sparkle_source.outputs.path }}" \ - --version "${{ steps.ver.outputs.version }}" \ - --build "${{ github.run_number }}" \ + --version "${{ inputs.version }}" \ + --build "${{ inputs.build }}" \ --source-commit "$GITHUB_SHA" \ --signing-identity "Developer ID Application" \ --workroot "$RUNNER_TEMP/dory-release-live-sparkle" \ @@ -649,8 +826,8 @@ jobs: scripts/interrupted-upgrade-rollback-gate.sh \ --candidate-app "${{ steps.sparkle_candidate.outputs.app }}" \ --sign-update "$sign_update" \ - --version "${{ steps.ver.outputs.version }}" \ - --build "${{ github.run_number }}" \ + --version "${{ inputs.version }}" \ + --build "${{ inputs.build }}" \ --source-commit "$GITHUB_SHA" \ --fixture-image "$DORY_RELEASE_FIXTURE_IMAGE" \ --signing-identity "Developer ID Application" \ @@ -709,7 +886,7 @@ jobs: scripts/homebrew-install-gate.sh \ --candidate-dir release-build \ --version "$VERSION" \ - --build "${{ github.run_number }}" \ + --build "${{ inputs.build }}" \ --source-commit "$GITHUB_SHA" \ --workroot "$RUNNER_TEMP/dory-homebrew-install" \ --confirm CLEAN-RELEASE-USER-HOMEBREW-INSTALL @@ -764,12 +941,12 @@ jobs: scripts/qualify-release-candidate.sh \ --build-dir release-build \ --version "${{ needs.release_candidate.outputs.version }}" \ - --build "${{ github.run_number }}" \ + --build "${{ inputs.build }}" \ --source-commit "${{ github.sha }}" \ --confirm QUALIFY-EXACT-DORY-RELEASE - performance_qualification: - name: Exact candidate isolated and interleaved performance evidence + container_engine_performance_qualification: + name: Exact candidate container-engine performance evidence needs: release_candidate runs-on: [self-hosted, macOS, arm64, dory, benchmark] timeout-minutes: 720 @@ -784,17 +961,17 @@ jobs: with: name: dory-release-candidate-${{ github.sha }}-${{ github.run_attempt }} path: release-build - - name: Run exact-candidate clean-account campaign + - name: Run exact-candidate clean-account container-engine campaign env: DORY_RELEASE_CLEAN_USER: '1' DORY_RELEASE_BENCHMARK_USER: '1' run: | - scripts/qualify-release-performance.sh \ + scripts/qualify-container-engine-performance.sh \ --candidate-dir release-build \ --version "${{ needs.release_candidate.outputs.version }}" \ - --build "${{ github.run_number }}" \ + --build "${{ inputs.build }}" \ --source-commit "$GITHUB_SHA" \ - --workroot "$RUNNER_TEMP/dory-release-performance" \ + --workroot "$RUNNER_TEMP/dory-container-engine-performance" \ --alpine-image "${{ vars.DORY_RELEASE_ALPINE_IMAGE }}" \ --iperf-image "${{ vars.DORY_BENCH_IPERF_IMAGE }}" \ --node-image "${{ vars.DORY_BENCH_NODE_IMAGE }}" \ @@ -807,14 +984,14 @@ jobs: --download-url "${{ vars.DORY_BENCH_DOWNLOAD_URL }}" \ --download-bytes "${{ vars.DORY_BENCH_DOWNLOAD_BYTES }}" \ --confirm CLEAN-BENCHMARK-USER-DELETE-ENGINE-DATA - - name: Retain exact performance evidence for publication + - name: Retain exact container-engine performance evidence for publication uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: dory-performance-evidence-${{ github.sha }}-${{ github.run_attempt }} + name: dory-container-engine-performance-evidence-${{ github.sha }}-${{ github.run_attempt }} retention-days: 90 if-no-files-found: error compression-level: 0 - path: ${{ runner.temp }}/dory-release-performance/Dory-${{ needs.release_candidate.outputs.version }}-performance-evidence.zip + path: ${{ runner.temp }}/dory-container-engine-performance/Dory-${{ needs.release_candidate.outputs.version }}-container-engine-performance-evidence.zip sonoma_vz_certification: name: Exact candidate macOS 14 VZ + IPv6 + LAN/Tailscale source certification @@ -874,7 +1051,7 @@ jobs: ssh-add -L >/dev/null scripts/vz-native-ipv6-gate.sh \ --dory-vmm "$SONOMA_APP/Contents/Helpers/dory-vmm" \ - --dory-hv "$SONOMA_APP/Contents/Helpers/dory-hv" \ + --dory-hv "$SONOMA_APP/Contents/Helpers/DoryHVRunner.app/Contents/MacOS/dory-hv" \ --gvproxy "$SONOMA_APP/Contents/Helpers/gvproxy" \ --gvproxy-provenance "$SONOMA_APP/Contents/Resources/gvproxy-provenance.txt" \ --payload-inventory "$SONOMA_APP/Contents/Resources/dory-payload-sha256.txt" \ @@ -1043,7 +1220,7 @@ jobs: publish_release: name: Publish only the exact qualified candidate - needs: [release_candidate, release_qualification, performance_qualification, sonoma_vz_certification, source_preserving_lan_certification, homebrew_cask_audit, homebrew_install_certification] + needs: [release_candidate, release_qualification, container_engine_performance_qualification, sonoma_vz_certification, source_preserving_lan_certification, homebrew_cask_audit, homebrew_install_certification] runs-on: [self-hosted, macOS, arm64, dory, release] timeout-minutes: 120 permissions: @@ -1083,24 +1260,24 @@ jobs: with: name: dory-homebrew-install-evidence-${{ github.sha }}-${{ github.run_attempt }} path: homebrew-install-evidence - - name: Download exact-candidate performance evidence + - name: Download exact-candidate container-engine performance evidence uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: dory-performance-evidence-${{ github.sha }}-${{ github.run_attempt }} - path: performance-evidence - - name: Verify performance evidence binding and internal digests + name: dory-container-engine-performance-evidence-${{ github.sha }}-${{ github.run_attempt }} + path: container-engine-performance-evidence + - name: Verify container-engine performance evidence binding and internal digests env: VERSION: ${{ needs.release_candidate.outputs.version }} - BUILD: ${{ github.run_number }} + BUILD: ${{ inputs.build }} run: | set -euo pipefail - archive="performance-evidence/Dory-$VERSION-performance-evidence.zip" + archive="container-engine-performance-evidence/Dory-$VERSION-container-engine-performance-evidence.zip" test -s "$archive" root="$RUNNER_TEMP/dory-performance-publication" rm -rf "$root" mkdir -p "$root" unzip -q "$archive" -d "$root" - evidence="$root/Dory-$VERSION-performance-evidence" + evidence="$root/Dory-$VERSION-container-engine-performance-evidence" test -s "$evidence/manifest.json" test -s "$evidence/sha256.txt" (cd "$evidence" && shasum -a 256 -c sha256.txt) @@ -1110,31 +1287,37 @@ jobs: import hashlib, json, pathlib, sys manifest_path, release_path, update_path, sbom_path, version, build, commit = sys.argv[1:] manifest = json.loads(pathlib.Path(manifest_path).read_text(encoding="utf-8")) - assert manifest == {**manifest}, "performance manifest must be an object" - assert manifest["schemaVersion"] == 1 - assert manifest["kind"] == "dev.dory.performance-qualification" - assert manifest["status"] == "PASS" and manifest["releaseQualifying"] is True + def require(condition, message): + if not condition: + raise SystemExit(message) + require(isinstance(manifest, dict), "performance manifest must be an object") + require(manifest.get("schemaVersion") == 1, "performance manifest schema mismatch") + require(manifest.get("kind") == "dev.dory.container-engine-performance-qualification", + "container-engine performance manifest kind mismatch") + require(manifest.get("status") == "PASS" and manifest.get("releaseQualifying") is True, + "performance qualification did not pass") candidate = manifest["candidate"] - assert candidate["version"] == version - assert candidate["build"] == build - assert candidate["sourceCommit"] == commit + require(isinstance(candidate, dict), "performance candidate binding is invalid") + require(candidate.get("version") == version, "performance candidate version mismatch") + require(candidate.get("build") == build, "performance candidate build mismatch") + require(candidate.get("sourceCommit") == commit, "performance candidate source mismatch") def digest(path): value = hashlib.sha256() with open(path, "rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): value.update(chunk) return value.hexdigest() - assert candidate["releaseManifestSHA256"] == digest(release_path) - assert candidate["appUpdateSHA256"] == digest(update_path) - assert candidate["sbomSHA256"] == digest(sbom_path) + require(candidate.get("releaseManifestSHA256") == digest(release_path), "performance release manifest mismatch") + require(candidate.get("appUpdateSHA256") == digest(update_path), "performance app update mismatch") + require(candidate.get("sbomSHA256") == digest(sbom_path), "performance SBOM mismatch") sbom = json.loads(pathlib.Path(sbom_path).read_text(encoding="utf-8")) values = [row["value"] for row in sbom["metadata"]["component"]["properties"] if row["name"] == "dev.dory.app.tree.sha256"] - assert values == [candidate["appTreeSHA256"]] - assert manifest["campaigns"] == [ + require(values == [candidate.get("appTreeSHA256")], "performance app tree mismatch") + require(manifest.get("campaigns") == [ "isolated", "user-workflows", "developer-workflows", "registry-npm", "external-network" - ] - assert manifest["cleanup"] == "PASS" + ], "performance campaign set mismatch") + require(manifest.get("cleanup") == "PASS", "performance cleanup did not pass") PY - name: Verify Homebrew audit evidence binding env: @@ -1176,7 +1359,7 @@ jobs: python3 - \ homebrew-install-evidence/manifest.txt \ "$GITHUB_SHA" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" \ - "$VERSION" "${{ github.run_number }}" "$ARM64_SHA256" <<'PY' + "$VERSION" "${{ inputs.build }}" "$ARM64_SHA256" <<'PY' import sys path, commit, run_id, attempt, version, build, digest = sys.argv[1:8] @@ -1211,7 +1394,7 @@ jobs: - name: Verify Sparkle install evidence binding env: VERSION: ${{ needs.release_candidate.outputs.version }} - BUILD: ${{ github.run_number }} + BUILD: ${{ inputs.build }} run: | set -euo pipefail manifests="$(find live-release-evidence -path '*/dory-release-live-sparkle/*/evidence/manifest.txt' -type f -print)" @@ -1239,7 +1422,7 @@ jobs: - name: Verify interrupted transactional-upgrade evidence binding env: VERSION: ${{ needs.release_candidate.outputs.version }} - BUILD: ${{ github.run_number }} + BUILD: ${{ inputs.build }} run: | set -euo pipefail manifests="$(find live-release-evidence \ @@ -1253,7 +1436,8 @@ jobs: payload = json.load(open(sys.argv[1], encoding="utf-8")) rows = payload["metadata"]["component"]["properties"] values = [row["value"] for row in rows if row["name"] == "dev.dory.app.tree.sha256"] - assert len(values) == 1 + if len(values) != 1: + raise SystemExit("release SBOM must contain exactly one app-tree digest") print(values[0]) PY )" @@ -1263,21 +1447,30 @@ jobs: values = {} for line in open(path, encoding="utf-8"): key, separator, value = line.rstrip("\n").partition("=") - assert separator and key not in values, line + if not separator or not key or key in values: + raise SystemExit(f"invalid or duplicate evidence row: {line.rstrip()}") values[key] = value - assert values["status"] == "PASS" - assert values["release_qualifying"] == "true" - assert values["source_commit"] == commit - assert values["candidate_version"] == version - assert values["candidate_build"] == build - assert values["candidate_tree_sha256"] == tree + expected = { + "status": "PASS", + "release_qualifying": "true", + "source_commit": commit, + "candidate_version": version, + "candidate_build": build, + "candidate_tree_sha256": tree, + "component_catalog_schema": "2", + } + for key, expected_value in expected.items(): + if values.get(key) != expected_value: + raise SystemExit(f"interruption evidence mismatch for {key}") for key in ( - "exact_last_good_app_restored", "signed_component_generation_restored", + "exact_last_good_app_restored", "component_catalog_signatures_verified", + "component_qualification_authority", "signed_component_generation_restored", "durable_data_not_downgraded", "durable_volume_sentinel_preserved", "preexisting_container_preserved", "published_port_preserved", "exact_smoke_failure_retained", "initial_clean_user_state_restored", ): - assert values[key] == "PASS", key + if values.get(key) != "PASS": + raise SystemExit(f"interruption proof did not pass: {key}") PY scripts/transactional-upgrade-gate.sh \ --record "$evidence/transaction.json" \ @@ -1293,7 +1486,7 @@ jobs: manifest="$(printf '%s\n' "$manifests" | awk 'NF { print; exit }')" dmg_sha="$(shasum -a 256 "release-build/Dory-$VERSION.dmg" | awk '{print $1}')" python3 - "$manifest" "$GITHUB_SHA" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" \ - "$VERSION" "${{ github.run_number }}" "$dmg_sha" <<'PY' + "$VERSION" "${{ inputs.build }}" "$dmg_sha" <<'PY' import sys path, commit, run_id, attempt, version, build, digest = sys.argv[1:8] @@ -1352,7 +1545,7 @@ jobs: - name: Verify durable qualification and candidate digest binding env: VERSION: ${{ needs.release_candidate.outputs.version }} - BUILD: ${{ github.run_number }} + BUILD: ${{ inputs.build }} SOURCE_COMMIT: ${{ github.sha }} PRIMARY_SHA256: ${{ needs.release_candidate.outputs.sha256 }} run: | @@ -1387,6 +1580,10 @@ jobs: codesign --verify --strict --deep "$extracted/Dory.app" codesign -dv --verbose=4 "$extracted/Dory.app" 2>&1 \ | grep -q 'Authority=Developer ID Application' + python3 scripts/renderer-release-identity.py verify \ + --runner-app "$extracted/Dory.app/Contents/Helpers/DoryHVRunner.app" \ + --doryd "$extracted/Dory.app/Contents/Helpers/doryd" \ + --expected-team 864H636QW4 xcrun stapler validate "$extracted/Dory.app" spctl --assess --type execute --verbose=4 "$extracted/Dory.app" \ > "$RUNNER_TEMP/dory-publication-gatekeeper.txt" 2>&1 @@ -1415,11 +1612,11 @@ jobs: done grep -qx 'architecture=arm64' "$vz_manifest" grep -qx 'release_qualifying=true' "$vz_manifest" - grep -qx 'gvproxy_version=v0.8.9-dory1' "$vz_manifest" + grep -qx 'gvproxy_version=v0.8.9-dory3' "$vz_manifest" gvproxy_sha="$(shasum -a 256 "$extracted/Dory.app/Contents/Helpers/gvproxy" | awk '{print $1}')" grep -qx "gvproxy_sha256=$gvproxy_sha" "$vz_manifest" - grep -qx 'gvproxy_build_sha256=bd9183f5dbe2bd27d7ea57f2f2dd4d5ce26487eeb1fa8c82cd81bad4df50e0c0' "$vz_manifest" - grep -qx 'verified_sha256=bd9183f5dbe2bd27d7ea57f2f2dd4d5ce26487eeb1fa8c82cd81bad4df50e0c0' \ + grep -qx 'gvproxy_build_sha256=56e0cde99ff2b589e467294145d66796ab4d990ae89a3633b0ef037dfcba03cd' "$vz_manifest" + grep -qx 'verified_sha256=56e0cde99ff2b589e467294145d66796ab4d990ae89a3633b0ef037dfcba03cd' \ "$extracted/Dory.app/Contents/Resources/gvproxy-provenance.txt" helper_sha="$(shasum -a 256 "$extracted/Dory.app/Contents/Helpers/dory-vmm" | awk '{print $1}')" grep -qx "dory_vmm_sha256=$helper_sha" "$vz_manifest" @@ -1437,7 +1634,7 @@ jobs: test ! -s "$(dirname "$vz_manifest")/new-host-panic-reports.txt" lan_manifests="$(find source-lan-evidence -type f -name manifest.txt -print)" test "$(printf '%s\n' "$lan_manifests" | awk 'NF { count++ } END { print count + 0 }')" = 2 - hv_sha="$(shasum -a 256 "$extracted/Dory.app/Contents/Helpers/dory-hv" | awk '{print $1}')" + hv_sha="$(shasum -a 256 "$extracted/Dory.app/Contents/Helpers/DoryHVRunner.app/Contents/MacOS/dory-hv" | awk '{print $1}')" for mode in lan tailscale; do manifest="$(printf '%s\n' "$lan_manifests" | while IFS= read -r candidate; do grep -qx "mode=$mode" "$candidate" && printf '%s\n' "$candidate"; done)" test "$(printf '%s\n' "$manifest" | awk 'NF { count++ } END { print count + 0 }')" = 1 @@ -1457,7 +1654,7 @@ jobs: grep -qx "app_executable_sha256=$app_sha" "$manifest" grep -qx "dory_hv_sha256=$hv_sha" "$manifest" grep -qx "gvproxy_sha256=$gvproxy_sha" "$manifest" - grep -qx 'gvproxy_build_sha256=bd9183f5dbe2bd27d7ea57f2f2dd4d5ce26487eeb1fa8c82cd81bad4df50e0c0' "$manifest" + grep -qx 'gvproxy_build_sha256=56e0cde99ff2b589e467294145d66796ab4d990ae89a3633b0ef037dfcba03cd' "$manifest" grep -Eq '^observed_source_ipv4=([0-9]{1,3}\.){3}[0-9]{1,3}$' "$manifest" grep -Eq '^server_image=.+@sha256:[0-9a-f]{64}$' "$manifest" grep -qx 'memory_pressure_mib=960' "$manifest" @@ -1478,7 +1675,7 @@ jobs: - name: Package stable reliability evidence env: VERSION: ${{ needs.release_candidate.outputs.version }} - BUILD: ${{ github.run_number }} + BUILD: ${{ inputs.build }} SOURCE_COMMIT: ${{ github.sha }} PRIMARY_SHA256: ${{ needs.release_candidate.outputs.sha256 }} run: | @@ -1539,80 +1736,246 @@ jobs: release-build/components/arm64/catalog.json.sig if-no-files-found: error - - name: Publish GitHub Release - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 - with: - tag_name: v${{ needs.release_candidate.outputs.version }} - name: Dory ${{ needs.release_candidate.outputs.version }} - files: | - release-build/Dory-${{ needs.release_candidate.outputs.version }}-arm64.zip - release-build/Dory-${{ needs.release_candidate.outputs.version }}.zip - release-build/Dory-${{ needs.release_candidate.outputs.version }}-arm64.dmg - release-build/Dory-${{ needs.release_candidate.outputs.version }}.dmg - release-build/Dory-${{ needs.release_candidate.outputs.version }}-app-update.zip - release-build/dory-engine-${{ needs.release_candidate.outputs.version }}-arm64.tar.gz - release-build/Dory-${{ needs.release_candidate.outputs.version }}.cdx.json - release-build/release-manifest.json - release-build/appcast.xml - release-build/components/arm64/* - performance-evidence/Dory-${{ needs.release_candidate.outputs.version }}-performance-evidence.zip - ${{ runner.temp }}/Dory-${{ needs.release_candidate.outputs.version }}-reliability-evidence.zip + - name: Revalidate tag and release absence immediately before publication + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.release_candidate.outputs.version }} + BUILD: ${{ needs.release_candidate.outputs.build }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + git fetch --force origin main + test "$GITHUB_SHA" = "$(git rev-parse origin/main)" || { + echo "Qualified commit $GITHUB_SHA is no longer the exact current origin/main commit" >&2 + exit 1 + } + python3 .github/scripts/verify-release-identity.py \ + --repository "$GITHUB_REPOSITORY" \ + --project Dory.xcodeproj/project.pbxproj \ + --version "$VERSION" \ + --build "$BUILD" + prove_absent() { + local endpoint="$1" label="$2" output="$RUNNER_TEMP/publication-$3.json" status + status="$(curl -sS --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 60 \ + -H 'Accept: application/vnd.github+json' \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + -o "$output" -w '%{http_code}' \ + "https://api.github.com/repos/$GITHUB_REPOSITORY/$endpoint")" + case "$status" in + 404) ;; + 200) echo "$label already exists immediately before publication" >&2; exit 1 ;; + *) echo "could not prove $label is absent (GitHub HTTP $status)" >&2; exit 1 ;; + esac + } + prove_absent "git/ref/tags/v$VERSION" "tag v$VERSION" tag + prove_absent "releases/tags/v$VERSION" "release v$VERSION" release + - name: Create a private draft, verify its exact assets, then publish it + id: published_release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.release_candidate.outputs.version }} + BUILD: ${{ needs.release_candidate.outputs.build }} + PRIMARY_SHA256: ${{ needs.release_candidate.outputs.sha256 }} + run: | + set -euo pipefail + body="$RUNNER_TEMP/dory-release-body.md" + cat > "$body" <<'EOF' + Native Docker & Linux containers for Apple Silicon, a free, open-source alternative to + OrbStack and Docker Desktop. Intel support is planned after the Apple Silicon production + contract is complete. + + **0.4 trust release** + + - Supported dedicated-VM agent sandboxes with enforced egress, scoped mounts and + credentials, non-root execution, resource caps, TTL cleanup, and rollback. + - Reason-coded staged readiness, bounded targeted repairs, incident provenance, and + attributed resource/storage/network diagnostics. + - Guided corporate proxy, registry CA, split-DNS, VPN, and route reconciliation. + - Transactional Sparkle/component upgrades with next-launch smoke tests, automatic + last-known-good rollback, and an export route when durable schema rollback is unsafe. + - Build Activity with durable Dory-launched build history, logs, cache visibility, and + cancellation; exact-selection transactional migration with completeness evidence. + - Verified scheduled local machine recovery bundles with retention and periodic + disposable boot proof, isolated from manual snapshots. + - Explicit-scope confirmation or recoverable undo for destructive UI, keyboard, menu, + CLI, migration, cleanup, component, and missing-drive paths. + - Exact-candidate physical, duration, compatibility, migration, update, security, and + container-engine performance evidence bound to the shipped manifest and SBOM. + + **Apple Silicon downloads** + + | Asset | What it is | + |---|---| + | `Dory-@VERSION@-arm64.dmg` / `.zip` | Full app optimized for Apple silicon | + | `Dory-@VERSION@.dmg` / `.zip` | Compatibility alias for the arm64 build | + | `dory-engine-@VERSION@-arm64.tar.gz` | Headless engine runtime, no GUI — `./dory-engine start`, then `docker context use dory-engine` | + | `Dory-@VERSION@.cdx.json` | CycloneDX 1.6 SBOM for the exact shipped app tree | + | `Dory-@VERSION@-container-engine-performance-evidence.zip` | Raw isolated/interleaved container-engine benchmark data, provenance, correctness evidence, and generated summaries; not Linux VM qualification | + | `Dory-@VERSION@-reliability-evidence.zip` | Candidate-bound eight-hour resource/file/API and 25-hour unchanged-connection qualification records | + + **Install** + + ```sh + brew install --cask Augani/dory/dory + ``` + + …or download the matching `.dmg` below and drag Dory to Applications. + + Dory.app and its built-in engine run on macOS 14 Sonoma or later. Sonoma uses the + bundled Virtualization.framework `dory-vmm` tier; supported macOS 15+ hosts use + Dory's raw `dory-hv` tier. An existing Docker-compatible engine remains selectable + from Settings → Engine Backend. + + ``` + arm64 zip sha256: @PRIMARY_SHA256@ + ``` + + Read next: `README.md`, `CHANGELOG.md`, `COMPATIBILITY.md`, and the documentation at + https://augani.github.io/dory/docs/. + EOF + python3 - "$body" "$VERSION" "$PRIMARY_SHA256" <<'PY' + import pathlib + import sys + + path = pathlib.Path(sys.argv[1]) + rendered = path.read_text(encoding="utf-8") + rendered = rendered.replace("@VERSION@", sys.argv[2]) + rendered = rendered.replace("@PRIMARY_SHA256@", sys.argv[3]) + path.write_text(rendered, encoding="utf-8") + PY + python3 .github/scripts/publish-github-release.py \ + --repository "$GITHUB_REPOSITORY" \ + --version "$VERSION" \ + --build "$BUILD" \ + --project Dory.xcodeproj/project.pbxproj \ + --source-commit "$GITHUB_SHA" \ + --name "Dory $VERSION" \ + --body-file "$body" \ + --github-output "$GITHUB_OUTPUT" \ + release-build/Dory-${{ needs.release_candidate.outputs.version }}-arm64.zip \ + release-build/Dory-${{ needs.release_candidate.outputs.version }}.zip \ + release-build/Dory-${{ needs.release_candidate.outputs.version }}-arm64.dmg \ + release-build/Dory-${{ needs.release_candidate.outputs.version }}.dmg \ + release-build/Dory-${{ needs.release_candidate.outputs.version }}-app-update.zip \ + release-build/dory-engine-${{ needs.release_candidate.outputs.version }}-arm64.tar.gz \ + release-build/Dory-${{ needs.release_candidate.outputs.version }}.cdx.json \ + release-build/release-manifest.json \ + release-build/appcast.xml \ + release-build/components/arm64/* \ + container-engine-performance-evidence/Dory-${{ needs.release_candidate.outputs.version }}-container-engine-performance-evidence.zip \ + ${{ runner.temp }}/Dory-${{ needs.release_candidate.outputs.version }}-reliability-evidence.zip \ ${{ runner.temp }}/Dory-${{ needs.release_candidate.outputs.version }}-reliability-evidence.zip.sha256 - fail_on_unmatched_files: true - generate_release_notes: true - body: | - Native Docker & Linux containers for Apple Silicon, a free, open-source alternative to - OrbStack and Docker Desktop. Intel support is planned after the Apple Silicon production - contract is complete. - - **0.4 trust release** - - - Supported dedicated-VM agent sandboxes with enforced egress, scoped mounts and - credentials, non-root execution, resource caps, TTL cleanup, and rollback. - - Reason-coded staged readiness, bounded targeted repairs, incident provenance, and - attributed resource/storage/network diagnostics. - - Guided corporate proxy, registry CA, split-DNS, VPN, and route reconciliation. - - Transactional Sparkle/component upgrades with next-launch smoke tests, automatic - last-known-good rollback, and an export route when durable schema rollback is unsafe. - - Build Activity with durable Dory-launched build history, logs, cache visibility, and - cancellation; exact-selection transactional migration with completeness evidence. - - Verified scheduled local machine recovery bundles with retention and periodic - disposable boot proof, isolated from manual snapshots. - - Explicit-scope confirmation or recoverable undo for destructive UI, keyboard, menu, - CLI, migration, cleanup, component, and missing-drive paths. - - Exact-candidate physical, duration, compatibility, migration, update, security, and - performance evidence bound to the shipped manifest and SBOM. - - **Apple Silicon downloads** - - | Asset | What it is | - |---|---| - | `Dory-${{ needs.release_candidate.outputs.version }}-arm64.dmg` / `.zip` | Full app optimized for Apple silicon | - | `Dory-${{ needs.release_candidate.outputs.version }}.dmg` / `.zip` | Compatibility alias for the arm64 build | - | `dory-engine-${{ needs.release_candidate.outputs.version }}-arm64.tar.gz` | Headless engine runtime, no GUI — `./dory-engine start`, then `docker context use dory-engine` | - | `Dory-${{ needs.release_candidate.outputs.version }}.cdx.json` | CycloneDX 1.6 SBOM for the exact shipped app tree | - | `Dory-${{ needs.release_candidate.outputs.version }}-performance-evidence.zip` | Raw isolated/interleaved benchmark data, provenance, correctness evidence, and generated summaries | - | `Dory-${{ needs.release_candidate.outputs.version }}-reliability-evidence.zip` | Candidate-bound eight-hour resource/file/API and 25-hour unchanged-connection qualification records | - - **Install** - - ```sh - brew install --cask Augani/dory/dory - ``` - - …or download the matching `.dmg` below and drag Dory to Applications. - - Dory.app and its built-in engine run on macOS 14 Sonoma or later. Sonoma uses the - bundled Virtualization.framework `dory-vmm` tier; supported macOS 15+ hosts use - Dory's raw `dory-hv` tier. An existing Docker-compatible engine remains selectable - from Settings → Engine Backend. - - ``` - arm64 zip sha256: ${{ needs.release_candidate.outputs.sha256 }} - ``` - - Read next: `README.md`, `CHANGELOG.md`, `COMPATIBILITY.md`, and the documentation at - https://augani.github.io/dory/docs/. + + - name: Prove the published release ref and exact asset set + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.release_candidate.outputs.version }} + RELEASE_ID: ${{ steps.published_release.outputs.id }} + run: | + set -euo pipefail + python3 - "$GITHUB_REPOSITORY" "$VERSION" "$GITHUB_SHA" "$RELEASE_ID" <<'PY' + import hashlib + import json + import os + import pathlib + import sys + import time + import urllib.error + import urllib.parse + import urllib.request + + repository, version, expected_commit, expected_release_id = sys.argv[1:] + token = os.environ.get("GH_TOKEN", "") + if not token: + raise SystemExit("publication verification has no GitHub token") + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "User-Agent": "Dory-release-publication-verifier", + "X-GitHub-Api-Version": "2022-11-28", + } + + def request(path): + url = f"https://api.github.com/repos/{repository}/{path}" + with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=60) as response: + return json.load(response) + + reference = request(f"git/ref/tags/{urllib.parse.quote('v' + version, safe='')}") + target = reference.get("object") if isinstance(reference, dict) else None + for _ in range(8): + if not isinstance(target, dict): + raise SystemExit("published tag has an invalid Git object") + if target.get("type") == "commit": + break + if target.get("type") != "tag" or not isinstance(target.get("sha"), str): + raise SystemExit("published tag does not resolve to a commit") + tag_object = request(f"git/tags/{target['sha']}") + target = tag_object.get("object") if isinstance(tag_object, dict) else None + else: + raise SystemExit("published tag indirection exceeds the safety bound") + if target.get("sha") != expected_commit: + raise SystemExit( + f"published tag resolves to {target.get('sha')}, expected qualified commit {expected_commit}" + ) + + root = pathlib.Path("release-build") + explicit = [ + root / f"Dory-{version}-arm64.zip", + root / f"Dory-{version}.zip", + root / f"Dory-{version}-arm64.dmg", + root / f"Dory-{version}.dmg", + root / f"Dory-{version}-app-update.zip", + root / f"dory-engine-{version}-arm64.tar.gz", + root / f"Dory-{version}.cdx.json", + root / "release-manifest.json", + root / "appcast.xml", + pathlib.Path("container-engine-performance-evidence") + / f"Dory-{version}-container-engine-performance-evidence.zip", + pathlib.Path(os.environ["RUNNER_TEMP"]) + / f"Dory-{version}-reliability-evidence.zip", + pathlib.Path(os.environ["RUNNER_TEMP"]) + / f"Dory-{version}-reliability-evidence.zip.sha256", + ] + explicit.extend(sorted((root / "components" / "arm64").glob("*"))) + expected = {} + def digest_file(path): + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + for path in explicit: + info = path.lstat() + if not path.is_file() or path.is_symlink() or info.st_size <= 0: + raise SystemExit(f"publication input is missing or indirect: {path}") + digest = digest_file(path) + if path.name in expected: + raise SystemExit(f"publication repeats asset name {path.name}") + expected[path.name] = (info.st_size, f"sha256:{digest}") + + release = None + for attempt in range(1, 13): + release = request(f"releases/tags/{urllib.parse.quote('v' + version, safe='')}") + assets = release.get("assets") if isinstance(release, dict) else None + actual = { + asset.get("name"): (asset.get("size"), asset.get("digest")) + for asset in assets or [] if isinstance(asset, dict) + } + if actual == expected: + break + if attempt == 12: + raise SystemExit(f"published release asset set differs: {actual!r} != {expected!r}") + time.sleep(5) + if str(release.get("id")) != expected_release_id: + raise SystemExit("published release ID differs from the action result") + if release.get("tag_name") != f"v{version}" or release.get("draft") or release.get("prerelease"): + raise SystemExit("published release identity is invalid") + if release.get("target_commitish") != expected_commit: + raise SystemExit("published release target_commitish differs from the qualified commit") + print(f"Published v{version} is exactly bound to {expected_commit} and {len(expected)} assets.") + PY - name: Remove runner-local qualification state after successful publication run: rm -rf "$QUALIFICATION" @@ -1621,6 +1984,8 @@ jobs: name: Publish generated appcast to the live Sparkle feed needs: publish_release runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} permissions: contents: read pages: write @@ -1633,6 +1998,8 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - name: Configure the job-scoped Sparkle authority cache + run: echo "DORY_SPARKLE_AUTHORITY_CACHE=$RUNNER_TEMP/dory-sparkle-authority" >> "$GITHUB_ENV" - name: Download metadata generated from the signed release uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -1645,6 +2012,7 @@ jobs: path: component-catalog-artifact - name: Validate generated live feed input run: | + set -euo pipefail test -s appcast-artifact/appcast.xml test -s component-catalog-artifact/catalog.json test -s component-catalog-artifact/catalog.json.sha256 @@ -1661,18 +2029,29 @@ jobs: sparkle = "http://www.andymatuschak.org/xml-namespaces/sparkle" dory = "https://augani.github.io/dory/appcast" item = ET.parse(path).getroot().find("./channel/item") - assert item is not None, "generated appcast has no current item" - assert item.findtext(f"{{{sparkle}}}shortVersionString") == version, "generated appcast version mismatch" - assert item.findtext(f"{{{sparkle}}}minimumSystemVersion") == "14.0", "generated appcast macOS floor mismatch" - assert item.findtext(f"{{{dory}}}dataSchemaVersion") == "1", "generated appcast data schema mismatch" - assert item.findtext(f"{{{dory}}}minimumReadableDataSchema") == "1", "generated appcast minimum readable schema mismatch" - assert item.findtext(f"{{{dory}}}maximumReadableDataSchema") == "1", "generated appcast maximum readable schema mismatch" - assert item.findtext(f"{{{dory}}}componentCatalogSchema") == "1", "generated appcast component schema mismatch" + def require(condition, message): + if not condition: + raise SystemExit(message) + require(item is not None, "generated appcast has no current item") + require(item.findtext(f"{{{sparkle}}}shortVersionString") == version, "generated appcast version mismatch") + require(item.findtext(f"{{{sparkle}}}minimumSystemVersion") == "14.0", "generated appcast macOS floor mismatch") + require(item.findtext(f"{{{dory}}}dataSchemaVersion") == "1", "generated appcast data schema mismatch") + require(item.findtext(f"{{{dory}}}minimumReadableDataSchema") == "1", "generated appcast minimum readable schema mismatch") + require(item.findtext(f"{{{dory}}}maximumReadableDataSchema") == "1", "generated appcast maximum readable schema mismatch") + require(item.findtext(f"{{{dory}}}componentCatalogSchema") == "2", "generated appcast component schema mismatch") enclosure = item.find("enclosure") - assert enclosure is not None, "generated appcast has no enclosure" + require(enclosure is not None, "generated appcast has no enclosure") name = os.path.basename(urllib.parse.urlparse(enclosure.attrib["url"]).path) - assert name == f"Dory-{version}-app-update.zip", f"generated appcast points at {name}" + require(name == f"Dory-{version}-app-update.zip", f"generated appcast points at {name}") PY + metadata="$RUNNER_TEMP/generated-release-metadata" + mkdir -p "$metadata/components/arm64" + cp appcast-artifact/appcast.xml "$metadata/appcast.xml" + for name in catalog.json catalog.json.sha256 catalog.json.sig; do + cp "component-catalog-artifact/$name" "$metadata/components/arm64/$name" + done + python3 .github/scripts/verify-pages-release-metadata.py \ + verify "$metadata" generated-release - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 @@ -1684,6 +2063,7 @@ jobs: working-directory: website - name: Overlay the exact release metadata onto the complete site run: | + set -euo pipefail test -d docs-build cp appcast-artifact/appcast.xml docs-build/appcast.xml cmp appcast-artifact/appcast.xml docs-build/appcast.xml @@ -1692,6 +2072,8 @@ jobs: cp "component-catalog-artifact/$name" "docs-build/components/arm64/$name" cmp "component-catalog-artifact/$name" "docs-build/components/arm64/$name" done + python3 .github/scripts/verify-pages-release-metadata.py \ + verify docs-build release-pages-artifact - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: @@ -1700,6 +2082,7 @@ jobs: uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 - name: Verify the public update metadata exactly matches this release run: | + set -euo pipefail live="$RUNNER_TEMP/live-release-metadata" rm -rf "$live" mkdir -p "$live/components/arm64" @@ -1718,6 +2101,8 @@ jobs: && cmp component-catalog-artifact/catalog.json "$live/components/arm64/catalog.json" \ && cmp component-catalog-artifact/catalog.json.sha256 "$live/components/arm64/catalog.json.sha256" \ && cmp component-catalog-artifact/catalog.json.sig "$live/components/arm64/catalog.json.sig"; then + python3 .github/scripts/verify-pages-release-metadata.py \ + verify "$live" public-release echo "Public appcast and component catalog exactly match ${{ needs.publish_release.outputs.version }}" exit 0 fi @@ -1779,7 +2164,8 @@ jobs: with open(sys.argv[1], encoding="utf-8") as handle: keys = json.load(handle).get("ssh_keys", []) - assert keys and all(key.startswith("ssh-") for key in keys), "GitHub SSH metadata is missing" + if not keys or not all(isinstance(key, str) and key.startswith("ssh-") for key in keys): + raise SystemExit("GitHub SSH metadata is missing") with open(sys.argv[2], "w", encoding="utf-8") as handle: for key in keys: handle.write(f"github.com {key}\n") diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 89ff1670..406abc14 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,12 +31,78 @@ jobs: run: | newest="$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)" echo "DEVELOPER_DIR=$newest/Contents/Developer" >> "$GITHUB_ENV" - - name: Dory CLI machine create - run: .github/scripts/test-dory-machine-create.sh - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler run: brew install protobuf + - name: Build shared Rust guest-control client + run: scripts/build-dory-ffi-xcframework.sh --if-needed + - name: Dory CLI machine create + run: .github/scripts/test-dory-machine-create.sh + - name: Signed release metadata contract + run: | + python3 .github/scripts/test-release-metadata.py + python3 .github/scripts/test-release-output-validator.py + python3 .github/scripts/test-homebrew-install-gate.py + python3 .github/scripts/test-container-engine-performance-gate.py + python3 .github/scripts/test-linux-vm-performance-evidence.py + python3 -B scripts/test-linux-vm-performance-bundle.py -v + python3 .github/scripts/test-renderer-production-tuple.py + python3 -B .github/scripts/test-renderer-release-identity.py -v + scripts/test-build-renderer-components.sh + python3 .github/scripts/test-source-preserving-lan-gate.py + python3 .github/scripts/test-sparkle-install-relaunch-gate.py + python3 .github/scripts/test-vz-native-ipv6-gate.py + bash scripts/test-make-dmg.sh + bash scripts/test-app-update-payload.sh + bash scripts/test-verify-macos-deployment-targets.sh + python3 .github/scripts/test-release-orchestrator.py + bash scripts/test-dmg-distribution-signing.sh + python3 .github/scripts/test-ci-umbrella.py + python3 .github/scripts/test-interrupted-upgrade-rollback-gate.py + python3 .github/scripts/test-generate-appcast.py + python3 .github/scripts/test-release-sbom.py + python3 .github/scripts/test-direct-dmg-install-gate.py + python3 .github/scripts/test-desktop-linux-live-gate.py + python3 .github/scripts/test-graphics-pack-installer.py + python3 .github/scripts/test-graphics-backend-resolver.py + python3 .github/scripts/test-machine-resource-reconfiguration-gate.py + python3 .github/scripts/test-sandbox-security-gate.py + python3 .github/scripts/test-ssh-agent-forwarding-gate.py + python3 .github/scripts/test-bind-advisory-lock-gate.py + python3 .github/scripts/test-nonnative-build-smoke.py + python3 .github/scripts/test-external-volume-bind-gate.py + python3 .github/scripts/test-host-network-integrity-gate.py + python3 .github/scripts/test-readiness-gate.py + python3 .github/scripts/test-release-candidate-live-smoke.py + python3 .github/scripts/test-live-migration-gate.py + python3 .github/scripts/test-devcontainers-compatibility-gate.py + python3 .github/scripts/test-act-compatibility-gate.py + python3 .github/scripts/test-testcontainers-compatibility-gate.py + python3 .github/scripts/test-localstack-compatibility-gate.py + python3 .github/scripts/test-tilt-compose-compatibility-gate.py + python3 .github/scripts/test-supabase-compatibility-gate.py + python3 .github/scripts/test-kubernetes-tooling-compatibility-gate.py + python3 .github/scripts/test-default-platform-image-gate.py + python3 .github/scripts/test-bind-file-coherence-gate.py + python3 .github/scripts/test-prune-safety-gate.py + python3 .github/scripts/test-data-drive-volume-identity-gate.py + python3 .github/scripts/test-data-disk-growth-gate.py + python3 .github/scripts/test-ecr-registry-retry-gate.py + python3 .github/scripts/test-private-registry-auth-gate.py + python3 .github/scripts/test-offline-bundled-boot-gate.py + python3 .github/scripts/test-competitor-runtime-regression-gate.py + python3 .github/scripts/test-managed-data-drive-gate.py + python3 .github/scripts/test-native-ipv6-gate.py + python3 .github/scripts/test-nonnative-exec-conformance-gate.py + python3 .github/scripts/test-nonnative-nix-gc-gate.py + python3 .github/scripts/test-nonnative-arch-pacman-gate.py + python3 .github/scripts/test-nonnative-mmdebstrap-gate.py + python3 .github/scripts/test-gvproxy-qemu-switch-gate.py + python3 .github/scripts/test-long-lived-network-soak.py + python3 .github/scripts/test-endurance-reliability-soak.py + python3 .github/scripts/test-release-candidate-qualifier.py + python3 .github/scripts/test-release-qualification-verifier.py - name: Install Go toolchain uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: diff --git a/.gitignore b/.gitignore index acfb5545..b2218f13 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ TestResults/ # Swift Package Manager .build/ +.build-*/ .swiftpm/ # Package.resolved is committed (app) so CI + contributors pin the same dependency versions. @@ -59,6 +60,9 @@ secrets* ExportOptions.plist # Release pipeline output +# Exact-candidate release gates are source, even when a checkout's private exclude file hides +# untracked scripts by default. +!/scripts/desktop-linux-live-gate.sh release-build*/ /release/ /dist/ @@ -144,12 +148,54 @@ docs-build/ # Go build artifact (build.sh writes the real output to guest/out/) guest/agent/agent -# Built GitHub Pages site (CI builds website/ and deploys the output directly) plus -# local-only planning notes. Nothing under docs/ is tracked. +# Built GitHub Pages site (CI builds website/ and deploys the output directly) plus local-only +# planning notes. Keep the source platform ADR and its Linux execution contracts under docs/. docs/ +!/docs/ +/docs/* +!/docs/virtual-workspace-platform.md +!/docs/linux-virtual-workspace-architecture.md +!/docs/linux-virtual-workspace-delivery-plan.md +!/docs/linux-capability-and-qualification-matrix.md +!/docs/linux-vm-performance-contract.md +!/docs/linux-iso-and-gpu-recovery-review.md +!/docs/container-engine-performance-qualification.md +!/docs/architecture-gates/ +/docs/architecture-gates/* +!/docs/architecture-gates/virtiofs-worker-xpc.json +!/docs/architecture-gates/vz-custom-virtio-gpu.json +!/docs/architecture-gates/vulkan-13-application-readiness.md !website/public/docs/ !website/public/docs/** +# Source-level renderer build and ABI verification are release inputs, not local scripts. +!/scripts/build-renderer-production-dependencies.sh +!/scripts/build-virglrenderer.sh +!/scripts/assemble-renderer-production-worker.sh +!/scripts/renderer-release-identity.py +!/scripts/renderer-production-tuple.py +!/scripts/renderer-build-tools/ +/scripts/renderer-build-tools/* +!/scripts/renderer-build-tools/xcodebuild +!/scripts/verify-virgl-resource-info-abi.c + +# Candidate-bound physical Linux developer smoke (fail-closed; never release qualification). +!/scripts/linux-local-runtime-smoke.sh + +# Canonical Linux VM performance evidence validation, signed-bundle verification, and their +# public-key-only adversarial fixtures are release source. Private collector output and signing +# keys remain excluded. +!/scripts/qualify-container-engine-performance.sh +!/scripts/validate-linux-vm-performance-evidence.py +!/scripts/verify-linux-vm-performance-bundle.py +!/scripts/test-linux-vm-performance-bundle.py +!/scripts/fixtures/ +/scripts/fixtures/* +!/scripts/fixtures/linux-vm-performance-bundle-schema1/ +!/scripts/fixtures/linux-vm-performance-bundle-schema1/** +!/scripts/fixtures/hostshare_guest_probe.py +!/scripts/fixtures/hostshare_nonping_probe.py + # Internal engine notes (not published) Packages/ContainerizationEngine/Docs/ @@ -176,7 +222,5 @@ scripts/__pycache__/ /DESTRUCTIVE_ACTIONS.md /DORY_V0.4_RESEARCH_REPORT.md /MACHINE_IMAGE_CONTRACT.md -/PERFORMANCE_QUALIFICATION.md /POST_V0.4_PRODUCT_DESIGNS.md /RELEASE_READINESS.md -/SANDBOX_THREAT_MODEL.md diff --git a/CHANGELOG.md b/CHANGELOG.md index aef2d159..670af019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,77 @@ # Changelog -## Unreleased +## 0.4.6 - 2026-08-28 + +### Added + +- Added named headless agent sandboxes with reusable sessions, coding-agent and polyglot templates, + dependency discovery, declarative recipes, persistent caches, and repairable provisioning. +- Added native Apple-silicon Linux desktop devices for speakers, microphone, camera, clipboard, + mouse, trackpad, keyboard, Retina resize, full-screen display, and host-device recovery. +- Added an isolated dual VirGL/Venus renderer path for accelerated OpenGL and Vulkan applications, + including native Zed startup and explicit GPU policy controls in Dory. +- Added a clear Desktops versus headless Sandboxes product model and expanded machine cards, + controls, resource grids, diagnostics, and lifecycle feedback. + +### Changed + +- Added custom arm64 Linux installation from a user-selected ISO. Dory now imports the installer + into private managed storage, creates a configurable thin-provisioned disk, boots through native + EFI with a stable VM identity and persistent NVRAM, and lets users eject or reattach installer + media from the machine menu. +- Extended custom EFI snapshots to capture and transactionally restore the VM disk, machine + identifier, and NVRAM together. EFI snapshot exports use an integrity-checked portable bundle, + while clones retain EFI boot variables and receive a new hardware identity. +- Enabled full-screen participation and system-key capture in the native Linux display window. + +- Rebuilt the Ubuntu 24.04 LTS desktop component around Canonical's GNOME session, Yaru themes, + Ubuntu Dock, Files, Settings, standard desktop utilities, and a working native-package browser + instead of presenting the shared Xfce profile as Ubuntu Desktop. +- Completed Debian's everyday desktop application set with Firefox ESR, Evince, and Galculator. +- Enabled explicit bidirectional SPICE clipboard transport and added a VirtIO microphone input + stream alongside desktop speaker output. +- Made release candidates rebuild and verify the desktop kernel and all three desktop root filesystems + from the release commit instead of accepting pre-existing local guest artifacts. +- Added a physical-Mac release gate that boots every managed desktop with the signed candidate + helper, launches its browser, and verifies applications, networking, audio devices, persistence, + read-only sharing, entitlements, and exact process provenance. +- Added signed, versioned in-place updates for existing Debian, Ubuntu, and Kali desktops. Dory now + preserves the persistent guest disk and account, creates a last-good snapshot, updates packages, + browser and guest integration, boots the new desktop kernel, qualifies the graphical session, + and automatically rolls back any failed or interrupted update. +- Extended the physical-Mac release gate to apply each exact desktop update payload and prove that + a corrupt payload restores the last-good snapshot without losing guest data or running state. + +### Fixed + +- Fixed first launch and recovery paths that could leave `doryd` unavailable, report “machine + manager is not configured,” require an extra Try Again click, or time out while cleaning up an + incomplete server or desktop. +- Fixed Desktop and Server creation failures caused by overlong handoff sockets, stale typed + settings, incomplete component activation, and provisioning before the daemon was ready. +- Fixed Docker tier discovery and repair when the macOS credential helper or bundled CLI plugins + were missing, while preserving the selected data drive and exact engine storage authority. +- Fixed inverted or sluggish mouse and trackpad scrolling, lost pointer clicks, upside-down or + flickering frames, desktop audio pacing, microphone/camera device recovery, and runner identity. +- Fixed strict OrbStack migration capacity checks when Docker object-level `/system/df` is + unavailable by using the authoritative guest data-disk capacity and usage probe. +- Fixed the Agent Core recipe composition so POSIX `sh` receives `fi\nif` instead of the invalid + `fiif` token reported by the external sandbox provisioning PR. +- Kept the engine's Docker inventory bridge alive for the full VM lifetime, restoring automatic + gvproxy listeners for published container ports. Port diagnostics and repair now verify real + loopback connections instead of treating Docker route metadata as proof of reachability. +- Capped the Apple-silicon engine at the exact 62 GiB guest-RAM limit imposed by Dory's 2 GiB RAM + base and Hypervisor.framework's 64 GiB guest-physical aperture. Existing 64 GiB settings are + clamped before launch, and the helper rejects unrepresentable configurations with a clear error. +- Stopped Firefox and Firefox ESR from producing blank, black, or duplicated desktop frames on + Dory's current macOS VirGL renderer. Managed desktops now apply Mozilla's supported + per-browser software-compositing fallback while leaving the Linux desktop and other compatible + applications on the accelerated graphics path. +- Made the desktop VM's virtio Ethernet adapter an explicit NetworkManager connection. Ubuntu's + vendor policy no longer leaves the guest offline when no Netplan-generated profile exists. +- Made that managed connection independent of the guest's interface name, covering both Debian and + Ubuntu's `enp0s1` and Kali's `eth0` without falling back to an auto-generated profile. +- Fixed `machine create` reporting a newly created desktop as `headless` until its first status refresh. ## 0.4.5 - 2026-08-13 diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index d4f78702..12f5fb3f 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -31,7 +31,7 @@ workload data. | Compose | Bundled Compose v2 with profiles, overrides, `.env`, builds, health dependencies, and external resources | | Registry authentication | Docker-compatible login and credential flow | | Bind mounts | Home-directory and `/Volumes` paths are shared at their native macOS paths | -| amd64 images on Apple Silicon | Supported for common development workloads through bundled FEX | +| amd64 images on Apple Silicon | Supported for common application/container workloads through bundled FEX inside Dory's ARM64 engine; this is not an x86_64 guest OS or ISO path | Some specialized Docker extensions and host-specific plugins may assume another product's internal paths. Open an issue with the exact tool and version when that happens. @@ -68,20 +68,33 @@ engine resources. | Capability | Status | |---|---| -| Guest OS | Managed Debian 13, Ubuntu 24.04 LTS, or Kali rolling Xfce desktop; lightweight Alpine headless Linux on native arm64 | +| Guest OS | Managed Ubuntu 24.04 LTS GNOME, Debian 13 Xfce, or Kali rolling Xfce desktop; lightweight Alpine headless Linux on native arm64 | | Access | Configurable desktop user, graphical session, embedded or selected external terminal, `dory machine shell`, and command execution | | Resources | CPU and memory configuration with guest-reported statistics | | Snapshots and export/import | Supported | | Scheduled local recovery bundles | Supported; owner-only durable schedules, archive re-import verification on every run, periodic disposable boot verification, and scheduler-owned retention | | Managed remote/offsite machine backup | **Unavailable**; Dory does not claim an S3 or hosted backup service | | Development recipes | Curated Node, Python, Go, Rust, Java, Ruby, and DevOps toolsets for Debian and Alpine | -| Graphical Linux sessions | Supported with managed Debian, Ubuntu, and Kali Xfce profiles on Apple Silicon | -| Desktop display | Retina-sharp 2x framebuffer, dynamic window resizing, and matching Xfce scaling | - -Desktop machines run normal graphical and command-line applications with glibc and systemd. Their -disk is thin-provisioned to 64 GiB in the selected Dory data drive. Headless machines use Alpine, -musl, `root`, and `/bin/sh`. Arbitrary desktop images and guest kernel modules are not part of the -current contract. +| Graphical Linux sessions | Supported with managed Ubuntu GNOME and Debian/Kali Xfce profiles on Apple Silicon | +| Desktop display | Retina-sharp 2x framebuffer, dynamic window resizing, and matching GNOME or Xfce scaling | +| Desktop GPU acceleration | **Unqualified for public release:** the repaired dual VirGL2+Venus tuple passed a 15-minute physical Developer-ID calibration on Apple M2 Pro with GNOME, Firefox, Files, Calculator, Settings, Terminal, sustained VirGL OpenGL, Venus Vulkan WSI, and native-Venus Zed; no rejected resource flush or device loss occurred. Public support remains closed until the same tuple is release-signed/notarized and passes the complete release matrix; software display remains the recovery path | +| Existing desktop updates | Signed in-place package, browser, guest-integration, and kernel updates with a retained last-good snapshot and automatic failure/interruption rollback | +| Custom arm64 installer ISO | Preview: architecture preflight, exact-media SHA-256/runtime evidence, native EFI boot, private managed ISO copy and recovery console, thin-provisioned disk, persistent machine identity/NVRAM, and attach/eject lifecycle | + +Managed desktop machines run normal graphical and command-line applications with glibc and systemd. +Their disk is thin-provisioned to 64 GiB in the selected Dory data drive. Headless machines use +Alpine, musl, `root`, and `/bin/sh`. Custom arm64 ISO installation is preview until real-distribution +installation, reboot, media-ejection, device, and guest-tools qualification passes on physical Macs. +Architecture compatibility does not imply runtime qualification: Dory records the exact ISO hash, +host model, and macOS build, blocks known-unstable tuples, and labels unseen combinations as +unqualified. + +Rosetta translates x86_64 Linux applications inside an eligible ARM64 Linux VM; Dory's current +container path uses FEX for the same application-level boundary. Neither boots an Intel distro, +kernel, or installer. Dory exposes no partial x86 VM mode. A future whole-system x86 product would +require a complete packaged QEMU TCG backend with independently qualified firmware, devices, +lifecycle, security, recovery, and performance, as defined by the +[whole-machine emulation contract](docs/x86-linux-whole-machine-emulation.md). ## Networking @@ -127,17 +140,20 @@ grants; `full` is an explicit unrestricted choice. See the ## Preview -- In-guest Venus/Vulkan acceleration is preview on the Apple-silicon raw-HV tier. +- Custom arm64 Linux installation from ISO through EFI is preview pending exact-candidate physical-Mac qualification. Ubuntu 24.04.3 and 24.04.4 ARM64 are known unstable on Mac14,10/macOS build 26A5406e with Dory's retired VirtIO-block EFI profile. Under the current native-NVMe/fsync profile, Ubuntu 24.04.3 completed installation, package updates, ISO ejection, and persistent GNOME/Chrome boot, but a later whole-guest stall during Chromium snap installation keeps that exact tuple unqualified. - Remote SSH workspace foundations and custom machine kernel/rootfs inputs remain preview with the exact limits reported by `dory agent guide --json`. ## Unavailable -- USB attach, detach, and remembered replay are unavailable. Host discovery is supported, but the - engine fails closed until a complete guest USB/IP RPC and physical qualification exist. +- USB attach/detach controls exist in the public app and `dorydctl`, but enable only for a running + raw-HV machine whose exact signed resolved plan authorizes removable USB. Current production + catalogs carry no such physical-device qualification, so attachment still fails closed in release + builds. Remembered replay remains unavailable. - Audio passthrough is unavailable. - Intel-host public builds are unavailable before dedicated physical qualification. -- Desktop images beyond the managed Debian, Ubuntu, and Kali Xfce profiles are unavailable. +- Intel/x86_64 Linux installer ISO boot is unavailable; application translation inside an ARM64 + guest does not change that boundary. - Managed image update discovery/replacement, mDNS/multicast relay, and general L2 bridging are unavailable in 0.4. diff --git a/Config/Dory-Info.plist b/Config/Dory-Info.plist index f9cb105d..a082383c 100644 --- a/Config/Dory-Info.plist +++ b/Config/Dory-Info.plist @@ -12,6 +12,8 @@ AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4= DoryComponentCatalogURL https://augani.github.io/dory/components/arm64/catalog.json + DoryVMQualificationBootstrap + $(DORY_VM_QUALIFICATION_BOOTSTRAP) SUEnableInstallerLauncherService LSMultipleInstancesProhibited diff --git a/Config/DoryFSWorker-Info.plist b/Config/DoryFSWorker-Info.plist new file mode 100644 index 00000000..ce3e5752 --- /dev/null +++ b/Config/DoryFSWorker-Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Dory Filesystem Worker + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + DoryFSWorker + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + XPCService + + ServiceType + Application + + + diff --git a/Config/DoryHVRunner-Info.plist b/Config/DoryHVRunner-Info.plist new file mode 100644 index 00000000..10a773ad --- /dev/null +++ b/Config/DoryHVRunner-Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Dory Desktop + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Dory Desktop + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSUIElement + + NSHighResolutionCapable + + NSLocalNetworkUsageDescription + Dory connects the Linux guest to local host services and published ports. + NSCameraUsageDescription + Dory shares your Mac camera with Linux desktop machines when you enable camera input. + NSMicrophoneUsageDescription + Dory shares your Mac microphone with Linux desktop machines when you use audio input. + NSPrincipalClass + NSApplication + + diff --git a/Config/DoryRendererProductionTuple.json b/Config/DoryRendererProductionTuple.json new file mode 100644 index 00000000..bf374654 --- /dev/null +++ b/Config/DoryRendererProductionTuple.json @@ -0,0 +1,624 @@ +{ + "architecture": "arm64", + "artifactProfiles": { + "rendererBundle": { + "angleMetal": [ + "XPCServices/DoryRendererWorker.xpc/Contents/Frameworks/libEGL.dylib", + "XPCServices/DoryRendererWorker.xpc/Contents/Frameworks/libGLESv2.dylib" + ], + "rendererWorker": [ + "XPCServices/DoryRendererWorker.xpc/Contents/MacOS/DoryRendererWorker" + ] + }, + "rendererQualificationEvidence": { + "qualification": [ + "Resources/renderer-bootstrap-qualification.json" + ] + }, + "rendererReleaseQualificationEvidence": { + "qualification": [ + "Resources/renderer-bootstrap-qualification.json" + ], + "releaseSignature": [ + "Resources/renderer-bootstrap-qualification.json.sig" + ] + }, + "staticDependencies": { + "angleHeaders": [ + "include/ANGLE/EGL/egl.h", + "include/ANGLE/EGL/eglext.h", + "include/ANGLE/EGL/eglplatform.h", + "include/ANGLE/KHR/khrplatform.h" + ], + "angleMetal": [ + "Frameworks/libEGL.dylib", + "Frameworks/libGLESv2.dylib" + ], + "libepoxy": [ + "include/epoxy/common.h", + "include/epoxy/egl.h", + "include/epoxy/egl_angle_ext_generated.h", + "include/epoxy/egl_generated.h", + "include/epoxy/gl.h", + "include/epoxy/gl_generated.h", + "lib/libepoxy.a" + ], + "moltenVK": [ + "lib/libMoltenVK.a" + ] + }, + "staticLinkClosure": { + "angleHeaders": [ + "include/ANGLE/EGL/egl.h", + "include/ANGLE/EGL/eglext.h", + "include/ANGLE/EGL/eglplatform.h", + "include/ANGLE/KHR/khrplatform.h" + ], + "linkContract": [ + "renderer-static-link.json" + ], + "angleMetal": [ + "Frameworks/libEGL.dylib", + "Frameworks/libGLESv2.dylib" + ], + "libepoxy": [ + "lib/libepoxy.a" + ], + "libepoxyHeaders": [ + "include/epoxy/common.h", + "include/epoxy/egl.h", + "include/epoxy/egl_angle_ext_generated.h", + "include/epoxy/egl_generated.h", + "include/epoxy/gl.h", + "include/epoxy/gl_generated.h" + ], + "moltenVK": [ + "lib/libMoltenVK.a" + ], + "virglrenderer": [ + "lib/libvirglrenderer.a" + ] + } + }, + "dependencyBuildPolicy": { + "compatibilityPatches": [ + { + "path": "patches/angle-shaderlang-trivial-copy-contract-backport.patch", + "resultBlob": "c2b52771aa71976133988ea026990357567733ca", + "sha256": "cf8b52e104dbc931efb064994c3b41df362b02e7921fb2cba32f48b450b2d4e2", + "source": "angle", + "sourceBlob": "5740d2f8bdd1e3a8aa25888d64e33d1daf9152c8", + "targetPath": "Source/ThirdParty/ANGLE/include/GLSLANG/ShaderLang.h", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-shift-count-overflow-backport.patch", + "resultBlob": "2f8c007fdf19b55881de0e6b1be753527a406053", + "sha256": "3d8f6b819e8dbb509adbb35c9774664dc125ff0247c835cf1550748a95313ca4", + "source": "angle", + "sourceBlob": "e9592d7f74221bd940ef0dc7df2ade4092c9a959", + "targetPath": "Source/ThirdParty/ANGLE/src/common/bitset_utils.h", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-shaderlang-trivial-copy-implementation-backport.patch", + "resultBlob": "f14f3c263c58a86501370909bbb5f8340949689a", + "sha256": "1e6a8e5b758ecec0bc1f8de26168a5782c60b764df41526910689cc510b43d43", + "source": "angle", + "sourceBlob": "44290593b5f88f4a0543e5aa117ca99075b7d805", + "targetPath": "Source/ThirdParty/ANGLE/src/compiler/translator/ShaderLang.cpp", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-final-virtual-destructor-backport.patch", + "resultBlob": "ed0ae84953bf570359500ddc6fcc14ad0741885d", + "sha256": "bdf2aa8ef0df46f78e4b7fbc10f33de96883cee8b86b346609fee2df762ece73", + "source": "angle", + "sourceBlob": "5939bfb19cbb5682db11edefb3f8bd519b4343f0", + "targetPath": "Source/ThirdParty/ANGLE/src/libANGLE/Fence.h", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-gles1-shader-state-logical-key-backport.patch", + "resultBlob": "a01628a1ec67aaa0a03770b9beed85d830067827", + "sha256": "6ab78c828ae72a7a7360f56e00259097c7c436b61dc2885823264bdf5a3180fc", + "source": "angle", + "sourceBlob": "6514bb7256cb5d703f239400441e19c6761aced3", + "targetPath": "Source/ThirdParty/ANGLE/src/libANGLE/GLES1Renderer.cpp", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-gles1-shader-state-copy-contract-backport.patch", + "resultBlob": "dbaedb48811456372ec58886a62146ea4610d0c6", + "sha256": "43c2f3015a4afc15332385ed8054c96d3cbc8b8a7d13c4198e166909d0a87054", + "source": "angle", + "sourceBlob": "98c7894ad52c14c3fe9e09b8d662f32775ee4bfe", + "targetPath": "Source/ThirdParty/ANGLE/src/libANGLE/GLES1Renderer.h", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-logical-state-copy-backport.patch", + "resultBlob": "d379d6a6e6b1a172a3806bc25b6ebe9152db0685", + "sha256": "0220a77647e8563ced7be7b7bf461642b2301d75dd2fc7a91148f2a149b52e19", + "source": "angle", + "sourceBlob": "7f9f56de4a38303b45240b38c14f7733da5dc14b", + "targetPath": "Source/ThirdParty/ANGLE/src/libANGLE/angletypes.cpp", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-logical-state-constructor-contract-backport.patch", + "resultBlob": "d9d7d51a332b23b369cb1b168e09add62031d349", + "sha256": "33904c0a00c1bac9b4b72043338d37f7640e47684e226d569766b3bbb4fab3ed", + "source": "angle", + "sourceBlob": "af30688ebebb614d622e1acb88ddc80068bc1a29", + "targetPath": "Source/ThirdParty/ANGLE/src/libANGLE/angletypes.h", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-sampler-state-logical-equality-backport.patch", + "resultBlob": "36f9e9497f142d8993591935c32b6956e1ca81e4", + "sha256": "8b91ad5dd79eeabbb66ce0c70b5dd0c78096fa1dc7d7adf7b42d02432cc4a720", + "source": "angle", + "sourceBlob": "862a3214a13a47150c29ffd72577bc7b11fe7a04", + "targetPath": "Source/ThirdParty/ANGLE/src/libANGLE/angletypes.inc", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-metal-blit-logical-base-copy-backport.patch", + "resultBlob": "1e628af0e9364b4ac9714a4fdb0ed4178921742c", + "sha256": "7729a2a31ab493b5f5de3d4351728636a537e2d87bb0675dce42ecbad21ce5f2", + "source": "angle", + "sourceBlob": "860f798cf68bb4fad87bee1ea9cd507d95767fd1", + "targetPath": "Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/FrameBufferMtl.mm", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/angle-metal-state-cache-logical-key-backport.patch", + "resultBlob": "8ab57b5fd510d6ce647d3da645a216609533a608", + "sha256": "6fdb6ef913707c8ae91526bacdd4be9d0b003e76a598d3730f4e76dd9f3b9ecd", + "source": "angle", + "sourceBlob": "7b2e9926b29407d7870b0da20a6591d0982146e7", + "targetPath": "Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_state_cache.mm", + "upstreamRepository": "https://github.com/utmapp/WebKit.git", + "upstreamRevision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7" + }, + { + "path": "patches/libepoxy-dory-angle-rpath.patch", + "resultBlob": "b61354a4cfd12478c9a7d73a05c465cb7bed7ff9", + "sha256": "63a6efddab3c20ef014a8d29b434c60d7f18609700edc7b30806d0ec8e584da0", + "source": "libepoxy", + "sourceBlob": "770563c9cb70dc0a6f960968752c747bf5865ea6", + "targetPath": "src/dispatch_common.c", + "upstreamRepository": "https://github.com/utmapp/libepoxy.git", + "upstreamRevision": "15d904dcb1d5a8d626ffe11e8f3339499d6f7b09" + }, + { + "path": "patches/moltenvk-fail-closed-robustness2.patch", + "resultBlob": "110c542637a68618e797e5d8b83cf95f2ee6bb39", + "sha256": "7c90f042dadd6fcb805843faa7b78fef835b16283a744652a2b9ead3e8b0d609", + "source": "moltenVK", + "sourceBlob": "612a527083ab6059c5349414457cd52e439f3cd7", + "targetPath": "MoltenVK/MoltenVK/GPUObjects/MVKDevice.mm", + "upstreamRepository": "https://github.com/utmapp/MoltenVK.git", + "upstreamRevision": "ef1c5461774f5fbd224ddcfd91fd2c0ea23f0384" + }, + { + "path": "patches/moltenvk-hidden-vulkan-alias.patch", + "resultBlob": "878e5ca82468bd1e827c5b28d198ff3be1e12dc5", + "sha256": "be4aec6b7fc08fadc785c1b10bc66439d8882b9a025c0106d7adfac9cefaf646", + "source": "moltenVK", + "sourceBlob": "3071c3f89e9eca8b1020ec2ba38dd30f02b0bb87", + "targetPath": "MoltenVK/MoltenVK/GPUObjects/MVKInstance.mm", + "upstreamRepository": "https://github.com/utmapp/MoltenVK.git", + "upstreamRevision": "ef1c5461774f5fbd224ddcfd91fd2c0ea23f0384" + }, + { + "path": "patches/moltenvk-sync-fd-binary-consumption.patch", + "resultBlob": "51752fd183ae00c55c92bbdd97a4ccab03f0960c", + "sha256": "588504b0474eb2130d273cc2bfb31dc9672fbb9cdc3ad4cdbb621b4cc08274d7", + "source": "moltenVK", + "sourceBlob": "34022876aaa3d333b043049f48db81403f85fc73", + "targetPath": "MoltenVK/MoltenVK/GPUObjects/MVKSync.mm", + "upstreamRepository": "https://github.com/utmapp/MoltenVK.git", + "upstreamRevision": "ef1c5461774f5fbd224ddcfd91fd2c0ea23f0384" + }, + { + "path": "patches/moltenvk-hidden-vulkan-icd-entrypoints.patch", + "resultBlob": "588a5fb8b44f8e7b5b258051f2e819acb80a8bec", + "sha256": "feb558dfc75622bd6ebb5e89b5d4ab5d21bd14b26d442ec8eae791228189de69", + "source": "moltenVK", + "sourceBlob": "198b8473c6bdd9977b805c5a8854cfd1bcd0849c", + "targetPath": "MoltenVK/MoltenVK/Vulkan/vulkan.mm", + "upstreamRepository": "https://github.com/utmapp/MoltenVK.git", + "upstreamRevision": "ef1c5461774f5fbd224ddcfd91fd2c0ea23f0384" + }, + { + "path": "patches/moltenvk-dory-native-arrays.patch", + "resultBlob": "59915f75a5294d5f54647cb5e02c87aa162b6045", + "sha256": "1485bc300e1b8969121b90a34011f53f8c3b1a1c49a2da521b72cc040d2f4877", + "source": "moltenVK", + "sourceBlob": "b52730b2afc1ce97d886fc9a782d10e1bfef6e8d", + "targetPath": "MoltenVKShaderConverter/MoltenVKShaderConverter/SPIRVToMSLConverter.cpp", + "upstreamRepository": "https://github.com/utmapp/MoltenVK.git", + "upstreamRevision": "ef1c5461774f5fbd224ddcfd91fd2c0ea23f0384" + } + ], + "angleMetalProduct": { + "archiveScheme": "ANGLE", + "eglInstallName": "@loader_path/libEGL.dylib", + "glesInstallName": "@loader_path/libGLESv2.dylib", + "runtimePaths": [ + "Frameworks/libEGL.dylib", + "Frameworks/libGLESv2.dylib" + ] + }, + "libcxxHardeningMode": "extensive", + "libepoxyStaticProduct": { + "archivePath": "lib/libepoxy.a", + "eglResolver": "@loader_path/../Frameworks/libEGL.dylib", + "glesResolver": "@loader_path/../Frameworks/libGLESv2.dylib" + }, + "maximumConcurrentBuildJobs": 3, + "moltenVKStaticProduct": { + "archivePath": "lib/libMoltenVK.a", + "hideVulkanSymbols": true, + "requiredEntrypoint": "vkGetInstanceProcAddr" + }, + "moltenVKSemaphoreQualification": { + "importExportCycles": 2, + "path": "scripts/renderer-moltenvk-semaphore-probe.m", + "sha256": "156a9ffc2e1b435872866e2f2ae381fea32b922c9830c53c0b8256f382ddbd16", + "signalExportCycles": 2, + "style": "mtlEvent" + }, + "moltenVKScanoutCopyQualification": { + "destinationTiling": "linear", + "directLinearColorAttachment": false, + "formats": [ + "bgra8-unorm", + "rgba8-unorm" + ], + "path": "scripts/renderer-moltenvk-scanout-copy-probe.m", + "readback": "mapped-host-visible", + "renderTiling": "optimal", + "sha256": "d9affc819971f5aee4a7140862283126aaae8cc69c2f8f36a2dfe63bf971a61e", + "transfer": "vkCmdCopyImage" + }, + "warningSuppression": false, + "zeroFuzzPatchApplication": true + }, + "dependencySources": { + "moltenVK": { + "cereal": { + "repository": "https://github.com/USCiLab/cereal.git", + "revision": "a56bad8bbb770ee266e930c95d37fff2a5be7fea", + "tree": "31169d00742fa22795e582f47bce5a0eeafecdad" + }, + "spirvCross": { + "repository": "https://github.com/utmapp/SPIRV-Cross.git", + "revision": "939b40b33a44443c404c4078823c406e3c94866f", + "tree": "09cb57c85e770936a6390ed3f891363075a0b67a" + }, + "spirvHeaders": { + "repository": "https://github.com/KhronosGroup/SPIRV-Headers.git", + "revision": "b824a462d4256d720bebb40e78b9eb8f78bbb305", + "tree": "4f5ba6304fde3759b6e42e94f36499802c6f3bdb" + }, + "spirvTools": { + "repository": "https://github.com/KhronosGroup/SPIRV-Tools.git", + "revision": "262bdab48146c937467f826699a40da0fdfc0f1a", + "tree": "0252479f729a6f5f2064a36d89044c5326171566" + }, + "volk": { + "repository": "https://github.com/zeux/Volk.git", + "revision": "59660878571aa99e3c9a366bb1d19fdcd701f0e7", + "tree": "ba6a6ea0d7b581d637a3c8822a71ffb743c0c007" + }, + "vulkanHeaders": { + "repository": "https://github.com/KhronosGroup/Vulkan-Headers.git", + "revision": "6aefb8eb95c8e170d0805fd0f2d02832ec1e099a", + "tree": "b6a7d1d42b0439ba90a2927d94ed098c9111cc2d" + }, + "vulkanTools": { + "repository": "https://github.com/KhronosGroup/Vulkan-Tools.git", + "revision": "013058f74e2356347f8d9317233bc769816c9dfb", + "tree": "9e704d67ff5461b87f360dc194c09b67c805b594" + } + } + }, + "guestMesaBuildPolicy": { + "applicationReadiness": { + "application": "zed", + "applicationRevision": "eb8e1c8b5502b7007465fbbc465f4a736fa39210", + "applicationTag": "v1.16.1", + "atlasFormats": [ + "bgra8-unorm", + "rgba8-unorm" + ], + "atlasUsages": [ + "copy-destination", + "texture-binding" + ], + "backend": "vulkan", + "minimumVulkanAPI": "1.3", + "presentMode": "fifo", + "sourceRepository": "https://github.com/zed-industries/zed.git", + "surfaceExtent": "64x64", + "surfaceFormatPolicy": "first-capability-format", + "wgpuRevision": "e99f5305ded96ff7006f0714d043a7f735bd45c2", + "wgpuVersion": "29.0.4" + }, + "builderImage": "debian:bullseye-slim@sha256:f313b4bd62667092a59b3a664d7d3ab8b5e65f41675f48e81455a15dc5abe792", + "builderSnapshot": "20260713T000000Z", + "compositorProbeNeededSONAMEs": [ + "libc.so.6", + "libvulkan.so.1" + ], + "compositorProfile": "native-vulkan-optimal-copy-compositor-v2", + "compositorProfileSourceCommit": "329a88e72424486180ff3339440fa9f8f711af02", + "compositorProfileSourceTree": "ef4676a2279bd364c645f31cd0e1e1cb238e0e0c", + "glibcSymbolCeiling": "GLIBC_2.31", + "hiddenQueueSubmission": false, + "icdNeededSONAMEs": [ + "libX11-xcb.so.1", + "libc.so.6", + "libdl.so.2", + "libm.so.6", + "libpthread.so.0", + "libwayland-client.so.0", + "libxcb-dri3.so.0", + "libxcb-keysyms.so.1", + "libxcb-present.so.0", + "libxcb-randr.so.0", + "libxcb-shm.so.0", + "libxcb-sync.so.1", + "libxcb-xfixes.so.0", + "libxcb.so.1", + "libxshmfence.so.1", + "libz.so.1", + "libzstd.so.1" + ], + "inputSHA256": "19a55684e03b26053f504982ebbbd85f31d198bcaeb307239689fd11189f17e9", + "libcFamily": "glibc", + "libdrmLinkage": "static-hidden", + "manifestLibraryPath": "../../../lib/libvulkan_virtio.so", + "maxGLIBCSymbol": "GLIBC_2.29", + "mesonVersion": "1.10.0", + "mesonWheelSHA256": "4b27aafce281e652dcb437b28007457411245d975c48b5db3a797d3e93ae1585", + "packLayout": "single-tree", + "patches": [], + "probeNeededSONAMEs": [ + "libc.so.6", + "libvulkan.so.1", + "libwayland-client.so.0", + "libxcb.so.1" + ], + "requiredDeviceExtensions": [ + "VK_KHR_external_semaphore_fd", + "VK_KHR_swapchain" + ], + "requiredInstanceExtensions": [ + "VK_KHR_surface", + "VK_KHR_wayland_surface", + "VK_KHR_xcb_surface" + ], + "requiredVulkan13Features": [ + "dynamicRendering", + "maintenance4", + "synchronization2" + ], + "runtimeArtifactPath": "guest/out/dory-mesa-venus-arm64.tar.zst", + "runtimeDigestContract": "DoryRendererArtifactManifest.guestMesa", + "runtimeManifestSchema": 6, + "runtimeSHA256": "fa12e2bef9855dd382c3cd7f1dcd434f65302fc13471ae06367179f1ad37124c", + "sourceDateEpoch": 1767751301, + "standardWSISemaphorePath": "sync-fd-minus-one", + "vulkanAPI": "1.3", + "waylandProtocolsVersion": "1.41", + "waylandVersion": "1.20.0", + "wsi": [ + "x11", + "wayland" + ], + "wsiSurfaceGate": [ + "xcb", + "wayland" + ] + }, + "kind": "dev.dory.renderer-production-tuple", + "platform": "macos", + "producerFence": { + "failClosedPatch": { + "path": "guest/kernel/patches/6.12.106/0008-virtio-gpu-fail-closed-on-producer-fence-error.patch", + "sha256": "53f0db7b102f53c6d3aee13dc43cf45dbfedd5dabd0c04026b0b0c709c13953d" + }, + "kernelVersion": "6.12.106", + "prepareFramebufferPatch": { + "path": "guest/kernel/patches/6.12.106/0007-virtio-gpu-wait-for-scanout-producers.patch", + "sha256": "b899d2981d192828ebcbba02a3f8f3409dd27663bf8ca3059bdc95da55090f42" + } + }, + "schemaVersion": 3, + "sourceTuple": "dory-dual-metal-20260826", + "sources": { + "angle": { + "repository": "https://github.com/utmapp/WebKit.git", + "revision": "6a7f464047e2f6f2b65fe315aaad5d1ff3229cb7", + "subdirectory": "Source/ThirdParty/ANGLE", + "tree": "6ff242f54d40a2aaf08ed74b6a4bfe65a8bf447f" + }, + "libepoxy": { + "repository": "https://github.com/utmapp/libepoxy.git", + "revision": "15d904dcb1d5a8d626ffe11e8f3339499d6f7b09", + "tree": "86af2dd5a68cb45140eef8a1165cd5bfd23a03e8" + }, + "mesa": { + "repository": "https://gitlab.freedesktop.org/osy/mesa.git", + "revision": "79bc850d884a1307356ff61c017e58901b90c7e2", + "tree": "585b6604e6ef58585cfc44f7b4d5eab172ddfbbd" + }, + "moltenVK": { + "repository": "https://github.com/utmapp/MoltenVK.git", + "revision": "ef1c5461774f5fbd224ddcfd91fd2c0ea23f0384", + "tree": "14f470cffb6b74c5e72647925a0c7f83ba64abb8" + }, + "virglrenderer": { + "repository": "https://github.com/utmapp/virglrenderer.git", + "revision": "65cc14eb896f121ffc5130ce04815a923a03c41d", + "tree": "94dc34ffde98cf70f0c11fe921bec10a09d3907f" + } + }, + "toolchain": { + "appleClang": "Apple clang version 21.0.0 (clang-2100.1.1.101)", + "cmake": "4.2.3", + "meson": "1.12.0", + "ninja": "1.13.2", + "pkgConfig": "2.5.1", + "xcodeBuild": "17F109", + "xcodeVersion": "26.6" + }, + "virglBuildPolicy": { + "checkGLErrors": false, + "classicRenderer": "virgl2-angle-metal", + "defaultLibrary": "static", + "drmRenderers": [], + "fuzzer": false, + "minimumMacOS": "15.0", + "metalSharedTextureQualification": { + "allocation": "newSharedTextureWithDescriptor", + "crossProcessHandle": true, + "path": "scripts/renderer-virgl-metal-shared-texture-probe.m", + "renderImportReadback": true, + "sha256": "1b939c36c82fa29eb17a6b3c2405f3b4cb1e42ad6196eb5815fbcec66fe7d622", + "storageMode": "private" + }, + "minigbmAllocation": false, + "neptune": false, + "platforms": [ + "egl" + ], + "renderServerMode": "thread", + "renderServerWorker": "thread", + "requiredCapsets": [ + 2, + 4 + ], + "sourcePatches": [ + { + "path": "patches/virglrenderer-venus-only-static.patch", + "sha256": "e067deb5086f70d4781bd4877c1df94cfb95f2f7c4f62c72570996b5dc4c736a", + "targets": { + "config.h.meson": { + "resultBlob": "fc7d181899b2f4ca14ca8672e30f32659084a831", + "sourceBlob": "99ead1ab20f15ebf01eeb3549659334c2b6abc08" + }, + "meson.build": { + "resultBlob": "7ed632df5fa6ed61f433c631d30df8208774cb25", + "sourceBlob": "b863eccba1acff2ffaa5ee9160640721e58043ab" + }, + "meson_options.txt": { + "resultBlob": "d1dbd265391de867e7c35b8c7de0df83f0fa8d6f", + "sourceBlob": "1862ee50f0230d00ec672ac70d175a27c4ff4bcf" + }, + "server/render_protocol.h": { + "resultBlob": "821dca2db428ca2e47f3a0cadaeb31a23d50e783", + "sourceBlob": "9dd4c48f3a6f68b628e6b4eefc1fb0cf8e2d7dab" + }, + "src/gallium/meson.build": { + "resultBlob": "e9a1487dcc51a9f28c6e682d0e095e124eb4d3eb", + "sourceBlob": "ac8f8be23edf640329dd6b9c0e7c1c5392259bb8" + }, + "src/meson.build": { + "resultBlob": "78434d835650c1267d6a65a20dccb354213b56e8", + "sourceBlob": "e6e48ca5bdb157c8ce7a4a8939dc792246e90aa7" + }, + "src/proxy/proxy_context.c": { + "resultBlob": "736b013aa603083c0d76ed338284431aabffcfdf", + "sourceBlob": "9b334532803bd294505dbbfd28afae977b19edbb" + }, + "src/proxy/proxy_context.h": { + "resultBlob": "662fcaac39c98353050d3f047a04c7c03fb40482", + "sourceBlob": "ce29ecaea7fa06f4e6cc8d6d1646099489423aa7" + }, + "src/virglrenderer.c": { + "resultBlob": "96992b73af8f1c39cedf5fcc3626edb2247152e7", + "sourceBlob": "71076727dbafd4a4f9432a3af03cf439ebd7907e" + } + } + }, + { + "path": "patches/virglrenderer-dory-submit-failure-offset.patch", + "sha256": "ee4ba95725e2fd505ee823610cd227a45812389f3611c6fbe93385151bd488a6", + "targets": { + "src/vrend/vrend_decode.c": { + "resultBlob": "bdba09859231455ea0a77c96c87e3d6302418a57", + "sourceBlob": "7774a253bc4532f95edeb92874a8987bc2e3ddd9" + } + } + }, + { + "path": "patches/virglrenderer-linear-modifier-contract.patch", + "sha256": "03e2c23c43e38c080291ba0d5aaf61c03f85bf9702fed7cf0e5324374034639c", + "targets": { + "src/venus/vkr_physical_device.c": { + "resultBlob": "a7a3d3c6710723f232466f36eda84ee1a750a0ff", + "sourceBlob": "e8558156a7b674c78b13b0714120f247a1946739" + } + } + }, + { + "path": "patches/virglrenderer-metal-shm-external-memory.patch", + "sha256": "ec642b18929c9ee66e4b0ab073ebe0b86754bdbcca781f26dea485cbca10a497", + "targets": { + "src/venus/vkr_context.c": { + "resultBlob": "5c79451cab9d6509d2a20d2c3e5e42222e97d010", + "sourceBlob": "2ea589881710405f68819469f455133558b6f9e2" + }, + "src/venus/vkr_context.h": { + "resultBlob": "e765a177f86613a5e6ccc026bbb17057f265329a", + "sourceBlob": "12541a72d2e948b2ebf106702ae403c1a8cc073f" + }, + "src/venus/vkr_device_memory.c": { + "resultBlob": "d65e979dc4875a63c9bb05e81c0eeffab6684e73", + "sourceBlob": "b2e606f738591ecd7561f44750bb921c14f316ef" + }, + "src/venus/vkr_metal_helpers.h": { + "resultBlob": "a92ff8a1f9357655b45714ee438affd3fc58cafc", + "sourceBlob": "4f5208bd1720210c6d89ce4177a45b6887687b77" + }, + "src/venus/vkr_metal_helpers.m": { + "resultBlob": "7cab45751332bdf26bd67176cf43edf2292c1ff7", + "sourceBlob": "f0373ed10bf23bc0a8dde6e9a6a542325b93a4aa" + } + } + }, + { + "path": "patches/virglrenderer-metal-shareable-scanout.patch", + "sha256": "2f7505d21a29361c0cce47a986a678f79823bd22214fcdacf947451aeb9ee658", + "targets": { + "src/vrend/vrend_metal.m": { + "resultBlob": "750a2b44848833bcf2fd668eef969c920979bbe7", + "sourceBlob": "e8ded745d272cecd5b54b6e59ee7739e543c2194" + } + } + } + ], + "tests": false, + "unstableAPIs": true, + "venus": true, + "venusOnly": false, + "video": false, + "vtest": false, + "vulkanDynamicLoad": false + } +} diff --git a/Config/DoryRendererWorker-Info.plist b/Config/DoryRendererWorker-Info.plist new file mode 100644 index 00000000..1f6735f8 --- /dev/null +++ b/Config/DoryRendererWorker-Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Dory Renderer Worker + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + DoryRendererWorker + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + XPCService + + ServiceType + Application + + + diff --git a/Dory.xcodeproj/project.pbxproj b/Dory.xcodeproj/project.pbxproj index 286e4079..080afb6e 100644 --- a/Dory.xcodeproj/project.pbxproj +++ b/Dory.xcodeproj/project.pbxproj @@ -11,6 +11,23 @@ AA00000000000000000000B3 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = AA00000000000000000000B2 /* SwiftTerm */; }; AA00000000000000000000C3 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = AA00000000000000000000C2 /* Sparkle */; }; AA00000000000000000000D3 /* DoryOperations in Frameworks */ = {isa = PBXBuildFile; productRef = AA00000000000000000000D2 /* DoryOperations */; }; + D0B100000000000000000001 /* DoryHV in Frameworks */ = {isa = PBXBuildFile; productRef = D0B700000000000000000001 /* DoryHV */; }; + D0B100000000000000000002 /* DoryCore in Frameworks */ = {isa = PBXBuildFile; productRef = D0B700000000000000000002 /* DoryCore */; }; + D0B100000000000000000003 /* DorydKit in Frameworks */ = {isa = PBXBuildFile; productRef = D0B700000000000000000003 /* DorydKit */; }; + D0B100000000000000000004 /* DoryOperations in Frameworks */ = {isa = PBXBuildFile; productRef = D0B700000000000000000004 /* DoryOperations */; }; + D0B100000000000000000005 /* DoryVMContracts in Frameworks */ = {isa = PBXBuildFile; productRef = D0B700000000000000000005 /* DoryVMContracts */; }; + D0B100000000000000000006 /* DoryVMMKit in Frameworks */ = {isa = PBXBuildFile; productRef = D0B700000000000000000006 /* DoryVMMKit */; }; + D0B100000000000000000007 /* AppKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0B200000000000000000002 /* AppKit.framework */; }; + D0B100000000000000000008 /* AVFAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0B200000000000000000003 /* AVFAudio.framework */; }; + D0B100000000000000000009 /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0B200000000000000000004 /* AVFoundation.framework */; }; + D0B10000000000000000000B /* DoryHVRunner.app in Embed Linux VM Runner */ = {isa = PBXBuildFile; fileRef = D0B200000000000000000001 /* DoryHVRunner.app */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D0C100000000000000000001 /* DoryFSWorker.xpc in Embed Filesystem Worker */ = {isa = PBXBuildFile; fileRef = D0C200000000000000000001 /* DoryFSWorker.xpc */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D0C100000000000000000002 /* DoryFSWorkerContracts in Frameworks */ = {isa = PBXBuildFile; productRef = D0C700000000000000000001 /* DoryFSWorkerContracts */; }; + D0C100000000000000000003 /* DoryFSWorkerServiceCore in Frameworks */ = {isa = PBXBuildFile; productRef = D0C700000000000000000002 /* DoryFSWorkerServiceCore */; }; + D0D100000000000000000001 /* DoryRendererWorker.xpc in Embed Renderer Worker */ = {isa = PBXBuildFile; fileRef = D0D200000000000000000001 /* DoryRendererWorker.xpc */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D0D100000000000000000002 /* DoryRendererWorkerContracts in Frameworks */ = {isa = PBXBuildFile; productRef = D0D700000000000000000001 /* DoryRendererWorkerContracts */; }; + D0D100000000000000000003 /* DoryRendererWorkerServiceCore in Frameworks */ = {isa = PBXBuildFile; productRef = D0D700000000000000000002 /* DoryRendererWorkerServiceCore */; }; + D0D100000000000000000004 /* DoryRendererWorkerVirglBackend in Frameworks */ = {isa = PBXBuildFile; productRef = D0D700000000000000000003 /* DoryRendererWorkerVirglBackend */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -25,6 +42,39 @@ name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + D0B400000000000000000004 /* Embed Linux VM Runner */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/Helpers; + dstSubfolderSpec = 1; + files = ( + D0B10000000000000000000B /* DoryHVRunner.app in Embed Linux VM Runner */, + ); + name = "Embed Linux VM Runner"; + runOnlyForDeploymentPostprocessing = 0; + }; + D0C400000000000000000004 /* Embed Filesystem Worker */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/XPCServices; + dstSubfolderSpec = 1; + files = ( + D0C100000000000000000001 /* DoryFSWorker.xpc in Embed Filesystem Worker */, + ); + name = "Embed Filesystem Worker"; + runOnlyForDeploymentPostprocessing = 0; + }; + D0D400000000000000000004 /* Embed Renderer Worker */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/XPCServices; + dstSubfolderSpec = 1; + files = ( + D0D100000000000000000001 /* DoryRendererWorker.xpc in Embed Renderer Worker */, + ); + name = "Embed Renderer Worker"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -45,6 +95,27 @@ remoteGlobalIDString = AA100000000000000000000A; remoteInfo = DoryStorageProvider; }; + D0B800000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3E705CE72FE37C790094B33C /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0B500000000000000000001; + remoteInfo = DoryHVRunner; + }; + D0C800000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3E705CE72FE37C790094B33C /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0C500000000000000000001; + remoteInfo = DoryFSWorker; + }; + D0D800000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3E705CE72FE37C790094B33C /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0D500000000000000000001; + remoteInfo = DoryRendererWorker; + }; 3E705CFD2FE37C7B0094B33C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 3E705CE72FE37C790094B33C /* Project object */; @@ -66,6 +137,12 @@ 3E705CEF2FE37C790094B33C /* Dory.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Dory.app; sourceTree = BUILT_PRODUCTS_DIR; }; 3E705CFC2FE37C7B0094B33C /* DoryTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DoryTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3E705D062FE37C7B0094B33C /* DoryUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DoryUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + D0B200000000000000000001 /* DoryHVRunner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DoryHVRunner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D0B200000000000000000002 /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; }; + D0B200000000000000000003 /* AVFAudio.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFAudio.framework; path = System/Library/Frameworks/AVFAudio.framework; sourceTree = SDKROOT; }; + D0B200000000000000000004 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; }; + D0C200000000000000000001 /* DoryFSWorker.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = DoryFSWorker.xpc; sourceTree = BUILT_PRODUCTS_DIR; }; + D0D200000000000000000001 /* DoryRendererWorker.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = DoryRendererWorker.xpc; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -97,6 +174,24 @@ path = DoryUITests; sourceTree = ""; }; + D0B300000000000000000001 /* DoryHVRunner Sources */ = { + isa = PBXFileSystemSynchronizedRootGroup; + name = "DoryHVRunner Sources"; + path = Packages/ContainerizationEngine/Sources/dory-hv; + sourceTree = ""; + }; + D0C300000000000000000001 /* DoryFSWorker Sources */ = { + isa = PBXFileSystemSynchronizedRootGroup; + name = "DoryFSWorker Sources"; + path = Packages/ContainerizationEngine/Sources/DoryFSWorkerXPCService; + sourceTree = ""; + }; + D0D300000000000000000001 /* DoryRendererWorker Sources */ = { + isa = PBXFileSystemSynchronizedRootGroup; + name = "DoryRendererWorker Sources"; + path = Packages/ContainerizationEngine/Sources/DoryRendererWorkerXPCService; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -125,6 +220,41 @@ files = ( ); }; + D0B400000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D0B100000000000000000001 /* DoryHV in Frameworks */, + D0B100000000000000000002 /* DoryCore in Frameworks */, + D0B100000000000000000003 /* DorydKit in Frameworks */, + D0B100000000000000000004 /* DoryOperations in Frameworks */, + D0B100000000000000000005 /* DoryVMContracts in Frameworks */, + D0B100000000000000000006 /* DoryVMMKit in Frameworks */, + D0B100000000000000000007 /* AppKit.framework in Frameworks */, + D0B100000000000000000008 /* AVFAudio.framework in Frameworks */, + D0B100000000000000000009 /* AVFoundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0C400000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D0C100000000000000000002 /* DoryFSWorkerContracts in Frameworks */, + D0C100000000000000000003 /* DoryFSWorkerServiceCore in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0D400000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D0D100000000000000000002 /* DoryRendererWorkerContracts in Frameworks */, + D0D100000000000000000003 /* DoryRendererWorkerServiceCore in Frameworks */, + D0D100000000000000000004 /* DoryRendererWorkerVirglBackend in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -132,18 +262,35 @@ isa = PBXGroup; children = ( 3E705CF12FE37C790094B33C /* Dory */, + D0B300000000000000000001 /* DoryHVRunner Sources */, + D0C300000000000000000001 /* DoryFSWorker Sources */, + D0D300000000000000000001 /* DoryRendererWorker Sources */, AA1000000000000000000004 /* DoryStorageShared */, AA1000000000000000000005 /* DoryStorageProvider */, 3E705CFF2FE37C7B0094B33C /* DoryTests */, 3E705D092FE37C7B0094B33C /* DoryUITests */, + D0B300000000000000000002 /* Frameworks */, 3E705CF02FE37C790094B33C /* Products */, ); sourceTree = ""; }; + D0B300000000000000000002 /* Frameworks */ = { + isa = PBXGroup; + children = ( + D0B200000000000000000002 /* AppKit.framework */, + D0B200000000000000000003 /* AVFAudio.framework */, + D0B200000000000000000004 /* AVFoundation.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 3E705CF02FE37C790094B33C /* Products */ = { isa = PBXGroup; children = ( 3E705CEF2FE37C790094B33C /* Dory.app */, + D0B200000000000000000001 /* DoryHVRunner.app */, + D0C200000000000000000001 /* DoryFSWorker.xpc */, + D0D200000000000000000001 /* DoryRendererWorker.xpc */, AA1000000000000000000003 /* DoryStorageProvider.appex */, 3E705CFC2FE37C7B0094B33C /* DoryTests.xctest */, 3E705D062FE37C7B0094B33C /* DoryUITests.xctest */, @@ -158,16 +305,19 @@ isa = PBXNativeTarget; buildConfigurationList = 3E705D0E2FE37C7B0094B33C /* Build configuration list for PBXNativeTarget "Dory" */; buildPhases = ( + D0E400000000000000000002 /* Verify Release Guest Kernels */, 3E705CEB2FE37C790094B33C /* Sources */, 3E705CEC2FE37C790094B33C /* Frameworks */, 3E705CED2FE37C790094B33C /* Resources */, AA1000000000000000000009 /* Embed App Extensions */, AA00000000000000000000D0 /* Prune Stale Bundled Helpers */, + D0B400000000000000000004 /* Embed Linux VM Runner */, ); buildRules = ( ); dependencies = ( AA100000000000000000000B /* PBXTargetDependency */, + D0B800000000000000000002 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 3E705CF12FE37C790094B33C /* Dory */, @@ -206,6 +356,86 @@ productReference = AA1000000000000000000003 /* DoryStorageProvider.appex */; productType = "com.apple.product-type.app-extension"; }; + D0B500000000000000000001 /* DoryHVRunner */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0B600000000000000000003 /* Build configuration list for PBXNativeTarget "DoryHVRunner" */; + buildPhases = ( + D0B400000000000000000003 /* Sources */, + D0B400000000000000000001 /* Frameworks */, + D0B400000000000000000002 /* Resources */, + D0C400000000000000000004 /* Embed Filesystem Worker */, + D0D400000000000000000004 /* Embed Renderer Worker */, + D0E400000000000000000001 /* Package Exact Renderer Tuple */, + ); + buildRules = ( + ); + dependencies = ( + D0C800000000000000000002 /* PBXTargetDependency */, + D0D800000000000000000002 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + D0B300000000000000000001 /* DoryHVRunner Sources */, + ); + name = DoryHVRunner; + packageProductDependencies = ( + D0B700000000000000000001 /* DoryHV */, + D0B700000000000000000002 /* DoryCore */, + D0B700000000000000000003 /* DorydKit */, + D0B700000000000000000004 /* DoryOperations */, + D0B700000000000000000005 /* DoryVMContracts */, + D0B700000000000000000006 /* DoryVMMKit */, + ); + productName = DoryHVRunner; + productReference = D0B200000000000000000001 /* DoryHVRunner.app */; + productType = "com.apple.product-type.application"; + }; + D0C500000000000000000001 /* DoryFSWorker */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0C600000000000000000003 /* Build configuration list for PBXNativeTarget "DoryFSWorker" */; + buildPhases = ( + D0C400000000000000000003 /* Sources */, + D0C400000000000000000001 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + D0C300000000000000000001 /* DoryFSWorker Sources */, + ); + name = DoryFSWorker; + packageProductDependencies = ( + D0C700000000000000000001 /* DoryFSWorkerContracts */, + D0C700000000000000000002 /* DoryFSWorkerServiceCore */, + ); + productName = DoryFSWorker; + productReference = D0C200000000000000000001 /* DoryFSWorker.xpc */; + productType = "com.apple.product-type.xpc-service"; + }; + D0D500000000000000000001 /* DoryRendererWorker */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0D600000000000000000003 /* Build configuration list for PBXNativeTarget "DoryRendererWorker" */; + buildPhases = ( + D0D400000000000000000003 /* Sources */, + D0D400000000000000000001 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + D0D300000000000000000001 /* DoryRendererWorker Sources */, + ); + name = DoryRendererWorker; + packageProductDependencies = ( + D0D700000000000000000001 /* DoryRendererWorkerContracts */, + D0D700000000000000000002 /* DoryRendererWorkerServiceCore */, + D0D700000000000000000003 /* DoryRendererWorkerVirglBackend */, + ); + productName = DoryRendererWorker; + productReference = D0D200000000000000000001 /* DoryRendererWorker.xpc */; + productType = "com.apple.product-type.xpc-service"; + }; 3E705CFB2FE37C7B0094B33C /* DoryTests */ = { isa = PBXNativeTarget; buildConfigurationList = 3E705D112FE37C7B0094B33C /* Build configuration list for PBXNativeTarget "DoryTests" */; @@ -261,6 +491,15 @@ AA100000000000000000000A = { CreatedOnToolsVersion = 27.0; }; + D0B500000000000000000001 = { + CreatedOnToolsVersion = 27.0; + }; + D0C500000000000000000001 = { + CreatedOnToolsVersion = 27.0; + }; + D0D500000000000000000001 = { + CreatedOnToolsVersion = 27.0; + }; 3E705CEE2FE37C790094B33C = { CreatedOnToolsVersion = 27.0; }; @@ -285,6 +524,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( AA00000000000000000000D1 /* XCLocalSwiftPackageReference "dory-core-swift" */, + D0B700000000000000000000 /* XCLocalSwiftPackageReference "ContainerizationEngine" */, AA00000000000000000000B1 /* XCRemoteSwiftPackageReference "SwiftTerm" */, AA00000000000000000000C1 /* XCRemoteSwiftPackageReference "Sparkle" */, ); @@ -294,6 +534,9 @@ projectRoot = ""; targets = ( 3E705CEE2FE37C790094B33C /* Dory */, + D0B500000000000000000001 /* DoryHVRunner */, + D0C500000000000000000001 /* DoryFSWorker */, + D0D500000000000000000001 /* DoryRendererWorker */, AA100000000000000000000A /* DoryStorageProvider */, 3E705CFB2FE37C7B0094B33C /* DoryTests */, 3E705D052FE37C7B0094B33C /* DoryUITests */, @@ -324,9 +567,64 @@ files = ( ); }; + D0B400000000000000000002 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + D0E400000000000000000002 /* Verify Release Guest Kernels */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/guest/kernel/PINS", + "$(SRCROOT)/guest/kernel/verify-release-package.sh", + "$(SRCROOT)/guest/kernel/verify-build.sh", + ); + name = "Verify Release Guest Kernels"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/bash; + shellScript = "exec /bin/bash \"$SRCROOT/guest/kernel/verify-release-package.sh\"\n"; + }; + D0E400000000000000000001 /* Package Exact Renderer Tuple */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/Config/DoryRendererProductionTuple.json", + "$(SRCROOT)/Packages/ContainerizationEngine/DoryRendererWorker.entitlements", + "$(SRCROOT)/Packages/ContainerizationEngine/Package.swift", + "$(SRCROOT)/scripts/assemble-renderer-production-worker.sh", + "$(SRCROOT)/scripts/package-renderer-production-bundle.py", + "$(SRCROOT)/scripts/renderer-production-tuple.py", + "$(SRCROOT)/scripts/xcode-package-renderer-production.sh", + ); + name = "Package Exact Renderer Tuple"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/bash; + shellScript = "exec \"$SRCROOT/scripts/xcode-package-renderer-production.sh\"\n"; + }; AA00000000000000000000D0 /* Prune Stale Bundled Helpers */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -345,7 +643,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -eu\nAPP=\"${TARGET_BUILD_DIR}/${WRAPPER_NAME}\"\nHELPERS=\"$APP/Contents/Helpers\"\nif [ -d \"$HELPERS\" ]; then\n find \"$HELPERS\" -maxdepth 1 -type f -exec rm -f {} +\n rmdir \"$HELPERS\" 2>/dev/null || true\nfi\n"; + shellScript = "set -eu\nAPP=\"${TARGET_BUILD_DIR}/${WRAPPER_NAME}\"\nHELPERS=\"$APP/Contents/Helpers\"\ncase \"$HELPERS\" in\n \"${TARGET_BUILD_DIR}\"/*.app/Contents/Helpers) ;;\n *) echo \"error: refusing to prune unexpected helper path: $HELPERS\" >&2; exit 1 ;;\nesac\nif [ -d \"$HELPERS\" ]; then\n find \"$HELPERS\" -depth -delete\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -372,6 +670,27 @@ files = ( ); }; + D0B400000000000000000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0C400000000000000000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0D400000000000000000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -380,6 +699,21 @@ target = AA100000000000000000000A /* DoryStorageProvider */; targetProxy = AA1000000000000000000002 /* PBXContainerItemProxy */; }; + D0B800000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0B500000000000000000001 /* DoryHVRunner */; + targetProxy = D0B800000000000000000001 /* PBXContainerItemProxy */; + }; + D0C800000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0C500000000000000000001 /* DoryFSWorker */; + targetProxy = D0C800000000000000000001 /* PBXContainerItemProxy */; + }; + D0D800000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0D500000000000000000001 /* DoryRendererWorker */; + targetProxy = D0D800000000000000000001 /* PBXContainerItemProxy */; + }; 3E705CFE2FE37C7B0094B33C /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 3E705CEE2FE37C790094B33C /* Dory */; @@ -399,7 +733,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = DoryStorageProvider/DoryStorageProvider.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 52; DEVELOPMENT_TEAM = 864H636QW4; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -411,7 +745,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.4.5; + MARKETING_VERSION = 0.4.6; PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory.StorageProvider; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -427,7 +761,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = DoryStorageProvider/DoryStorageProvider.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 52; DEVELOPMENT_TEAM = 864H636QW4; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -439,7 +773,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.4.5; + MARKETING_VERSION = 0.4.6; PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory.StorageProvider; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -577,12 +911,16 @@ CODE_SIGN_ENTITLEMENTS = Dory/Dory.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 52; DEVELOPMENT_TEAM = 864H636QW4; + DORY_BUNDLE_VENUS = 0; + DORY_BUNDLE_VENUS_REQUIRED = 0; + DORY_VM_QUALIFICATION_BOOTSTRAP = 0; ENABLE_APP_SANDBOX = NO; ENABLE_DEBUG_DYLIB = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Dory-Info.plist; @@ -590,11 +928,12 @@ INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Dory connects to its local Linux VM so your container ports are reachable at localhost."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Dory shares your Mac microphone with Linux desktop machines when you use audio input."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.4.5; + MARKETING_VERSION = 0.4.6; PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -615,11 +954,15 @@ CODE_SIGN_ENTITLEMENTS = Dory/Dory.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 52; DEVELOPMENT_TEAM = 864H636QW4; + DORY_BUNDLE_VENUS = 1; + DORY_BUNDLE_VENUS_REQUIRED = 1; + DORY_VM_QUALIFICATION_BOOTSTRAP = 0; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Dory-Info.plist; @@ -627,11 +970,12 @@ INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Dory connects to its local Linux VM so your container ports are reachable at localhost."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Dory shares your Mac microphone with Linux desktop machines when you use audio input."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.4.5; + MARKETING_VERSION = 0.4.6; PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -649,11 +993,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 52; DEVELOPMENT_TEAM = 864H636QW4; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.4.5; + MARKETING_VERSION = 0.4.6; PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.DoryTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -670,11 +1014,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 52; DEVELOPMENT_TEAM = 864H636QW4; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.4.5; + MARKETING_VERSION = 0.4.6; PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.DoryTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -690,10 +1034,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 52; DEVELOPMENT_TEAM = 864H636QW4; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.4.5; + MARKETING_VERSION = 0.4.6; PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.DoryUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -709,10 +1053,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 49; + CURRENT_PROJECT_VERSION = 52; DEVELOPMENT_TEAM = 864H636QW4; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.4.5; + MARKETING_VERSION = 0.4.6; PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.DoryUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -724,6 +1068,190 @@ }; name = Release; }; + D0B600000000000000000001 /* Debug configuration for PBXNativeTarget "DoryHVRunner" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Packages/ContainerizationEngine/dory-hv.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 52; + DEVELOPMENT_TEAM = 864H636QW4; + DORY_BUNDLE_VENUS = 0; + DORY_BUNDLE_VENUS_REQUIRED = 0; + ENABLE_APP_SANDBOX = NO; + ENABLE_DEBUG_DYLIB = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + EXECUTABLE_NAME = "dory-hv"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Config/DoryHVRunner-Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 0.4.6; + OTHER_SWIFT_FLAGS = "$(inherited) -package-name containerizationengine"; + PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory.HVRunner; + PRODUCT_NAME = DoryHVRunner; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + D0B600000000000000000002 /* Release configuration for PBXNativeTarget "DoryHVRunner" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_ENTITLEMENTS = Packages/ContainerizationEngine/dory-hv.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 52; + DEVELOPMENT_TEAM = 864H636QW4; + DORY_BUNDLE_VENUS = 1; + DORY_BUNDLE_VENUS_REQUIRED = 1; + ENABLE_APP_SANDBOX = NO; + ENABLE_DEBUG_DYLIB = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + EXECUTABLE_NAME = "dory-hv"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Config/DoryHVRunner-Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 0.4.6; + OTHER_SWIFT_FLAGS = "$(inherited) -package-name containerizationengine"; + PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory.HVRunner; + PRODUCT_NAME = DoryHVRunner; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + D0C600000000000000000001 /* Debug configuration for PBXNativeTarget "DoryFSWorker" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_ENTITLEMENTS = Packages/ContainerizationEngine/DoryFSWorker.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 52; + DEVELOPMENT_TEAM = 864H636QW4; + ENABLE_APP_SANDBOX = NO; + ENABLE_DEBUG_DYLIB = NO; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Config/DoryFSWorker-Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 0.4.6; + PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory.HVRunner.FSWorker; + PRODUCT_NAME = DoryFSWorker; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + D0C600000000000000000002 /* Release configuration for PBXNativeTarget "DoryFSWorker" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_ENTITLEMENTS = Packages/ContainerizationEngine/DoryFSWorker.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 52; + DEVELOPMENT_TEAM = 864H636QW4; + ENABLE_APP_SANDBOX = NO; + ENABLE_DEBUG_DYLIB = NO; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Config/DoryFSWorker-Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 0.4.6; + PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory.HVRunner.FSWorker; + PRODUCT_NAME = DoryFSWorker; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + D0D600000000000000000001 /* Debug configuration for PBXNativeTarget "DoryRendererWorker" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Packages/ContainerizationEngine/DoryRendererWorker.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 52; + DEVELOPMENT_TEAM = 864H636QW4; + ENABLE_APP_SANDBOX = YES; + ENABLE_DEBUG_DYLIB = NO; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Config/DoryRendererWorker-Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 0.4.6; + PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory.HVRunner.RendererWorker; + PRODUCT_NAME = DoryRendererWorker; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + D0D600000000000000000002 /* Release configuration for PBXNativeTarget "DoryRendererWorker" */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_ENTITLEMENTS = Packages/ContainerizationEngine/DoryRendererWorker.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 52; + DEVELOPMENT_TEAM = 864H636QW4; + ENABLE_APP_SANDBOX = YES; + ENABLE_DEBUG_DYLIB = NO; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Config/DoryRendererWorker-Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 0.4.6; + PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory.HVRunner.RendererWorker; + PRODUCT_NAME = DoryRendererWorker; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -768,6 +1296,33 @@ ); defaultConfigurationName = Release; }; + D0B600000000000000000003 /* Build configuration list for PBXNativeTarget "DoryHVRunner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0B600000000000000000001 /* Debug configuration for PBXNativeTarget "DoryHVRunner" */, + D0B600000000000000000002 /* Release configuration for PBXNativeTarget "DoryHVRunner" */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D0C600000000000000000003 /* Build configuration list for PBXNativeTarget "DoryFSWorker" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0C600000000000000000001 /* Debug configuration for PBXNativeTarget "DoryFSWorker" */, + D0C600000000000000000002 /* Release configuration for PBXNativeTarget "DoryFSWorker" */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D0D600000000000000000003 /* Build configuration list for PBXNativeTarget "DoryRendererWorker" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0D600000000000000000001 /* Debug configuration for PBXNativeTarget "DoryRendererWorker" */, + D0D600000000000000000002 /* Release configuration for PBXNativeTarget "DoryRendererWorker" */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ @@ -775,6 +1330,10 @@ isa = XCLocalSwiftPackageReference; relativePath = "dory-core-swift"; }; + D0B700000000000000000000 /* XCLocalSwiftPackageReference "ContainerizationEngine" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/ContainerizationEngine; + }; /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -812,6 +1371,61 @@ package = AA00000000000000000000C1 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + D0B700000000000000000001 /* DoryHV */ = { + isa = XCSwiftPackageProductDependency; + package = D0B700000000000000000000 /* XCLocalSwiftPackageReference "ContainerizationEngine" */; + productName = DoryHV; + }; + D0B700000000000000000002 /* DoryCore */ = { + isa = XCSwiftPackageProductDependency; + package = AA00000000000000000000D1 /* XCLocalSwiftPackageReference "dory-core-swift" */; + productName = DoryCore; + }; + D0B700000000000000000003 /* DorydKit */ = { + isa = XCSwiftPackageProductDependency; + package = AA00000000000000000000D1 /* XCLocalSwiftPackageReference "dory-core-swift" */; + productName = DorydKit; + }; + D0B700000000000000000004 /* DoryOperations */ = { + isa = XCSwiftPackageProductDependency; + package = AA00000000000000000000D1 /* XCLocalSwiftPackageReference "dory-core-swift" */; + productName = DoryOperations; + }; + D0B700000000000000000005 /* DoryVMContracts */ = { + isa = XCSwiftPackageProductDependency; + package = AA00000000000000000000D1 /* XCLocalSwiftPackageReference "dory-core-swift" */; + productName = DoryVMContracts; + }; + D0B700000000000000000006 /* DoryVMMKit */ = { + isa = XCSwiftPackageProductDependency; + package = AA00000000000000000000D1 /* XCLocalSwiftPackageReference "dory-core-swift" */; + productName = DoryVMMKit; + }; + D0C700000000000000000001 /* DoryFSWorkerContracts */ = { + isa = XCSwiftPackageProductDependency; + package = D0B700000000000000000000 /* XCLocalSwiftPackageReference "ContainerizationEngine" */; + productName = DoryFSWorkerContracts; + }; + D0C700000000000000000002 /* DoryFSWorkerServiceCore */ = { + isa = XCSwiftPackageProductDependency; + package = D0B700000000000000000000 /* XCLocalSwiftPackageReference "ContainerizationEngine" */; + productName = DoryFSWorkerServiceCore; + }; + D0D700000000000000000001 /* DoryRendererWorkerContracts */ = { + isa = XCSwiftPackageProductDependency; + package = D0B700000000000000000000 /* XCLocalSwiftPackageReference "ContainerizationEngine" */; + productName = DoryRendererWorkerContracts; + }; + D0D700000000000000000002 /* DoryRendererWorkerServiceCore */ = { + isa = XCSwiftPackageProductDependency; + package = D0B700000000000000000000 /* XCLocalSwiftPackageReference "ContainerizationEngine" */; + productName = DoryRendererWorkerServiceCore; + }; + D0D700000000000000000003 /* DoryRendererWorkerVirglBackend */ = { + isa = XCSwiftPackageProductDependency; + package = D0B700000000000000000000 /* XCLocalSwiftPackageReference "ContainerizationEngine" */; + productName = DoryRendererWorkerVirglBackend; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 3E705CE72FE37C790094B33C /* Project object */; diff --git a/Dory/App/AppInfo.swift b/Dory/App/AppInfo.swift index c60449e7..8d3500de 100644 --- a/Dory/App/AppInfo.swift +++ b/Dory/App/AppInfo.swift @@ -26,6 +26,14 @@ nonisolated enum AppInfo { && [.desktopDebian, .desktopUbuntu, .desktopKali].contains(where: componentAvailable) } + /// Candidate-only bridge for booting exact desktop bytes while schema-2 qualification + /// evidence is collected. Normal builds omit the key and stay fail-closed. + static var vmQualificationBootstrapEnabled: Bool { + explicitBuildFlagBundleValue( + Bundle.main.object(forInfoDictionaryKey: "DoryVMQualificationBootstrap") + ) + } + static func componentAvailable(_ id: DoryComponentID) -> Bool { if bundledComponents.contains(id) { return true } guard id.isRemovable, let store = try? DoryComponentStore.selected() else { return false } @@ -44,12 +52,28 @@ nonisolated enum AppInfo { } static func desktopLinuxIncluded(from bundleValue: Any?) -> Bool { + booleanBundleValue(bundleValue, default: true) + } + + static func booleanBundleValue(_ bundleValue: Any?, default defaultValue: Bool) -> Bool { + if let value = bundleValue as? Bool { + return value + } + if let value = bundleValue as? NSNumber { + return value.boolValue + } + return defaultValue + } + + /// Xcode expands custom Info.plist build settings as strings. Accept only the canonical + /// enabled value so malformed, missing, and human-authored truthy strings remain disabled. + static func explicitBuildFlagBundleValue(_ bundleValue: Any?) -> Bool { if let value = bundleValue as? Bool { return value } if let value = bundleValue as? NSNumber { return value.boolValue } - return true + return (bundleValue as? String) == "1" } } diff --git a/Dory/DesignSystem/Tokens.swift b/Dory/DesignSystem/Tokens.swift index d4448cf3..07ede815 100644 --- a/Dory/DesignSystem/Tokens.swift +++ b/Dory/DesignSystem/Tokens.swift @@ -26,3 +26,32 @@ enum DoryRadius: CGFloat { case md = 8 case lg = 12 } + +/// Shared sizing for page-level card collections. +/// +/// Page grids deliberately use wider cards than compact grids embedded in sheets or +/// cards. Resource cards carry metrics, runtime evidence, and several labeled actions; +/// allowing them to collapse to the old 340-point minimum made those controls wrap +/// while the surrounding page remained mostly empty. +enum DoryPageGrid { + static let spacing: CGFloat = 16 + static let horizontalInset: CGFloat = 20 + static let verticalInset: CGFloat = 18 + + static let resourceCardMinimumWidth: CGFloat = 520 + static let resourceCardMaximumWidth: CGFloat = 680 + + static let componentCardMinimumWidth: CGFloat = 420 + static let componentCardMaximumWidth: CGFloat = 560 + static let componentContentMaximumWidth: CGFloat = 1_180 + + static func columns(minimum: CGFloat, maximum: CGFloat) -> [GridItem] { + [ + GridItem( + .adaptive(minimum: minimum, maximum: maximum), + spacing: spacing, + alignment: .topLeading + ) + ] + } +} diff --git a/Dory/Dory.entitlements b/Dory/Dory.entitlements index 9fddc7b3..d69c0ad5 100644 --- a/Dory/Dory.entitlements +++ b/Dory/Dory.entitlements @@ -10,5 +10,7 @@ com.apple.security.network.server + com.apple.security.device.audio-input + diff --git a/Dory/Features/Components/ComponentsView.swift b/Dory/Features/Components/ComponentsView.swift index 29f45483..2c73f5a7 100644 --- a/Dory/Features/Components/ComponentsView.swift +++ b/Dory/Features/Components/ComponentsView.swift @@ -10,6 +10,7 @@ struct ComponentsView: View { @State private var catalogData = Data() @State private var statuses: [DoryComponentStatus] = [] @State private var progress: [DoryComponentID: DoryComponentProgress] = [:] + @State private var activeOperationIDs: [DoryComponentID: UUID] = [:] @State private var busy: Set = [] @State private var pendingRemoval: DoryComponentID? @State private var errorMessage: String? @@ -70,7 +71,11 @@ struct ComponentsView: View { } dataSafetyPanel } - .frame(maxWidth: 820, alignment: .leading) + .frame( + maxWidth: embedded ? 820 : DoryPageGrid.componentContentMaximumWidth, + alignment: .leading + ) + .frame(maxWidth: .infinity, alignment: .leading) } private var header: some View { @@ -99,9 +104,12 @@ struct ComponentsView: View { private var componentGrid: some View { LazyVGrid( - columns: [GridItem(.adaptive(minimum: embedded ? 300 : 340, maximum: 410), spacing: 12)], + columns: DoryPageGrid.columns( + minimum: embedded ? 300 : DoryPageGrid.componentCardMinimumWidth, + maximum: embedded ? 410 : DoryPageGrid.componentCardMaximumWidth + ), alignment: .leading, - spacing: 12 + spacing: DoryPageGrid.spacing ) { ForEach(statuses) { status in componentCard(status) @@ -223,6 +231,8 @@ struct ComponentsView: View { .tint(p.accent) Text("\(currentProgress.phase.rawValue.capitalized) · \(formatted(currentProgress.completedBytes)) of \(formatted(currentProgress.totalBytes))") .font(.system(size: 10.5)).foregroundStyle(p.text3) + Text("Operation \(currentProgress.operationID.uuidString.lowercased().prefix(8))…") + .font(.system(size: 9.5, design: .monospaced)).foregroundStyle(p.text3) } } @@ -263,6 +273,11 @@ struct ComponentsView: View { Spacer(minLength: 0) Text(status.installedVersion.map { "v\($0)" } ?? "v\(status.availableVersion)") .font(.system(size: 10.5, weight: .medium)).foregroundStyle(p.text3) + if let operationID = status.installationOperationID { + Text("op \(operationID.prefix(8))…") + .font(.system(size: 9.5, design: .monospaced)).foregroundStyle(p.text3) + .help("Installed by component operation \(operationID)") + } } } @@ -300,7 +315,7 @@ struct ComponentsView: View { case .notInstalled: p.text3 } let label: String = switch state { - case .bundled: "Core" + case .bundled: "Included" case .installed: "Installed" case .updateAvailable: "Update" case .invalid: "Repair" @@ -399,7 +414,9 @@ struct ComponentsView: View { catalogData = loadedData statuses = store.list( catalog: loadedCatalog, - catalogDigest: DoryComponentCatalogVerifier.digest(loadedData) + catalogDigest: DoryComponentCatalogVerifier.digest(loadedData), + bundledComponents: AppInfo.bundledComponents, + bundledVersion: AppInfo.version ) usingCachedCatalog = cached errorMessage = nil @@ -410,20 +427,27 @@ struct ComponentsView: View { @MainActor @discardableResult private func install(_ id: DoryComponentID, showSuccess: Bool = true) async -> Bool { + if AppInfo.bundledComponents.contains(id) { return true } guard let catalog, !catalogData.isEmpty else { return false } - var operationIDs: Set = [id] + let operationID = UUID() + var affectedComponents: Set = [id] busy.insert(id) + activeOperationIDs[id] = operationID errorMessage = nil defer { - for operationID in operationIDs { - busy.remove(operationID) - progress[operationID] = nil + for componentID in affectedComponents { + busy.remove(componentID) + if activeOperationIDs[componentID] == operationID { + activeOperationIDs[componentID] = nil + progress[componentID] = nil + } } } do { let store = try DoryComponentStore.selected() let installer = DoryComponentInstaller(store: store) let digest = DoryComponentCatalogVerifier.digest(catalogData) + var activatedComponents: Set = [id] for release in try installationOrder(id, catalog: catalog) { if let current = try store.installedComponent(release.id), current.version == release.version, @@ -431,17 +455,44 @@ struct ComponentsView: View { (try? store.verify(release.id)) != nil { continue } - operationIDs.insert(release.id) + affectedComponents.insert(release.id) busy.insert(release.id) - _ = try await installer.install(release, catalogData: catalogData) { update in - Task { @MainActor in self.progress[release.id] = update } + activeOperationIDs[release.id] = operationID + _ = try await installer.install( + release, + catalogData: catalogData, + operationID: operationID + ) { update in + Task { @MainActor in + guard self.activeOperationIDs[release.id] == update.operationID else { + return + } + self.progress[release.id] = update + } } + activatedComponents.insert(release.id) busy.remove(release.id) } - statuses = store.list(catalog: catalog, catalogDigest: digest) + let desktopUpdates = try await appStore.updateManagedDesktops( + affectedBy: activatedComponents, + operationID: operationID + ) + statuses = store.list( + catalog: catalog, + catalogDigest: digest, + bundledComponents: AppInfo.bundledComponents, + bundledVersion: AppInfo.version + ) HostDockerCLI.reconcileOptionalTools(enabled: appStore.routeDockerCLI) if showSuccess { - appStore.showSettingsSuccess("\(displayName(id)) is installed and verified.") + let updated = desktopUpdates.isEmpty + ? "" + : " Updated " + String(desktopUpdates.count) + " existing desktop" + + (desktopUpdates.count == 1 ? "." : "s.") + appStore.showSettingsSuccess( + "\(displayName(id)) is installed and verified (operation " + + "\(operationID.uuidString.lowercased().prefix(8))…)." + updated + ) } return true } catch { @@ -471,7 +522,9 @@ struct ComponentsView: View { if let catalog { statuses = store.list( catalog: catalog, - catalogDigest: DoryComponentCatalogVerifier.digest(catalogData) + catalogDigest: DoryComponentCatalogVerifier.digest(catalogData), + bundledComponents: AppInfo.bundledComponents, + bundledVersion: AppInfo.version ) } appStore.showSettingsSuccess("\(displayName(id)) passed verification.") @@ -489,7 +542,9 @@ struct ComponentsView: View { try store.remove(id, catalog: catalog) statuses = store.list( catalog: catalog, - catalogDigest: DoryComponentCatalogVerifier.digest(catalogData) + catalogDigest: DoryComponentCatalogVerifier.digest(catalogData), + bundledComponents: AppInfo.bundledComponents, + bundledVersion: AppInfo.version ) HostDockerCLI.reconcileOptionalTools(enabled: appStore.routeDockerCLI) appStore.showSettingsSuccess("Removed \(displayName(id)). Your workload data was preserved.") @@ -505,7 +560,9 @@ struct ComponentsView: View { var visited: Set = [] var ordered: [DoryComponentRelease] = [] func append(_ current: DoryComponentID) throws { - guard current != .dockerCore, !visited.contains(current) else { return } + guard !AppInfo.bundledComponents.contains(current), !visited.contains(current) else { + return + } guard let release = catalog.component(current) else { throw DoryComponentError.unknownComponent(current.rawValue) } diff --git a/Dory/Features/Machines/MachinePortForwardEditor.swift b/Dory/Features/Machines/MachinePortForwardEditor.swift new file mode 100644 index 00000000..5eb8f9c0 --- /dev/null +++ b/Dory/Features/Machines/MachinePortForwardEditor.swift @@ -0,0 +1,164 @@ +import DoryOperations +import SwiftUI + +struct MachinePortForwardDraft: Identifiable, Hashable { + let id: UUID + var name: String + var transport: DoryVMPortForwardTransport + var hostPort: String + var guestPort: String + var exposure: DoryVMPortForwardExposure + + init( + id: UUID = UUID(), + name: String = "", + transport: DoryVMPortForwardTransport = .tcp, + hostPort: String = "", + guestPort: String = "", + exposure: DoryVMPortForwardExposure = .loopback + ) { + self.id = id + self.name = name + self.transport = transport + self.hostPort = hostPort + self.guestPort = guestPort + self.exposure = exposure + } + + init(_ forward: DoryVMPortForward) { + self.init( + name: forward.id, + transport: forward.transport, + hostPort: String(forward.hostPort), + guestPort: String(forward.guestPort), + exposure: forward.exposure + ) + } + + var resolved: DoryVMPortForward? { + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard Self.isSafeIdentifier(normalizedName), + let host = UInt16(hostPort), host >= 1_024, + let guest = UInt16(guestPort), guest > 0 else { + return nil + } + return DoryVMPortForward( + id: normalizedName, + transport: transport, + hostPort: host, + guestPort: guest, + exposure: exposure + ) + } + + private static func isSafeIdentifier(_ value: String) -> Bool { + let bytes = Array(value.utf8) + func alphaNumeric(_ byte: UInt8) -> Bool { + (48...57).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte) + } + guard (1...63).contains(bytes.count), alphaNumeric(bytes[0]) else { return false } + return bytes.dropFirst().allSatisfy { + alphaNumeric($0) || $0 == 95 || $0 == 46 || $0 == 45 + } + } + + static func resolved( + _ rows: [Self], + networkMode: DoryVMNetworkMode + ) -> [DoryVMPortForward]? { + guard rows.count <= DoryVMPortForward.maximumCount else { return nil } + let forwards = rows.compactMap(\.resolved) + guard forwards.count == rows.count, + rows.isEmpty || networkMode == .sharedNAT || networkMode == .isolated else { + return nil + } + var identifiers: Set = [] + var bindings: Set = [] + for forward in forwards { + guard identifiers.insert(forward.id).inserted, + bindings.insert("\(forward.transport.rawValue):\(forward.hostPort)").inserted, + forward.exposure != .lan || networkMode == .sharedNAT else { + return nil + } + } + return forwards + } +} + +struct MachinePortForwardEditor: View { + @Environment(\.palette) private var p + @Binding var rows: [MachinePortForwardDraft] + let networkMode: DoryVMNetworkMode + let accessibilityPrefix: String + + private var isValid: Bool { + MachinePortForwardDraft.resolved(rows, networkMode: networkMode) != nil + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("PORT FORWARDS") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(p.text3) + .tracking(0.5) + Spacer(minLength: 0) + Button { + guard rows.count < DoryVMPortForward.maximumCount else { return } + rows.append(MachinePortForwardDraft()) + } label: { + Image(systemName: "plus") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(p.accent) + .frame(width: 22, height: 22) + .background(p.accentSoft, in: RoundedRectangle(cornerRadius: 7)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("\(accessibilityPrefix)-add-forward") + } + + ForEach($rows) { $row in + HStack(spacing: 7) { + TextField("name", text: $row.name) + .frame(width: 86) + Picker("Transport", selection: $row.transport) { + Text("TCP").tag(DoryVMPortForwardTransport.tcp) + Text("UDP").tag(DoryVMPortForwardTransport.udp) + } + .labelsHidden() + .frame(width: 68) + TextField("host", text: $row.hostPort) + .frame(width: 58) + Image(systemName: "arrow.right") + .font(.system(size: 9)) + .foregroundStyle(p.text3) + TextField("guest", text: $row.guestPort) + .frame(width: 58) + Picker("Exposure", selection: $row.exposure) { + Text("This Mac").tag(DoryVMPortForwardExposure.loopback) + Text("LAN").tag(DoryVMPortForwardExposure.lan) + } + .labelsHidden() + .frame(width: 92) + Button { + rows.removeAll { $0.id == row.id } + } label: { + Image(systemName: "minus.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(p.text3) + } + .buttonStyle(.plain) + } + .textFieldStyle(.roundedBorder) + } + + Text(rows.isEmpty + ? "Expose selected guest services only when you add them." + : isValid + ? "Host ports 1024–65535 are installed exactly before the VM is ready." + : "Use unique names and transport/host-port pairs. LAN requires Shared NAT; host ports start at 1024.") + .font(.system(size: 11)) + .foregroundStyle(isValid ? p.text3 : p.red) + } + } +} diff --git a/Dory/Features/Machines/MachinesView.swift b/Dory/Features/Machines/MachinesView.swift index 37259f01..894dc9d6 100644 --- a/Dory/Features/Machines/MachinesView.swift +++ b/Dory/Features/Machines/MachinesView.swift @@ -1,4 +1,6 @@ +import AppKit import Darwin +import DoryOperations import SwiftUI struct MachinesView: View { @@ -7,7 +9,10 @@ struct MachinesView: View { let displayMode: MachineDisplayMode - private let columns = [GridItem(.adaptive(minimum: 340, maximum: 500), spacing: 14)] + private let columns = DoryPageGrid.columns( + minimum: DoryPageGrid.resourceCardMinimumWidth, + maximum: DoryPageGrid.resourceCardMaximumWidth + ) var body: some View { content @@ -81,8 +86,8 @@ struct MachinesView: View { private var emptyMessage: String { displayMode == .desktop - ? "Create a graphical Linux desktop with its own display, terminal, user, resources, folders, snapshots, and persistent disk." - : "Create a lightweight Linux server for terminals, development tools, services, and VPS-style workflows." + ? "Create an interactive graphical Linux VM for desktop and GUI applications. It has its own display, terminal, user, resources, folders, snapshots, and persistent disk." + : "Create a user-managed headless Linux server VM for terminals, services, and VPS-style workflows. For coding agents, use a policy-enforced Agent Sandbox instead." } private var matchingMachines: [Machine] { @@ -106,12 +111,14 @@ struct MachinesView: View { private var machineGrid: some View { ScrollView { - LazyVGrid(columns: columns, alignment: .leading, spacing: 14) { + LazyVGrid(columns: columns, alignment: .leading, spacing: DoryPageGrid.spacing) { ForEach(matchingFilteredMachines) { machine in MachineCard(machine: machine) } } - .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, DoryPageGrid.horizontalInset) + .padding(.vertical, DoryPageGrid.verticalInset) } } } @@ -122,9 +129,23 @@ private struct MachineCard: View { @Environment(\.openWindow) private var openWindow let machine: Machine @State private var confirmingDelete = false + @State private var confirmingToolsRepair = false + @State private var confirmingInstallerMediaChange = false + @State private var showingIntegrationHealth = false + @State private var showingSerialConsole = false + @State private var isTransferDropTargeted = false private var isRunning: Bool { machine.status == .running } + private var isPaused: Bool { machine.status == .paused } + private var isSuspended: Bool { machine.status == .suspended } + private var isActive: Bool { isRunning || isPaused } private var hasAssignedAddress: Bool { DoryDNS.ipv4Bytes(machine.ip) != nil } + private var fileTransfer: DorydMachineFileTransferOperation? { + store.machineFileTransfer(for: machine.name) + } + private var guestFileExport: DorydMachineGuestFileExportOperation? { + store.machineGuestFileExport(for: machine.name) + } var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -144,26 +165,38 @@ private struct MachineCard: View { } Spacer(minLength: 8) statusPill + serialConsoleButton overflowMenu } HStack(alignment: .top, spacing: 0) { metric("CPU", isRunning ? String(format: "%.1f%%", machine.cpuPercent) : "—") - metric("MEMORY", isRunning ? machine.memoryDisplay : "—") + metric("MEMORY", isActive ? machine.memoryDisplay : "—") VStack(alignment: .leading, spacing: 3) { Text(hasAssignedAddress ? "ADDRESS" : "DNS NAME").font(.system(size: 10, weight: .semibold)).foregroundStyle(p.text3).tracking(0.4) - Text(machine.ip).font(.mono(12.5, weight: .semibold)).foregroundStyle(isRunning ? p.accentText : p.text3).lineLimit(1) + Text(machine.ip).font(.mono(12.5, weight: .semibold)).foregroundStyle(isActive ? p.accentText : p.text3).lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) } .padding(.top, 16).padding(.bottom, 14) - if machine.username != "root" { + if machine.username != "root", !machine.loginShell.isEmpty { Text("\(machine.username) · \(machine.loginShell)") .font(.system(size: 11)).foregroundStyle(p.text3).lineLimit(1) .padding(.bottom, 12) } + runtimeEvidence + .padding(.bottom, 12) + + if let fileTransfer { + fileTransferProgress(fileTransfer) + .padding(.bottom, 12) + } else if let guestFileExport { + guestFileExportProgress(guestFileExport) + .padding(.bottom, 12) + } + if let command = store.machineTerminalCommand(machine) { HStack(spacing: 6) { Image(systemName: "terminal").font(.system(size: 11)).foregroundStyle(p.text3) @@ -186,10 +219,19 @@ private struct MachineCard: View { Divider().overlay(p.border) - HStack(spacing: 8) { - actionButton(isRunning ? "stop.fill" : "play.fill", isRunning ? "Stop" : "Start", prominent: !isRunning) { + HStack(spacing: 10) { + actionButton( + isRunning ? "stop.fill" : "play.fill", + isRunning ? "Stop" : ((isPaused || isSuspended) ? "Resume" : "Start"), + prominent: !isRunning + ) { store.toggleMachine(machine) } + if isRunning { + actionButton("pause.fill", "Pause", prominent: false) { + store.pauseMachine(machine) + } + } if machine.displayMode == .desktop { actionButton("display", "Desktop", prominent: false, enabled: store.canOpenMachineDesktop(machine)) { store.openMachineDesktop(machine) @@ -204,13 +246,108 @@ private struct MachineCard: View { } .padding(16) .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 14)) - .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(p.border)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .strokeBorder( + isTransferDropTargeted ? p.accent : p.border, + lineWidth: isTransferDropTargeted ? 2 : 1 + ) + ) + .overlay { + if isTransferDropTargeted { + ZStack { + RoundedRectangle(cornerRadius: 14) + .fill(p.accentSoft.opacity(0.96)) + VStack(spacing: 8) { + Image(systemName: "arrow.down.doc.fill") + .font(.system(size: 24, weight: .semibold)) + Text(store.canTransferFolders(to: machine) + ? "Send files or folders" + : "Send files") + .font(.system(size: 13, weight: .semibold)) + Text("Copies into a new Downloads folder") + .font(.system(size: 11)) + .foregroundStyle(p.text2) + } + .foregroundStyle(p.accentText) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + .dropDestination(for: URL.self) { urls, _ in + guard store.canTransferFiles(to: machine), + !store.isMachineBusy(machine.name), + !urls.isEmpty, + urls.allSatisfy(\.isFileURL) else { + return false + } + Task { await store.transferFiles(urls, to: machine) } + return true + } isTargeted: { targeted in + isTransferDropTargeted = targeted + && store.canTransferFiles(to: machine) + && !store.isMachineBusy(machine.name) + } + .sheet(isPresented: $showingIntegrationHealth) { + MachineIntegrationHealthSheet(machine: machine) + } + .sheet(isPresented: $showingSerialConsole) { + MachineSerialConsoleSheet(machine: machine) + } .confirmationDialog("Delete machine \(machine.name)?", isPresented: $confirmingDelete, titleVisibility: .visible) { Button("Delete", role: .destructive) { store.deleteMachine(machine) } Button("Cancel", role: .cancel) {} } message: { Text("This permanently deletes the Linux machine and its disk. This cannot be undone.") } + .confirmationDialog( + "Repair Dory Tools in \(machine.name)?", + isPresented: $confirmingToolsRepair, + titleVisibility: .visible + ) { + Button("Repair Dory Tools") { store.repairMachineTools(machine) } + Button("Cancel", role: .cancel) {} + } message: { + Text("Dory will create a last-good snapshot, reinstall the active signed desktop and tools payload, restart the machine, and roll back automatically if verification fails.") + } + .confirmationDialog( + installerMediaDialogTitle, + isPresented: $confirmingInstallerMediaChange, + titleVisibility: .visible + ) { + Button(installerMediaActionTitle) { + store.setMachineInstallerMedia( + machine, + attached: !machine.installerMediaAttached + ) + } + Button("Cancel", role: .cancel) {} + } message: { + Text(installerMediaDialogMessage) + } + } + + private var installerMediaDialogTitle: String { + machine.installerMediaAttached + ? "Eject the installer ISO from \(machine.name)?" + : "Attach the installer ISO to \(machine.name)?" + } + + private var installerMediaActionTitle: String { + let action = machine.installerMediaAttached ? "Eject Installer" : "Attach Installer" + return isActive ? action + " and Restart" : action + } + + private var installerMediaDialogMessage: String { + if machine.installerMediaAttached { + return isActive + ? "Dory will request a graceful shutdown, eject the ISO, and restart from the installed virtual disk. Continue only after the Linux installer has finished writing the disk." + : "The next Start will boot from the installed virtual disk without the ISO. Continue only after installation is complete." + } + return isActive + ? "Dory will request a graceful shutdown, attach the read-only installer ISO, and restart into EFI recovery/install media." + : "The next Start will boot with the read-only installer ISO attached." } private var distroBadge: some View { @@ -229,8 +366,214 @@ private struct MachineCard: View { .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(p.border)) } + private func fileTransferProgress(_ operation: DorydMachineFileTransferOperation) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 7) { + Image(systemName: operation.phase == .cancelling ? "xmark.circle" : "paperplane.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(p.accentText) + Text(fileTransferTitle(operation)) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(p.text) + .lineLimit(1) + Spacer(minLength: 8) + Text(operation.fractionCompleted, format: .percent.precision(.fractionLength(0))) + .font(.mono(10.5, weight: .semibold)) + .foregroundStyle(p.text2) + } + + ProgressView(value: operation.fractionCompleted) + .progressViewStyle(.linear) + .tint(p.accent) + + HStack(spacing: 8) { + Text(fileTransferDetail(operation)) + .font(.system(size: 10.5)) + .foregroundStyle(p.text3) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + if !operation.phase.isTerminal { + Button(operation.phase == .cancelling ? "Cancelling…" : "Cancel") { + Task { await store.cancelFileTransfer(to: machine) } + } + .buttonStyle(.borderless) + .font(.system(size: 10.5, weight: .semibold)) + .disabled(operation.phase == .cancelling) + .accessibilityIdentifier("machine-transfer-cancel-\(machine.name)") + } + } + } + .padding(10) + .background(p.accentSoft.opacity(0.55), in: RoundedRectangle(cornerRadius: 9)) + .overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.accent.opacity(0.24))) + .accessibilityElement(children: .contain) + .accessibilityLabel("File transfer to \(machine.name)") + .accessibilityValue(fileTransferDetail(operation)) + } + + private func fileTransferTitle(_ operation: DorydMachineFileTransferOperation) -> String { + switch operation.phase { + case .preparing: "Preparing files" + case .transferring: "Sending files" + case .finalizing: "Finishing transfer" + case .cancelling: "Cancelling transfer" + case .completed: "Files sent" + case .cancelled: "Transfer cancelled" + case .failed: "Transfer failed" + } + } + + private func fileTransferDetail(_ operation: DorydMachineFileTransferOperation) -> String { + if let currentPath = operation.currentPath { + return currentPath + } + if operation.bytesTotal > 0 { + let completed = ByteCountFormatter.string( + fromByteCount: Int64(clamping: operation.bytesCompleted), + countStyle: .file + ) + let total = ByteCountFormatter.string( + fromByteCount: Int64(clamping: operation.bytesTotal), + countStyle: .file + ) + return "\(completed) of \(total)" + } + if operation.filesTotal > 0 { + return "\(operation.filesCompleted) of \(operation.filesTotal) files" + } + return fileTransferTitle(operation) + } + + private func guestFileExportProgress( + _ operation: DorydMachineGuestFileExportOperation + ) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 7) { + Image(systemName: operation.phase == .cancelling + ? "xmark.circle" : "square.and.arrow.down.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(p.accentText) + Text(guestFileExportTitle(operation)) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(p.text) + .lineLimit(1) + Spacer(minLength: 8) + Text(operation.fractionCompleted, format: .percent.precision(.fractionLength(0))) + .font(.mono(10.5, weight: .semibold)) + .foregroundStyle(p.text2) + } + + ProgressView(value: operation.fractionCompleted) + .progressViewStyle(.linear) + .tint(p.accent) + + HStack(spacing: 8) { + Text(guestFileExportDetail(operation)) + .font(.system(size: 10.5)) + .foregroundStyle(p.text3) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + if operation.phase == .completed { + Button("Save…") { savePendingGuestFileExport() } + .accessibilityIdentifier("machine-export-save-\(machine.name)") + Button("Discard") { + Task { await store.discardGuestFileExport(from: machine) } + } + .accessibilityIdentifier("machine-export-discard-\(machine.name)") + } else if !operation.phase.isTerminal { + Button(operation.phase == .cancelling ? "Cancelling…" : "Cancel") { + Task { await store.cancelGuestFileExport(from: machine) } + } + .disabled(operation.phase == .cancelling) + .accessibilityIdentifier("machine-export-cancel-\(machine.name)") + } + } + .buttonStyle(.borderless) + .font(.system(size: 10.5, weight: .semibold)) + } + .padding(10) + .background(p.accentSoft.opacity(0.55), in: RoundedRectangle(cornerRadius: 9)) + .overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.accent.opacity(0.24))) + .accessibilityElement(children: .contain) + .accessibilityLabel("File export from \(machine.name)") + .accessibilityValue(guestFileExportDetail(operation)) + } + + private func guestFileExportTitle( + _ operation: DorydMachineGuestFileExportOperation + ) -> String { + switch operation.phase { + case .preparing: "Preparing export" + case .transferring: "Receiving files" + case .finalizing: "Verifying files" + case .cancelling: "Cancelling export" + case .completed: "Files ready to save" + case .cancelled: "Export cancelled" + case .failed: "Export failed" + } + } + + private func guestFileExportDetail( + _ operation: DorydMachineGuestFileExportOperation + ) -> String { + if operation.phase == .completed, let result = operation.result { + let bytes = ByteCountFormatter.string( + fromByteCount: Int64(clamping: result.bytesReceived), + countStyle: .file + ) + let fileLabel = result.filesReceived == 1 ? "file" : "files" + return "\(result.filesReceived) \(fileLabel) · \(bytes)" + } + if let currentPath = operation.currentPath { + return currentPath + } + if operation.bytesTotal > 0 { + let completed = ByteCountFormatter.string( + fromByteCount: Int64(clamping: operation.bytesCompleted), + countStyle: .file + ) + let total = ByteCountFormatter.string( + fromByteCount: Int64(clamping: operation.bytesTotal), + countStyle: .file + ) + return "\(completed) of \(total)" + } + return guestFileExportTitle(operation) + } + private var overflowMenu: some View { Menu { + if isActive { + Button { store.suspendMachine(machine) } label: { + Label("Suspend", systemImage: "moon.zzz") + } + Button { store.restartMachine(machine) } label: { + Label("Restart", systemImage: "arrow.clockwise") + } + if isRunning { + Button { selectAndSendFiles() } label: { + Label( + store.canTransferFolders(to: machine) + ? "Send Files or Folders\u{2026}" : "Send Files\u{2026}", + systemImage: "paperplane" + ) + } + .disabled(!store.canTransferFiles(to: machine)) + .help( + store.canTransferFolders(to: machine) + ? "Copy selected files and folders into this machine's Downloads folder" + : "Copy selected files into this machine's Downloads folder" + ) + Button { selectAndReceiveFiles() } label: { + Label("Receive Files or Folder…", systemImage: "square.and.arrow.down") + } + .disabled(!store.canExportGuestFiles(from: machine)) + .help("Copy a file or folder from this machine into a folder on your Mac") + } + Divider() + } Button { store.takeSnapshot(machine, note: "") } label: { Label("Snapshot", systemImage: "camera.aperture") } @@ -247,6 +590,28 @@ private struct MachineCard: View { Button { store.openMachineEdit(machine) } label: { Label("Edit…", systemImage: "slider.horizontal.3") } + Button { showingIntegrationHealth = true } label: { + Label("Integration Health…", systemImage: "stethoscope") + } + Button { showingSerialConsole = true } label: { + Label("Serial Console…", systemImage: "text.line.first.and.arrowtriangle.forward") + } + if store.canRepairMachineTools(machine) { + Button { confirmingToolsRepair = true } label: { + Label("Repair Dory Tools…", systemImage: "wrench.and.screwdriver") + } + } + if machine.bootMode == .efi { + Divider() + Button { + confirmingInstallerMediaChange = true + } label: { + Label( + machine.installerMediaAttached ? "Eject Installer ISO" : "Attach Installer ISO", + systemImage: machine.installerMediaAttached ? "eject" : "opticaldiscdrive" + ) + } + } } label: { Image(systemName: "ellipsis.circle").font(.system(size: 14, weight: .semibold)) .foregroundStyle(p.text2) @@ -258,6 +623,106 @@ private struct MachineCard: View { .disabled(store.isMachineBusy(machine.name) || !store.canUseMachineArtifacts(machine)) } + private var serialConsoleButton: some View { + Button { showingSerialConsole = true } label: { + Image(systemName: "text.line.first.and.arrowtriangle.forward") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(p.text2) + .frame(width: 22, height: 22) + } + .buttonStyle(.plain) + .help("Open serial console") + .accessibilityLabel("Open serial console for \(machine.name)") + } + + private func selectAndSendFiles() { + guard store.canTransferFiles(to: machine) else { return } + let supportsFolders = store.canTransferFolders(to: machine) + let panel = NSOpenPanel() + panel.title = supportsFolders + ? "Send files or folders to \(machine.name)" + : "Send files to \(machine.name)" + panel.message = supportsFolders + ? "Files and folders are copied into a new folder in the machine's Downloads folder." + : "Files are copied into a new folder in the machine's Downloads folder. Update Dory Tools to send folders." + panel.prompt = "Send" + panel.canChooseFiles = true + panel.canChooseDirectories = supportsFolders + panel.allowsMultipleSelection = true + panel.resolvesAliases = false + guard panel.runModal() == .OK else { return } + let selected = panel.urls + guard !selected.isEmpty else { return } + Task { await store.transferFiles(selected, to: machine) } + } + + private func selectAndReceiveFiles() { + guard store.canExportGuestFiles(from: machine), + let guestSource = promptForGuestSource() else { + return + } + let name = guestExportName(for: guestSource) + guard let destination = selectGuestExportDestination(suggestedName: name) else { + return + } + Task { + await store.exportGuestFiles( + guestSource, + from: machine, + to: destination + ) + } + } + + private func savePendingGuestFileExport() { + let name = store.suggestedGuestFileExportName(for: machine.name) + guard let destination = selectGuestExportDestination(suggestedName: name) else { + return + } + Task { await store.saveGuestFileExport(from: machine, to: destination) } + } + + private func promptForGuestSource() -> String? { + let home = "/home/\(machine.username)" + let alert = NSAlert() + alert.messageText = "Receive files from \(machine.name)" + alert.informativeText = "Enter a file or folder inside \(home). Dory verifies the guest transfer before saving it on your Mac." + alert.addButton(withTitle: "Continue") + alert.addButton(withTitle: "Cancel") + let field = NSTextField(string: home + "/Documents") + field.placeholderString = home + "/Documents/project" + field.frame = NSRect(x: 0, y: 0, width: 390, height: 24) + alert.accessoryView = field + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + let value = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + private func selectGuestExportDestination(suggestedName: String) -> URL? { + let panel = NSSavePanel() + panel.title = "Save files from \(machine.name)" + panel.message = "Dory creates a new folder at this location and never overwrites an existing item." + panel.prompt = "Save" + panel.canCreateDirectories = true + panel.nameFieldStringValue = suggestedName + panel.directoryURL = FileManager.default.urls( + for: .downloadsDirectory, + in: .userDomainMask + ).first + guard panel.runModal() == .OK else { return nil } + return panel.url + } + + private func guestExportName(for guestSource: String) -> String { + let name = URL(fileURLWithPath: guestSource).lastPathComponent + guard !name.isEmpty, + name != ".dory-sync-tmp", + name.utf8.count <= 255 else { + return "\(machine.name)-export" + } + return name + } + private var statusPill: some View { HStack(spacing: 5) { Circle().fill(machine.status.dotColor(p)).frame(width: 6, height: 6) @@ -301,15 +766,57 @@ private struct MachineCard: View { } } + private var runtimeEvidence: some View { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 104, maximum: 190), spacing: 6)], + alignment: .leading, + spacing: 6 + ) { + ForEach(machine.runtimeEvidence) { evidence in + HStack(spacing: 4) { + Image(systemName: evidence.systemImage) + .font(.system(size: 9, weight: .semibold)) + Text(evidence.label) + .font(.system(size: 10, weight: .semibold)) + .lineLimit(1) + } + .foregroundStyle(runtimeEvidenceColor(evidence.tone)) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background(runtimeEvidenceBackground(evidence.tone), in: Capsule()) + .help(evidence.detail) + } + } + } + + private func runtimeEvidenceColor(_ tone: MachineRuntimeEvidenceTone) -> Color { + switch tone { + case .standard: p.text2 + case .positive: p.green + case .warning: p.amber + } + } + + private func runtimeEvidenceBackground(_ tone: MachineRuntimeEvidenceTone) -> Color { + switch tone { + case .standard: p.pill + case .positive: p.greenWeak + case .warning: p.amberWeak + } + } + private func actionButton(_ systemImage: String, _ title: String, prominent: Bool, enabled: Bool = true, action: @escaping () -> Void) -> some View { Button(action: action) { HStack(spacing: 6) { Image(systemName: systemImage).font(.system(size: 11, weight: .semibold)) - Text(title).font(.system(size: 12, weight: .semibold)) + Text(title) + .font(.system(size: 12, weight: .semibold)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } .foregroundStyle(prominent ? p.accentText : p.text) - .frame(maxWidth: .infinity) - .padding(.vertical, 7) + .frame(maxWidth: .infinity, minHeight: 32) + .padding(.horizontal, 10) .background(prominent ? p.accentSoft : p.bgInput, in: RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(prominent ? p.accentWeak : p.border)) } @@ -322,13 +829,14 @@ private struct MachineCard: View { Button(action: action) { Image(systemName: systemImage).font(.system(size: 12, weight: .semibold)) .foregroundStyle(p.red) - .frame(width: 34, height: 30) + .frame(width: 38, height: 32) .background(p.redWeak, in: RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(p.border)) } .buttonStyle(.plain) .disabled(store.isMachineBusy(machine.name)) .help("Delete machine") + .accessibilityLabel("Delete \(machine.name)") } } @@ -340,6 +848,496 @@ private func logoName(for distro: String) -> String? { return nil } +private struct MachineSerialConsoleSheet: View { + @Environment(AppStore.self) private var store + @Environment(\.dismiss) private var dismiss + @Environment(\.palette) private var p + + let machine: Machine + + @State private var cursor = DorydMachineSerialConsoleCursor() + @State private var consoleBytes = Data() + @State private var displayedStartOffset: UInt64 = 0 + @State private var inputAvailable = false + @State private var input = "" + @State private var errorMessage: String? + @State private var isSending = false + @State private var hasConnected = false + + private let maximumDisplayedBytes = 1_024 * 1_024 + + private var consoleText: String { + guard !consoleBytes.isEmpty else { + return "Waiting for serial output…" + } + return String(decoding: consoleBytes, as: UTF8.self) + } + + var body: some View { + VStack(spacing: 0) { + header + Divider().overlay(p.border) + console + Divider().overlay(p.border) + inputBar + } + .frame(minWidth: 720, idealWidth: 820, minHeight: 500, idealHeight: 620) + .background(p.bgContent) + .task(id: machine.id) { + while !Task.isCancelled { + await refresh() + try? await Task.sleep(for: .milliseconds(inputAvailable ? 250 : 750)) + } + } + } + + private var header: some View { + HStack(spacing: 12) { + Image(systemName: "text.line.first.and.arrowtriangle.forward") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(p.accentText) + .frame(width: 38, height: 38) + .background(p.accentSoft, in: RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 2) { + Text("Serial Console") + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(p.text) + Text(machine.name) + .font(.mono(11.5, weight: .semibold)) + .foregroundStyle(p.text3) + } + Spacer() + statusPill + Button("Done") { dismiss() } + .keyboardShortcut(.cancelAction) + } + .padding(16) + } + + private var statusPill: some View { + let title = errorMessage != nil ? "RETRYING" : (hasConnected ? "CONNECTED" : "CONNECTING") + let color = errorMessage != nil ? p.amber : (hasConnected ? p.green : p.text3) + let background = errorMessage != nil ? p.amberWeak : (hasConnected ? p.greenWeak : p.pill) + return HStack(spacing: 5) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + Text(title) + .font(.system(size: 9, weight: .bold)) + .tracking(0.5) + } + .foregroundStyle(color) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(background, in: Capsule()) + } + + private var console: some View { + ScrollViewReader { proxy in + ScrollView([.horizontal, .vertical]) { + Text(consoleText) + .font(.system(size: 11.5, design: .monospaced)) + .foregroundStyle(p.monoText) + .textSelection(.enabled) + .frame(maxWidth: .infinity, minHeight: 400, alignment: .topLeading) + .padding(14) + Color.clear.frame(width: 1, height: 1).id("serial-console-end") + } + .background(p.monoBg) + .onChange(of: cursor.offset) { + proxy.scrollTo("serial-console-end", anchor: .bottom) + } + } + .overlay(alignment: .topTrailing) { + if let errorMessage { + Text(errorMessage) + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(p.amber) + .lineLimit(2) + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background(p.monoBg.opacity(0.94), in: RoundedRectangle(cornerRadius: 7)) + .padding(10) + } + } + } + + private var inputBar: some View { + VStack(spacing: 8) { + HStack(spacing: 8) { + Text("offset \(displayedStartOffset)–\(cursor.offset)") + .font(.mono(9.5, weight: .medium)) + .foregroundStyle(p.text3) + Spacer() + Text(inputAvailable ? "INPUT AVAILABLE" : "READ-ONLY ON THIS BACKEND") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(inputAvailable ? p.green : p.text3) + .tracking(0.45) + } + + HStack(spacing: 8) { + TextField(inputAvailable ? "Send input to the guest" : "Serial input is unavailable", text: $input) + .textFieldStyle(.plain) + .font(.system(size: 11.5, design: .monospaced)) + .padding(.horizontal, 10) + .frame(height: 32) + .background(p.bgInput, in: RoundedRectangle(cornerRadius: 7)) + .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(p.border)) + .disabled(!inputAvailable || isSending) + .onSubmit { sendInput() } + Button("Send") { sendInput() } + .disabled(!canSend) + } + } + .padding(12) + .background(p.bgWindow) + } + + private var canSend: Bool { + inputAvailable && !isSending && !input.isEmpty && pendingInputData.count <= 4 * 1_024 + } + + private var pendingInputData: Data { + var data = Data(input.utf8) + if data.last != 0x0A { data.append(0x0A) } + return data + } + + private func refresh() async { + do { + let batch = try await store.readMachineSerialConsole(machine, cursor: cursor) + if batch.snapshotRequired || batch.generation != cursor.generation { + consoleBytes = batch.bytes + displayedStartOffset = batch.startOffset + } else if !batch.bytes.isEmpty { + consoleBytes.append(batch.bytes) + } + if consoleBytes.count > maximumDisplayedBytes { + let excess = consoleBytes.count - maximumDisplayedBytes + consoleBytes.removeFirst(excess) + displayedStartOffset += UInt64(excess) + } + cursor = batch.cursor + inputAvailable = batch.inputAvailable + errorMessage = nil + hasConnected = true + } catch { + errorMessage = String(describing: error) + inputAvailable = false + } + } + + private func sendInput() { + guard canSend else { return } + let data = pendingInputData + isSending = true + Task { + defer { isSending = false } + do { + try await store.writeMachineSerialConsole(machine, data: data) + input = "" + errorMessage = nil + } catch { + errorMessage = String(describing: error) + } + } + } +} + +private struct MachineIntegrationHealthSheet: View { + @Environment(AppStore.self) private var store + @Environment(\.dismiss) private var dismiss + @Environment(\.palette) private var p + let machine: Machine + @State private var confirmingRepair = false + + private var health: DoryGuestIntegrationHealth { machine.integrationHealthProjection } + + var body: some View { + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 14) { + Image(systemName: healthIcon) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(healthColor) + .frame(width: 42, height: 42) + .background(healthBackground, in: RoundedRectangle(cornerRadius: 11)) + VStack(alignment: .leading, spacing: 4) { + Text("Integration Health") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(p.text) + Text(machine.name) + .font(.mono(12, weight: .semibold)) + .foregroundStyle(p.text3) + } + Spacer() + Button("Done") { dismiss() } + .keyboardShortcut(.defaultAction) + } + .padding(20) + + Divider().overlay(p.border) + + ScrollView { + VStack(alignment: .leading, spacing: 18) { + healthSummary + + VStack(alignment: .leading, spacing: 9) { + Text("INTEGRATIONS") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(p.text3) + .tracking(0.7) + ForEach(health.features, id: \.id) { feature in + featureRow(feature) + } + } + } + .padding(20) + } + + if store.canRepairMachineTools(machine) { + Divider().overlay(p.border) + HStack { + Text("Repair reinstalls the active signed Dory Tools payload with rollback.") + .font(.system(size: 11)) + .foregroundStyle(p.text3) + Spacer() + Button("Repair Dory Tools…") { confirmingRepair = true } + .disabled(store.isMachineBusy(machine.name)) + } + .padding(16) + } + } + .frame(width: 610, height: 650) + .background(p.bgContent) + .confirmationDialog( + "Repair Dory Tools in \(machine.name)?", + isPresented: $confirmingRepair, + titleVisibility: .visible + ) { + Button("Repair Dory Tools") { + store.repairMachineTools(machine) + dismiss() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Dory will create a last-good snapshot, reinstall the active signed desktop and tools payload, restart the machine, and roll back automatically if verification fails.") + } + } + + private var healthSummary: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text(healthTitle) + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(healthColor) + Text(health.runtimeAuthority.rawValue) + .font(.mono(10, weight: .semibold)) + .foregroundStyle(p.text2) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(p.pill, in: Capsule()) + } + Text(healthDescription) + .font(.system(size: 12)) + .foregroundStyle(p.text2) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 16) { + summaryValue("TOOLS BUILD", health.agentBuild ?? "Not connected") + summaryValue( + "PROTOCOL", + health.agentProtocolVersion.map(String.init) ?? "—" + ) + summaryValue( + "ACTIVE", + "\(health.features.filter { $0.state == .active }.count)/\(health.features.count)" + ) + } + } + .padding(14) + .background(healthBackground.opacity(0.55), in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(p.border)) + } + + private func summaryValue(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(label) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(p.text3) + .tracking(0.5) + Text(value) + .font(.mono(11, weight: .semibold)) + .foregroundStyle(p.text) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func featureRow(_ feature: DoryGuestIntegrationFeatureHealth) -> some View { + HStack(spacing: 10) { + Image(systemName: featureIcon(feature.state)) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(featureColor(feature.state)) + .frame(width: 24, height: 24) + .background(featureBackground(feature.state), in: RoundedRectangle(cornerRadius: 6)) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(featureTitle(feature.id)) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(p.text) + if feature.required { + Text("REQUIRED") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(p.text3) + } + } + Text("\(feature.provider.rawValue) · \(featureVersion(feature))") + .font(.mono(9.5)) + .foregroundStyle(p.text3) + } + Spacer() + Text(featureStateTitle(feature.state)) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(featureColor(feature.state)) + } + .padding(.horizontal, 11) + .padding(.vertical, 9) + .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 9)) + .overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border)) + } + + private var healthTitle: String { + switch health.state { + case .inactive: "Inactive" + case .missingTools: "Dory Tools missing" + case .incompatible: "Dory Tools incompatible" + case .degraded: "Integration degraded" + case .compatibility: "Compatibility mode" + case .healthy: "All required integrations healthy" + } + } + + private var healthDescription: String { + switch health.state { + case .inactive: "The workspace is stopped. Live guest-integration evidence is intentionally inactive." + case .missingTools: "The running guest has not completed a valid Dory Tools handshake." + case .incompatible: "The running guest reported a tools protocol this version of Dory cannot use." + case .degraded: "One or more required capabilities are unavailable, outdated, or need a current runtime plan." + case .compatibility: "Guest capabilities are negotiated, but host runtime integrations are not backed by a qualified resolved plan." + case .healthy: "The daemon verified the runtime authority and every required integration is active." + } + } + + private var healthIcon: String { + switch health.state { + case .healthy: "checkmark.seal.fill" + case .inactive: "pause.circle.fill" + case .compatibility: "arrow.triangle.2.circlepath" + default: "exclamationmark.triangle.fill" + } + } + + private var healthColor: Color { + switch health.state { + case .healthy: p.green + case .inactive, .compatibility: p.text2 + default: p.amber + } + } + + private var healthBackground: Color { + switch health.state { + case .healthy: p.greenWeak + case .inactive, .compatibility: p.pill + default: p.amberWeak + } + } + + private func featureTitle(_ id: DoryGuestIntegrationCapabilityID) -> String { + switch id { + case .readiness: "Guest readiness" + case .gracefulShutdown: "Graceful shutdown" + case .reboot: "Guest reboot" + case .clockSynchronization: "Clock synchronization" + case .health: "Health reporting" + case .displayTopology: "Display topology" + case .displayResize: "Dynamic display resize" + case .clipboardText: "Text clipboard" + case .clipboardImage: "Image clipboard" + case .sharedFolderDiscovery: "Shared-folder discovery" + case .sharedFolderMountStatus: "Shared-folder mount status" + case .fileTransferPush: "Host-to-guest transfer" + case .fileTransferPull: "Guest-to-host transfer" + case .networkIdentity: "Network identity" + case .processLaunch: "Process launch" + case .processInput: "Process input" + case .listenPorts: "Port discovery" + case .lifecycleReceipt: "Lifecycle acknowledgement" + case .telemetry: "Telemetry" + case .snapshotQuiesce: "Snapshot freeze/thaw" + case .packageUpdate: "Tools update" + } + } + + private func featureVersion(_ feature: DoryGuestIntegrationFeatureHealth) -> String { + guard let minimum = feature.minimumVersion else { return "runtime-qualified" } + if let negotiated = feature.negotiatedVersion { + return "v\(negotiated), requires v\(minimum)+" + } + return "requires v\(minimum)+" + } + + private func featureStateTitle(_ state: DoryGuestIntegrationFeatureState) -> String { + switch state { + case .inactive: "Inactive" + case .active: "Active" + case .unavailable: "Unavailable" + case .updateRequired: "Update required" + case .unqualified: "Unqualified" + } + } + + private func featureIcon(_ state: DoryGuestIntegrationFeatureState) -> String { + switch state { + case .active: "checkmark" + case .inactive: "pause.fill" + case .updateRequired: "arrow.clockwise" + case .unavailable, .unqualified: "exclamationmark" + } + } + + private func featureColor(_ state: DoryGuestIntegrationFeatureState) -> Color { + switch state { + case .active: p.green + case .inactive, .unqualified: p.text3 + case .unavailable, .updateRequired: p.amber + } + } + + private func featureBackground(_ state: DoryGuestIntegrationFeatureState) -> Color { + switch state { + case .active: p.greenWeak + case .inactive, .unqualified: p.pill + case .unavailable, .updateRequired: p.amberWeak + } + } +} + +nonisolated enum MachineAudioSettingsPolicy { + static func editedConfiguration( + existing: DoryVMAudioConfiguration?, + inputEnabled: Bool, + outputEnabled: Bool + ) -> DoryVMAudioConfiguration? { + // An older daemon may omit the audio claim entirely. Preserve that absence for an + // unrelated edit, but let an explicit toggle opt into the typed policy. + guard existing != nil || !inputEnabled || !outputEnabled else { return nil } + return DoryVMAudioConfiguration( + inputEnabled: inputEnabled, + outputEnabled: outputEnabled + ) + } +} + private struct MachineEditSheet: View { @Environment(AppStore.self) private var store @Environment(\.palette) private var p @@ -350,13 +1348,30 @@ private struct MachineEditSheet: View { @State private var address = "" @State private var displayMode: MachineDisplayMode = .headless @State private var guestUsername = "dory" - @State private var environment: [String: String] = [:] + @State private var clipboardPolicy = DoryDesktopClipboardPolicy.bidirectional + @State private var fileTransferPolicy = DoryVMClipboardDirection.bidirectional + @State private var initialClipboardPicker = DoryDesktopClipboardPolicy.bidirectional + @State private var initialFileTransferPolicy = DoryVMClipboardDirection.bidirectional + @State private var originalClipboardPolicy: DoryVMClipboardPolicy? + @State private var runtimePreference = DoryDesktopVMMPreference.automatic + @State private var graphicsPreference = DoryDesktopGraphicsPreference.automatic + @State private var hostDisplays: [HostDisplayChoice] = [] + @State private var dedicatedHostDisplayUUID: String? + @State private var networkMode = DoryVMNetworkMode.sharedNAT + @State private var portForwardRows: [MachinePortForwardDraft] = [] + @State private var audioInputEnabled = true + @State private var audioOutputEnabled = true + @State private var originalAudioConfiguration: DoryVMAudioConfiguration? + @State private var cameraEnabled = false + @State private var intelApplicationTranslationEnabled = false + @State private var typedSettings = DorydMachineTypedSettings() private struct MountRow: Identifiable, Hashable { let id = UUID() var host = "" var guest = "" var readOnly = false + var shareTag: String? = nil } @State private var mountRows: [MountRow] = [] @@ -369,6 +1384,17 @@ private struct MachineEditSheet: View { VStack(alignment: .leading, spacing: 18) { warning machineTypeBlock + networkBlock + MachinePortForwardEditor( + rows: $portForwardRows, + networkMode: networkMode, + accessibilityPrefix: "edit-machine" + ) + audioBlock + runtimeBlock + displayAssignmentBlock + intelApplicationTranslationBlock + clipboardBlock resourceRow addressBlock mountsBlock @@ -389,9 +1415,44 @@ private struct MachineEditSheet: View { memoryGB = max(1, min(16, settings.memoryMB.map { $0 / 1024 } ?? 4)) address = settings.address ?? "" displayMode = settings.displayMode - environment = settings.env - guestUsername = settings.env["DORY_GUEST_USER"] ?? "dory" - mountRows = settings.mounts.map { MountRow(host: $0.host, guest: $0.guest, readOnly: $0.readOnly) } + typedSettings = settings.virtualMachineSettings ?? DorydMachineTypedSettings( + legacyEnvironment: settings.env, + displayMode: settings.displayMode + ) + // Keep an unrepresentable legacy username visible and invalid until the user explicitly + // corrects that field. Hiding it behind a default would turn an unrelated edit into a + // destructive rewrite. + guestUsername = settings.env[DoryVMGuestAccountIntent.legacyUsernameEnvironmentKey] + ?? typedSettings.guestIdentityIntent.account?.username + ?? "dory" + originalClipboardPolicy = typedSettings.clipboardPolicy + clipboardPolicy = typedSettings.clipboardPolicy.flatMap { + guard $0.text == $0.image else { return nil } + return DoryDesktopClipboardPolicy(rawValue: $0.text.rawValue) + } ?? .bidirectional + fileTransferPolicy = typedSettings.clipboardPolicy?.files ?? .bidirectional + initialClipboardPicker = clipboardPolicy + initialFileTransferPolicy = fileTransferPolicy + runtimePreference = typedSettings.runtimePreference ?? .automatic + graphicsPreference = typedSettings.graphicsPreference ?? .automatic + hostDisplays = HostDisplayChoice.connectedDisplays() + let presentation = settings.displayPresentation ?? .windowed + dedicatedHostDisplayUUID = presentation.assignment( + forGuestDisplayID: "display-0" + ).flatMap { + $0.mode == .dedicatedFullscreen ? $0.hostDisplayUUID : nil + } + networkMode = typedSettings.networkMode ?? .sharedNAT + portForwardRows = typedSettings.portForwards.map(MachinePortForwardDraft.init) + originalAudioConfiguration = typedSettings.audioConfiguration + audioInputEnabled = typedSettings.audioConfiguration?.inputEnabled ?? true + audioOutputEnabled = typedSettings.audioConfiguration?.outputEnabled ?? true + cameraEnabled = typedSettings.cameraConfiguration?.enabled ?? false + intelApplicationTranslationEnabled = typedSettings + .intelApplicationTranslationEnabled ?? false + mountRows = settings.mounts.map { + MountRow(host: $0.host, guest: $0.guest, readOnly: $0.readOnly, shareTag: $0.shareTag) + } } private var header: some View { @@ -401,7 +1462,10 @@ private struct MachineEditSheet: View { .background(p.accentSoft, in: RoundedRectangle(cornerRadius: 10)) VStack(alignment: .leading, spacing: 1) { Text("Edit \(machine.name)").font(.system(size: 15, weight: .bold)).foregroundStyle(p.text) - Text("Apply user, resources, address and mounted folders").font(.system(size: 11.5)).foregroundStyle(p.text3) + Text(machine.bootMode == .efi + ? "Apply resources, address and mounted folders" + : "Apply user, resources, address and mounted folders") + .font(.system(size: 11.5)).foregroundStyle(p.text3) } Spacer() } @@ -411,7 +1475,9 @@ private struct MachineEditSheet: View { private var warning: some View { HStack(spacing: 9) { Image(systemName: "info.circle.fill").font(.system(size: 13)).foregroundStyle(p.accent) - Text("Resource, user and mount changes restart a running machine automatically. The machine type is fixed to protect its existing disk.") + Text(machine.bootMode == .efi + ? "Resource and mount changes restart a running machine automatically. The EFI machine type is fixed to protect its installed disk." + : "Resource, user and mount changes restart a running machine automatically. The machine type is fixed to protect its existing disk.") .font(.system(size: 12)).foregroundStyle(p.text2) Spacer(minLength: 0) } @@ -428,13 +1494,13 @@ private struct MachineEditSheet: View { .frame(width: 34, height: 34) .background(p.accentSoft, in: RoundedRectangle(cornerRadius: 9)) VStack(alignment: .leading, spacing: 2) { - Text(displayMode == .desktop ? "Desktop Linux" : "Headless Linux") + Text(machine.bootMode == .efi ? "Custom EFI Linux" : (displayMode == .desktop ? "Desktop Linux" : "Headless Linux")) .font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text) Text(displayMode == .desktop ? "\(machine.distro) \(machine.version)" : "Lightweight Dory Linux") .font(.system(size: 11)).foregroundStyle(p.text3) } Spacer(minLength: 0) - if displayMode == .desktop { + if displayMode == .desktop, machine.bootMode != .efi { TextField("dory", text: $guestUsername) .textFieldStyle(.plain) .font(.mono(11.5)).foregroundStyle(p.text) @@ -478,6 +1544,155 @@ private struct MachineEditSheet: View { } } + private var networkBlock: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("NETWORK") + Picker("Network", selection: $networkMode) { + Text("Shared NAT").tag(DoryVMNetworkMode.sharedNAT) + Text("Host-only").tag(DoryVMNetworkMode.isolated) + Text("Disconnected").tag(DoryVMNetworkMode.disconnected) + } + .labelsHidden() + .pickerStyle(.segmented) + .accessibilityIdentifier("edit-machine-network-mode") + Text(networkMode == .disconnected + ? "Disconnected keeps the virtual adapter present with its link down." + : networkMode == .isolated + ? "Host-only allows private Mac-to-machine connectivity with no external route." + : "Shared NAT provides outbound access through your Mac without exposing the machine directly.") + .font(.system(size: 11)).foregroundStyle(p.text3) + } + } + + @ViewBuilder private var audioBlock: some View { + if displayMode == .desktop { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("AUDIO & VIDEO") + HStack(spacing: 24) { + Toggle("Microphone", isOn: $audioInputEnabled) + .toggleStyle(.switch) + .tint(p.accent) + .accessibilityIdentifier("edit-machine-audio-input") + Toggle("Speakers", isOn: $audioOutputEnabled) + .toggleStyle(.switch) + .tint(p.accent) + .accessibilityIdentifier("edit-machine-audio-output") + Toggle("Camera", isOn: $cameraEnabled) + .toggleStyle(.switch) + .tint(p.accent) + .accessibilityIdentifier("edit-machine-camera") + .disabled(machine.bootMode == .efi) + Spacer(minLength: 0) + } + .font(.system(size: 12.5)) + .foregroundStyle(p.text) + .disabled(!audioPolicyEditable) + Text(audioPolicyEditable + ? (machine.bootMode == .efi + ? "Speakers and microphone use standard VirtIO audio. Camera sharing is currently available on Dory-managed accelerated desktops, not custom ISO compatibility guests." + : "Enabled devices are attached explicitly. Camera sharing appears in Linux as a standard UVC webcam and follows macOS camera permission.") + : "This compatibility machine keeps its historical combined audio device. Replan it into the resolved runtime before changing audio policy.") + .font(.system(size: 11)) + .foregroundStyle(p.text3) + } + } + } + + @ViewBuilder private var clipboardBlock: some View { + if displayMode == .desktop, machine.bootMode != .efi { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("CLIPBOARD SHARING") + Picker("Clipboard sharing", selection: $clipboardPolicy) { + ForEach(DoryDesktopClipboardPolicy.allCases, id: \.self) { policy in + Text(policy.displayName).tag(policy) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .accessibilityIdentifier("edit-machine-clipboard-policy") + Text("Control whether text and images can move between this Linux desktop and your Mac.") + .font(.system(size: 11)).foregroundStyle(p.text3) + Picker("File transfer", selection: $fileTransferPolicy) { + Text("Off").tag(DoryVMClipboardDirection.off) + Text("To Linux").tag(DoryVMClipboardDirection.hostToGuest) + Text("To Mac").tag(DoryVMClipboardDirection.guestToHost) + Text("Both").tag(DoryVMClipboardDirection.bidirectional) + } + .pickerStyle(.segmented) + .accessibilityIdentifier("edit-machine-file-transfer-policy") + Text("File and folder drag/drop uses Dory Tools and follows this direction independently of text and images.") + .font(.system(size: 11)).foregroundStyle(p.text3) + } + } + } + + @ViewBuilder private var runtimeBlock: some View { + if displayMode == .desktop, machine.bootMode != .efi { + VStack(alignment: .leading, spacing: 9) { + sectionLabel("DISPLAY ENGINE") + HStack(spacing: 14) { + Picker("Virtual machine", selection: $runtimePreference) { + Text("Automatic").tag(DoryDesktopVMMPreference.automatic) + Text("Accelerated").tag(DoryDesktopVMMPreference.accelerated) + Text("Compatibility").tag(DoryDesktopVMMPreference.compatible) + } + .frame(maxWidth: .infinity) + .accessibilityIdentifier("edit-machine-vmm-preference") + + Picker("Graphics", selection: $graphicsPreference) { + Text("Automatic").tag(DoryDesktopGraphicsPreference.automatic) + Text("VirGL").tag(DoryDesktopGraphicsPreference.virgl) + Text("VirGL + Venus").tag(DoryDesktopGraphicsPreference.virglVenus) + Text("Software").tag(DoryDesktopGraphicsPreference.software) + } + .frame(maxWidth: .infinity) + .disabled(runtimePreference == .compatible) + .accessibilityIdentifier("edit-machine-graphics-preference") + } + Text(runtimePreference == .compatible + ? "Compatibility uses Apple's Virtualization framework; the raw graphics choice is kept for when you switch back." + : "Automatic uses VirGL for the desktop and Venus for Vulkan apps, then falls back safely when acceleration is unavailable.") + .font(.system(size: 11)).foregroundStyle(p.text3) + } + } + } + + @ViewBuilder private var displayAssignmentBlock: some View { + if displayMode == .desktop { + VStack(alignment: .leading, spacing: 9) { + sectionLabel("MAC DISPLAY") + Picker("Guest presentation", selection: $dedicatedHostDisplayUUID) { + Text("Windowed").tag(String?.none) + ForEach(hostDisplays) { display in + Text("Dedicated — \(display.name)").tag(Optional(display.id)) + } + if let selected = dedicatedHostDisplayUUID, + !hostDisplays.contains(where: { $0.id == selected }) { + Text("Disconnected display — window fallback") + .tag(Optional(selected)) + } + } + .accessibilityIdentifier("edit-machine-host-display") + Text(dedicatedHostDisplayUUID == nil + ? "The Linux desktop opens as a normal Mac window." + : "The guest owns a native full-screen Space on this monitor. Command-Control-F exits full screen; disconnecting it falls back to a normal window.") + .font(.system(size: 11)) + .foregroundStyle(p.text3) + } + } + } + + @ViewBuilder private var intelApplicationTranslationBlock: some View { + if displayMode == .desktop, machine.bootMode != .efi { + MachineIntelApplicationTranslationControl( + isEnabled: $intelApplicationTranslationEnabled, + editable: intelApplicationTranslationPolicyEditable, + runtimeCompatible: runtimePreference != .accelerated, + accessibilityPrefix: "edit-machine" + ) + } + } + private var addressBlock: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel("DNS TARGET OVERRIDE") @@ -542,7 +1757,12 @@ private struct MachineEditSheet: View { .background(p.accent.opacity(store.isMachineBusy(machine.name) ? 0.5 : 1), in: RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) - .disabled(store.isMachineBusy(machine.name) || guestUsernameInvalid) + .disabled( + store.isMachineBusy(machine.name) + || guestUsernameInvalid + || resolvedPortForwards == nil + || intelApplicationTranslationRuntimeConflict + ) } .padding(.horizontal, 18).padding(.vertical, 13) } @@ -611,10 +1831,29 @@ private struct MachineEditSheet: View { let host = row.host.trimmingCharacters(in: .whitespaces) let guest = row.guest.trimmingCharacters(in: .whitespaces) guard !host.isEmpty, !guest.isEmpty else { return nil } - return MountPair(host: host, guest: guest, readOnly: row.readOnly) + return MountPair( + host: host, + guest: guest, + readOnly: row.readOnly, + shareTag: row.shareTag + ) } + typedSettings.networkMode = networkMode + typedSettings.portForwards = resolvedPortForwards ?? [] if displayMode == .desktop { - let previousUsername = environment["DORY_GUEST_USER"] ?? "dory" + typedSettings.audioConfiguration = MachineAudioSettingsPolicy.editedConfiguration( + existing: originalAudioConfiguration, + inputEnabled: audioInputEnabled, + outputEnabled: audioOutputEnabled + ) + if machine.bootMode != .efi { + typedSettings.cameraConfiguration = DoryVMCameraConfiguration( + enabled: cameraEnabled + ) + } + } + if displayMode == .desktop, machine.bootMode != .efi { + let previousUsername = typedSettings.guestIdentityIntent.account?.username ?? "dory" if previousUsername != normalizedGuestUsername { let previousHome = "/home/\(previousUsername)" let updatedHome = "/home/\(normalizedGuestUsername)" @@ -625,20 +1864,61 @@ private struct MachineEditSheet: View { return MountPair( host: mount.host, guest: updatedHome + String(mount.guest.dropFirst(previousHome.count)), - readOnly: mount.readOnly + readOnly: mount.readOnly, + shareTag: mount.shareTag ) } } - environment["DORY_GUEST_USER"] = normalizedGuestUsername - environment["DORY_GUEST_UID"] = environment["DORY_GUEST_UID"] ?? String(getuid()) + typedSettings.guestIdentityIntent.account = DoryVMGuestAccountIntent( + username: normalizedGuestUsername, + numericUserID: typedSettings.guestIdentityIntent.account?.numericUserID + ) + if originalClipboardPolicy != nil + || clipboardPolicy != initialClipboardPicker + || fileTransferPolicy != initialFileTransferPolicy { + var exactPolicy = originalClipboardPolicy ?? .disabled + if clipboardPolicy != initialClipboardPicker { + let direction = DoryVMClipboardDirection( + rawValue: clipboardPolicy.rawValue + ) ?? .bidirectional + exactPolicy.text = direction + exactPolicy.image = direction + } + if fileTransferPolicy != initialFileTransferPolicy { + exactPolicy.files = fileTransferPolicy + } + typedSettings.clipboardPolicy = exactPolicy + } + if typedSettings.runtimePreference != nil || runtimePreference != .automatic { + typedSettings.runtimePreference = runtimePreference + } + if typedSettings.graphicsPreference != nil || graphicsPreference != .automatic { + typedSettings.graphicsPreference = graphicsPreference + } + if typedSettings.intelApplicationTranslationEnabled != nil + || intelApplicationTranslationEnabled { + typedSettings.intelApplicationTranslationEnabled = + intelApplicationTranslationEnabled + } } let settings = MachineSettings( cpus: cpus, memoryMB: memoryGB * 1024, mounts: mounts, - env: environment, + env: [:], + virtualMachineSettings: typedSettings, + displayPresentation: DoryMachineDisplayPresentation( + assignments: dedicatedHostDisplayUUID.map { + [DoryGuestDisplayPresentationAssignment( + guestDisplayID: "display-0", + mode: .dedicatedFullscreen, + hostDisplayUUID: $0 + )] + } ?? [] + ), address: address.trimmingCharacters(in: .whitespacesAndNewlines), - displayMode: displayMode + displayMode: displayMode, + bootMode: machine.bootMode ) let target = machine store.editMachineTarget = nil @@ -649,8 +1929,24 @@ private struct MachineEditSheet: View { guestUsername.trimmingCharacters(in: .whitespacesAndNewlines) } + private var resolvedPortForwards: [DoryVMPortForward]? { + MachinePortForwardDraft.resolved(portForwardRows, networkMode: networkMode) + } + + private var audioPolicyEditable: Bool { + machine.runtimeIdentity.mode != "legacy-compatibility" + } + + private var intelApplicationTranslationPolicyEditable: Bool { + machine.runtimeIdentity.mode != "legacy-compatibility" + } + + private var intelApplicationTranslationRuntimeConflict: Bool { + intelApplicationTranslationEnabled && runtimePreference == .accelerated + } + private var guestUsernameInvalid: Bool { - guard displayMode == .desktop else { return false } + guard displayMode == .desktop, machine.bootMode != .efi else { return false } return normalizedGuestUsername.range( of: "^[a-z_][a-z0-9_-]{0,31}$", options: .regularExpression diff --git a/Dory/Features/Machines/SnapshotsSheet.swift b/Dory/Features/Machines/SnapshotsSheet.swift index 1eb34840..190b32c9 100644 --- a/Dory/Features/Machines/SnapshotsSheet.swift +++ b/Dory/Features/Machines/SnapshotsSheet.swift @@ -238,6 +238,15 @@ struct SnapshotsSheet: View { Text(relativeTime(snapshot.createdISO)).font(.system(size: 11)).foregroundStyle(p.text3) Text("·").font(.system(size: 11)).foregroundStyle(p.text3) Text(DockerFormat.bytes(snapshot.sizeBytes)).font(.mono(11)).foregroundStyle(p.text3) + if let consistency = snapshot.consistency { + Text("·").font(.system(size: 11)).foregroundStyle(p.text3) + Text(consistency == .guestQuiesced ? "Guest quiesced" : "Cold stopped") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(consistency == .guestQuiesced ? p.green : p.text3) + .help(snapshot.guestQuiesceReceipt.map { + "Quiesced by \($0.agentBuild) using snapshot-quiesce@\($0.capabilityVersion)" + } ?? "The machine was stopped before its disks were copied") + } } } Spacer(minLength: 8) diff --git a/Dory/Features/Settings/SettingsView.swift b/Dory/Features/Settings/SettingsView.swift index 59d2bf30..ae62a970 100644 --- a/Dory/Features/Settings/SettingsView.swift +++ b/Dory/Features/Settings/SettingsView.swift @@ -31,7 +31,6 @@ struct SettingsView: View { @State private var customDomainDraft = "" @State private var customDomainPortDraft = "80" @State private var customSocketDraft = "" - @State private var machineEnvAllowListDraft = "" @State private var engineCPUCountDraft = 1 @State private var engineMemoryGiBDraft = 2 @State private var detectedEngineSources: [DockerSourceEngine] = [] @@ -1267,51 +1266,22 @@ struct SettingsView: View { private var machines: some View { VStack(alignment: .leading, spacing: 22) { - groupLabel("NEW MACHINE DEFAULTS") - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 3) { - Text("Host environment allow-list") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(p.text) - Text("Only named variables are copied when a new machine is created. Empty values are skipped; values are read at creation time.") - .font(.system(size: 11.5)) - .foregroundStyle(p.text3) - .lineLimit(3) - } - Spacer(minLength: 0) - Button(action: commitMachineEnvAllowListDraft) { - Image(systemName: "checkmark") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(.white) - .frame(width: 28, height: 26) - .background(p.accent, in: RoundedRectangle(cornerRadius: 7)) - } - .buttonStyle(.plain) - .help("Save environment allow-list") - .accessibilityIdentifier("machine-env-save") - } - TextField("ANTHROPIC_API_KEY, GH_TOKEN", text: $machineEnvAllowListDraft, onCommit: commitMachineEnvAllowListDraft) - .textFieldStyle(.plain) - .font(.mono(12)) - .foregroundStyle(p.text) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(p.bgInput, in: RoundedRectangle(cornerRadius: 8)) - .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(p.border)) - .accessibilityIdentifier("machine-env-allow-list") - VStack(spacing: 0) { - machineEnvToggleRow("ANTHROPIC_API_KEY", divider: true) - ForEach(MachineEnvImport.optionalExtras, id: \.self) { name in - machineEnvToggleRow(name, divider: name != (MachineEnvImport.optionalExtras.last ?? "")) - } + groupLabel("NEW MACHINE CREDENTIALS") + HStack(alignment: .top, spacing: 12) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(p.green) + .frame(width: 24, height: 24) + VStack(alignment: .leading, spacing: 4) { + Text("Host credentials are never copied") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(p.text) + Text("New machines do not inherit API keys, tokens, or other host environment values. Legacy machine records remain compatible; scoped secret grants will be configured separately.") + .font(.system(size: 11.5)) + .foregroundStyle(p.text3) + .lineLimit(4) } - .background(p.bgInput, in: RoundedRectangle(cornerRadius: 9)) - .overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border)) - Text(machineEnvAllowListSummary) - .font(.system(size: 11.5)) - .foregroundStyle(p.text3) - .lineLimit(2) + Spacer(minLength: 0) } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) @@ -1320,27 +1290,14 @@ struct SettingsView: View { groupLabel("AGENT SANDBOXES") VStack(spacing: 0) { - machinePolicyRow("Persistent machines", "No host folders are shared unless the create request includes mounts. Settings above control which host env names may be copied.", divider: true) - machinePolicyRow("Sandbox runs", "`dory sandbox run` starts a dedicated VM with no host file sharing by default. Add mounts explicitly for scoped workspace access.", divider: true) - machinePolicyRow("Shell access", "`dory machine shell NAME` and `dory machine exec NAME -- ...` enter the VM boundary; the agent sees machine files, mounted folders, and copied env only.", divider: false) + machinePolicyRow("Linux Desktops", "Interactive graphical VMs for desktop and GUI applications. They have a display and are managed by the user.", divider: true) + machinePolicyRow("Linux Servers", "General-purpose headless VMs for terminals and long-running services. They are user-managed and are not Agent Sandboxes.", divider: true) + machinePolicyRow("Agent Sandboxes", "Dedicated headless VMs for coding agents and Linux CLI applications. They have no desktop/display, deny host sharing and network access by default, and expose typed profiles, templates, limits, reset, and persistent attach.", divider: true) + machinePolicyRow("Persistent agent terminal", "`dory sandbox attach NAME` enters the Sandbox as its non-root workload identity and reconnects to its tmux session. `use`, `current`, and `switch` remember which Sandbox an agent works in.", divider: false) } .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 11)) .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border)) } - .onAppear(perform: syncMachineEnvAllowListDraft) - .onChange(of: store.machineEnvAllowList) { _, _ in syncMachineEnvAllowListDraft() } - } - - private func machineEnvToggleRow(_ name: String, divider: Bool) -> some View { - toggleRow( - name, - machineEnvDraftNames.contains(name) ? "Copied when present" : "Not copied", - isOn: Binding( - get: { machineEnvDraftNames.contains(name) }, - set: { enabled in setMachineEnvDraft(name, enabled: enabled) } - ), - divider: divider - ) } private func machinePolicyRow(_ title: String, _ subtitle: String, divider: Bool) -> some View { @@ -1365,38 +1322,6 @@ struct SettingsView: View { .overlay(alignment: .bottom) { if divider { Rectangle().fill(p.border).frame(height: 1) } } } - private var machineEnvDraftNames: [String] { - MachineEnvImport.parse(machineEnvAllowListDraft) - } - - private var machineEnvAllowListSummary: String { - let names = MachineEnvImport.normalize(store.machineEnvAllowList) - guard !names.isEmpty else { return "No host environment variables are copied to new machines." } - return "Saved: \(names.joined(separator: ", "))" - } - - private func syncMachineEnvAllowListDraft() { - machineEnvAllowListDraft = MachineEnvImport.serialize(store.machineEnvAllowList) - } - - private func commitMachineEnvAllowListDraft() { - let names = MachineEnvImport.parse(machineEnvAllowListDraft) - store.setMachineEnvAllowList(names) - machineEnvAllowListDraft = MachineEnvImport.serialize(store.machineEnvAllowList) - } - - private func setMachineEnvDraft(_ name: String, enabled: Bool) { - var names = machineEnvDraftNames - if enabled { - names.append(name) - } else { - names.removeAll { $0 == name } - } - machineEnvAllowListDraft = MachineEnvImport.serialize(names) - store.setMachineEnvAllowList(names) - machineEnvAllowListDraft = MachineEnvImport.serialize(store.machineEnvAllowList) - } - private var engine: some View { let kind = store.runtimeKind let onShared = kind == .sharedVM diff --git a/Dory/Features/Settings/UsbDevicesView.swift b/Dory/Features/Settings/UsbDevicesView.swift index 4162673b..47894a83 100644 --- a/Dory/Features/Settings/UsbDevicesView.swift +++ b/Dory/Features/Settings/UsbDevicesView.swift @@ -2,11 +2,10 @@ import SwiftUI struct UsbDevicesView: View { @Environment(\.palette) private var p - @State private var devicesOutput = "" + @State private var hostDevices: [DorydHostUSBDevice] = [] @State private var machine = UserDefaults.standard.string(forKey: "dev.dory.usb.lastMachine") ?? "default" @State private var busid = "" - @State private var port = "" - @State private var rememberAttachment = true + @State private var machines: [DorydMachineStatus] = [] @State private var remembered: [UsbAttachment] = UsbAttachmentStore().attachments() @State private var busy = false @State private var status = "" @@ -27,12 +26,20 @@ struct UsbDevicesView: View { } ScrollView { - Text(devicesOutput.isEmpty ? "No USB scan has run yet." : devicesOutput) - .font(.system(size: 11.5, design: .monospaced)) - .foregroundStyle(devicesOutput.isEmpty ? p.text3 : p.text2) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) + if hostDevices.isEmpty { + Text("No attachable host USB devices found.") + .font(.system(size: 11.5)) + .foregroundStyle(p.text3) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } else { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(hostDevices) { device in + hostDeviceButton(device) + if device.id != hostDevices.last?.id { Divider() } + } + } + } } .frame(minHeight: 180, maxHeight: 280) .background(p.bgInput, in: RoundedRectangle(cornerRadius: 8)) @@ -45,42 +52,38 @@ struct UsbDevicesView: View { groupLabel("ATTACHMENT") VStack(alignment: .leading, spacing: 12) { Label { - Text(UsbPassthroughAvailability.unavailableReason) + Text(availabilityMessage) .font(.system(size: 12)) .foregroundStyle(p.text2) } icon: { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) + Image(systemName: attachmentIsAvailable + ? "checkmark.shield.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(attachmentIsAvailable ? .green : .orange) } - .accessibilityIdentifier("usb-passthrough-unavailable") + .accessibilityIdentifier(attachmentIsAvailable + ? "usb-passthrough-available" : "usb-passthrough-unavailable") + + Text("Attach grants the selected guest temporary access to this host device. The public app requests user authorization only; it never silently seizes or captures devices.") + .font(.system(size: 11.5)) + .foregroundStyle(p.text3) HStack(spacing: 10) { - TextField("machine", text: $machine) - .textFieldStyle(.roundedBorder) - .font(.system(size: 12, design: .monospaced)) + Picker("Machine", selection: $machine) { + if machines.isEmpty { + Text("No local machines").tag(machine) + } else { + ForEach(machines, id: \.id) { candidate in + Text("\(candidate.id) · \(candidate.state)").tag(candidate.id) + } + } + } + .labelsHidden() + .frame(minWidth: 190) .accessibilityIdentifier("usb-machine") - TextField("bus id or vid:pid", text: $busid) + TextField("bus id", text: $busid) .textFieldStyle(.roundedBorder) .font(.system(size: 12, design: .monospaced)) .accessibilityIdentifier("usb-busid") - TextField("port", text: $port) - .textFieldStyle(.roundedBorder) - .font(.system(size: 12, design: .monospaced)) - .frame(width: 76) - .accessibilityIdentifier("usb-port") - } - .disabled(!UsbPassthroughAvailability.attachSupported) - - Toggle("Remember for this machine", isOn: $rememberAttachment) - .font(.system(size: 12.5)) - .toggleStyle(.checkbox) - .disabled(!UsbPassthroughAvailability.attachSupported) - - if UsbPassthroughAvailability.attachSupported, - rememberAttachment && validRememberPort() == nil { - Text("Enter a valid port (1-65535) to remember this attachment for automatic replay.") - .font(.system(size: 11)) - .foregroundStyle(p.text3) } HStack(spacing: 10) { @@ -90,7 +93,7 @@ struct UsbDevicesView: View { } .buttonStyle(.borderedProminent) .disabled( - !UsbPassthroughAvailability.attachSupported || + !attachmentIsAvailable || busy || busid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ) @@ -100,7 +103,7 @@ struct UsbDevicesView: View { } .buttonStyle(.bordered) .disabled( - !UsbPassthroughAvailability.attachSupported || + !attachmentIsAvailable || busy || busid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ) } @@ -115,7 +118,7 @@ struct UsbDevicesView: View { if !remembered.isEmpty { Divider() VStack(alignment: .leading, spacing: 8) { - Text("Saved preview entries (automatic replay is disabled)") + Text("Legacy remembered entries (automatic replay is disabled)") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(p.text3) ForEach(remembered) { attachment in @@ -142,7 +145,7 @@ struct UsbDevicesView: View { .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border)) } .task { - if devicesOutput.isEmpty { await refresh() } + if hostDevices.isEmpty { await refresh() } } } @@ -151,69 +154,104 @@ struct UsbDevicesView: View { .padding(.bottom, -10) } + private func hostDeviceButton(_ device: DorydHostUSBDevice) -> some View { + let subtitle = String( + format: "%@ · %04x:%04x", + device.busID, + device.vendorID, + device.productID + ) + return Button { + busid = device.busID + } label: { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text(device.displayName) + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(p.text) + Text(subtitle) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(p.text3) + } + Spacer(minLength: 0) + if busid == device.busID { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.accentColor) + } + } + .contentShape(Rectangle()) + .padding(.horizontal, 12) + .padding(.vertical, 9) + } + .buttonStyle(.plain) + .accessibilityIdentifier("usb-device-\(device.busID)") + } + @MainActor private func refresh() async { busy = true defer { busy = false } - let result = await Self.runDory(["usb", "ls"]) - devicesOutput = result.output - status = result.succeeded ? "USB devices refreshed." : "USB scan failed: \(result.output)" + async let deviceScan = DorydClient().hostUSBDevices() + async let machineScan = DorydClient().machineList() + var errors: [String] = [] + do { + machines = try await machineScan.sorted { $0.id < $1.id } + if !machines.contains(where: { $0.id == machine }) { + machine = machines.first(where: { + UsbPassthroughAvailability.attachSupported(for: $0) + })?.id ?? machines.first?.id ?? machine + } + } catch { + machines = [] + errors.append("Machine status failed: \(error)") + } + do { + hostDevices = try await deviceScan + if busid.isEmpty, let first = hostDevices.first { + busid = first.busID + } + } catch { + hostDevices = [] + errors.append("USB scan failed: \(error)") + } + status = errors.isEmpty ? "USB devices refreshed." : errors.joined(separator: " ") } @MainActor private func attach() async { - guard UsbPassthroughAvailability.attachSupported else { - status = UsbPassthroughAvailability.unavailableReason + guard attachmentIsAvailable else { + status = availabilityMessage return } busy = true defer { busy = false } - let args = usbCommand("attach") - let result = await Self.runDory(args) - if result.succeeded { - rememberIfNeeded() - status = "Attached \(cleanBusID())." - } else { - status = "Attach failed: \(result.output)" + do { + let attachment = try await DorydClient().machineUSBAttach( + cleanMachine(), + busID: cleanBusID() + ) + UserDefaults.standard.set(cleanMachine(), forKey: "dev.dory.usb.lastMachine") + status = "Attached \(attachment.busID) on guest port \(attachment.port)." + } catch { + status = "Attach failed: \(error)" } } @MainActor private func detach() async { - guard UsbPassthroughAvailability.attachSupported else { - status = UsbPassthroughAvailability.unavailableReason + guard attachmentIsAvailable else { + status = availabilityMessage return } busy = true defer { busy = false } - let args = usbCommand("detach") - let result = await Self.runDory(args) - if result.succeeded { + do { + try await DorydClient().machineUSBDetach(cleanMachine(), busID: cleanBusID()) try? UsbAttachmentStore().forget(machine: cleanMachine(), busID: cleanBusID()) reloadRemembered() status = "Detached \(cleanBusID())." - } else { - status = "Detach failed: \(result.output)" - } - } - - @MainActor private func rememberIfNeeded() { - guard rememberAttachment else { return } - guard let port = validRememberPort() else { - status = "Attached \(cleanBusID()), but not remembered: enter a valid port (1-65535) to replay it automatically." - return - } - do { - UserDefaults.standard.set(cleanMachine(), forKey: "dev.dory.usb.lastMachine") - _ = try UsbAttachmentStore().remember(machine: cleanMachine(), busID: cleanBusID(), port: port) - reloadRemembered() } catch { - status = "Attached, but could not remember it: \(error)" + status = "Detach failed: \(error)" } } - private func validRememberPort() -> Int? { - guard let port = Int(cleanPort()), (1...65_535).contains(port) else { return nil } - return port - } - @MainActor private func forget(_ attachment: UsbAttachment) { try? UsbAttachmentStore().forget(machine: attachment.machine, busID: attachment.busID) reloadRemembered() @@ -225,51 +263,17 @@ struct UsbDevicesView: View { private func cleanMachine() -> String { machine.trimmingCharacters(in: .whitespacesAndNewlines) } private func cleanBusID() -> String { busid.trimmingCharacters(in: .whitespacesAndNewlines) } - private func cleanPort() -> String { port.trimmingCharacters(in: .whitespacesAndNewlines) } - private func usbCommand(_ action: String) -> [String] { - var args = ["usb", action, cleanBusID()] - if !cleanPort().isEmpty { - args += ["--port", cleanPort()] - } - if !cleanMachine().isEmpty { - args += ["--machine", cleanMachine()] - } - return args + private var selectedMachine: DorydMachineStatus? { + machines.first { $0.id == cleanMachine() } } - nonisolated static func runDory(_ arguments: [String]) async -> CommandResult { - await Task.detached(priority: .userInitiated) { - let process = Process() - process.executableURL = doryCLIURL() - process.arguments = arguments - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - do { - try process.run() - process.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return CommandResult(succeeded: process.terminationStatus == 0, output: output) - } catch { - return CommandResult(succeeded: false, output: error.localizedDescription) - } - }.value + private var attachmentIsAvailable: Bool { + UsbPassthroughAvailability.attachSupported(for: selectedMachine) } - nonisolated private static func doryCLIURL() -> URL { - if let override = ProcessInfo.processInfo.environment["DORY_CLI"], !override.isEmpty { - return URL(fileURLWithPath: override) - } - if FileManager.default.isExecutableFile(atPath: "/usr/local/bin/dory") { - return URL(fileURLWithPath: "/usr/local/bin/dory") - } - return URL(fileURLWithPath: "/opt/homebrew/bin/dory") + private var availabilityMessage: String { + UsbPassthroughAvailability.unavailableReason(for: selectedMachine) } - struct CommandResult: Sendable, Equatable { - let succeeded: Bool - let output: String - } } diff --git a/Dory/Features/Sheets/MachineCreationSheet.swift b/Dory/Features/Sheets/MachineCreationSheet.swift index 9e4614c0..e8489fbe 100644 --- a/Dory/Features/Sheets/MachineCreationSheet.swift +++ b/Dory/Features/Sheets/MachineCreationSheet.swift @@ -14,7 +14,7 @@ struct MachineCreationSheet: View { statusIcon VStack(alignment: .leading, spacing: 1) { Text(store.machineCreationTitle).font(.system(size: 15, weight: .bold)).foregroundStyle(p.text) - Text(failed ? "Creation failed" : (succeeded ? "Ready" : "Setting up your Linux machine…")) + Text(statusSubtitle) .font(.system(size: 11.5)).foregroundStyle(failed ? p.red : (succeeded ? p.green : p.text3)) } Spacer() @@ -78,14 +78,27 @@ struct MachineCreationSheet: View { .background(p.bgInput, in: RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(p.border)) }.buttonStyle(.plain) - Button { - openWindow(value: store.terminalSession(for: machine)) - dismissSuccess() - } label: { - Text("Open Terminal").font(.system(size: 13, weight: .semibold)).foregroundStyle(.white) - .padding(.horizontal, 18).padding(.vertical, 8) - .background(p.accent, in: RoundedRectangle(cornerRadius: 8)) - }.buttonStyle(.plain) + if machine.bootMode == .efi, machine.displayMode == .desktop { + Button { + store.openMachineDesktop(machine) + dismissSuccess() + } label: { + Text("Open Desktop").font(.system(size: 13, weight: .semibold)).foregroundStyle(.white) + .padding(.horizontal, 18).padding(.vertical, 8) + .background(p.accent, in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .disabled(!store.canOpenMachineDesktop(machine)) + } else if store.canOpenMachineTerminal(machine) { + Button { + openWindow(value: store.terminalSession(for: machine)) + dismissSuccess() + } label: { + Text("Open Terminal").font(.system(size: 13, weight: .semibold)).foregroundStyle(.white) + .padding(.horizontal, 18).padding(.vertical, 8) + .background(p.accent, in: RoundedRectangle(cornerRadius: 8)) + }.buttonStyle(.plain) + } } } } @@ -99,6 +112,15 @@ struct MachineCreationSheet: View { store.machineCreated = nil } + private var statusSubtitle: String { + if failed { return "Creation failed" } + guard succeeded else { return "Setting up your Linux machine…" } + if store.machineCreated?.bootMode == .efi { + return "VM and display started" + } + return "Ready" + } + @ViewBuilder private var statusIcon: some View { if failed { Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 17)).foregroundStyle(p.red) diff --git a/Dory/Features/Sheets/NewMachineSheet.swift b/Dory/Features/Sheets/NewMachineSheet.swift index 04edc9ef..7b8c0814 100644 --- a/Dory/Features/Sheets/NewMachineSheet.swift +++ b/Dory/Features/Sheets/NewMachineSheet.swift @@ -1,6 +1,8 @@ import Darwin import DoryOperations import SwiftUI +import UniformTypeIdentifiers +import Virtualization struct NewMachineSheet: View { @Environment(AppStore.self) private var store @@ -12,6 +14,31 @@ struct NewMachineSheet: View { @State private var displayMode: MachineDisplayMode @State private var desktopDistro: DesktopMachineDistro = .debian @State private var guestUsername = NewMachineSheet.defaultGuestUsername() + @State private var customISOInstall = false + @State private var installerISOPath = "" + @State private var installerISOCheck: InstallerISOCheck = .none + @State private var diskSizeGB = 64 + @State private var networkMode = DoryVMNetworkMode.sharedNAT + @State private var portForwardRows: [MachinePortForwardDraft] = [] + @State private var audioInputEnabled = true + @State private var audioOutputEnabled = true + @State private var cameraEnabled = true + @State private var gpuAccelerationEnabled = true + @State private var hostDisplays: [HostDisplayChoice] = [] + @State private var dedicatedHostDisplayUUID: String? + + private enum InstallerISOCheck: Equatable { + case none + case checking + case compatible( + DoryInstallerISOArchitecture, + DoryInstallerISORuntimeQualification + ) + case unknown(DoryInstallerISOMediaIdentity) + case unstable(String) + case incompatible(String) + case failed(String) + } enum Stage: Hashable { case useCase, form } @State private var stage: Stage @@ -30,9 +57,14 @@ struct NewMachineSheet: View { } init(displayMode: MachineDisplayMode) { + let resources = Self.recommendedDesktopResources() _displayMode = State(initialValue: displayMode) _stage = State(initialValue: displayMode == .desktop ? .form : .useCase) _name = State(initialValue: NewMachineSheet.defaultName()) + if displayMode == .desktop { + _cpus = State(initialValue: resources.cpus) + _memoryGB = State(initialValue: resources.memoryGB) + } if let installedDistro = DesktopMachineDistro.allCases.first(where: { AppInfo.componentAvailable($0.componentID) }) { @@ -52,6 +84,7 @@ struct NewMachineSheet: View { } .frame(width: 600, height: 600) .background(p.bgWindow) + .onAppear { hostDisplays = HostDisplayChoice.connectedDisplays() } } private var formScreen: some View { @@ -65,6 +98,15 @@ struct NewMachineSheet: View { machineKindSection devEnvironmentSection identitySection + networkBlock + MachinePortForwardEditor( + rows: $portForwardRows, + networkMode: networkMode, + accessibilityPrefix: "new-machine" + ) + desktopGraphicsBlock + audioBlock + displayAssignmentBlock optionsRow advancedSection } @@ -173,6 +215,13 @@ struct NewMachineSheet: View { cpus = useCase.cpus memoryGB = useCase.memoryGB activeUseCaseID = useCase.id + portForwardRows = useCase.recipe?.ports.map { + MachinePortForwardDraft( + name: "port-\($0)", + hostPort: String($0), + guestPort: String($0) + ) + } ?? [] stage = .form } @@ -206,7 +255,9 @@ struct NewMachineSheet: View { return "\(useCase.title) — tweak anything below" } if displayMode == .desktop { - return "\(desktopDistro.displayName) \(desktopDistro.version) · \(desktopDistro.desktopName) · Apple Silicon" + return customISOInstall + ? "Install an arm64 Linux distribution from ISO · Apple EFI" + : "\(desktopDistro.displayName) \(desktopDistro.version) · \(desktopDistro.desktopName) · Apple Silicon" } return "Headless Linux · native Apple Silicon" } @@ -232,15 +283,123 @@ struct NewMachineSheet: View { private var desktopDistroSection: some View { VStack(alignment: .leading, spacing: 9) { - sectionLabel("DESKTOP DISTRIBUTION") - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 9), count: 3), spacing: 9) { - ForEach(installedDesktopDistros) { distro in - desktopDistroButton(distro) + sectionLabel("INSTALLATION SOURCE") + Picker("", selection: $customISOInstall) { + Text("Dory desktop").tag(false) + Text("Custom ISO").tag(true) + } + .labelsHidden() + .pickerStyle(.segmented) + .onChange(of: customISOInstall) { _, custom in + if custom { + selectedRecipe = nil + cpus = DoryInstallerMachinePolicy.defaultCPUCount + } + } + + if customISOInstall { + customISOSection + } else { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 9), count: 3), spacing: 9) { + ForEach(installedDesktopDistros) { distro in + desktopDistroButton(distro) + } } + Text("Only installed distributions are shown. Add or remove Debian, Ubuntu, and Kali independently in Components.") + .font(.system(size: 11)).foregroundStyle(p.text3) } - Text("Only installed distributions are shown. Add or remove Debian, Ubuntu, and Kali independently in Components.") + } + } + + private var customISOSection: some View { + VStack(alignment: .leading, spacing: 10) { + Button(action: chooseInstallerISO) { + HStack(spacing: 8) { + Image(systemName: "opticaldiscdrive").foregroundStyle(p.accent) + Text(installerISOPath.isEmpty ? "Choose Linux installer ISO…" : installerISOPath) + .font(.mono(11.5)).foregroundStyle(installerISOPath.isEmpty ? p.text3 : p.text) + .lineLimit(1).truncationMode(.head) + Spacer(minLength: 0) + Text("Choose").font(.system(size: 11, weight: .semibold)).foregroundStyle(p.accent) + } + .padding(11) + .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(p.border)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("custom-linux-iso-picker") + + installerISOStatus + + HStack(spacing: 16) { + sectionLabel("VIRTUAL DISK") + boundedResourceControl( + value: $diskSizeGB, + range: 16...512, + display: { "\($0) GB" }, + valueIdentifier: "custom-linux-disk-size", + decrementIdentifier: "custom-linux-disk-decrement", + incrementIdentifier: "custom-linux-disk-increment" + ) + } + Text("Dory stores a private copy of the ISO, a thin-provisioned disk, a stable VM identity, and persistent EFI NVRAM. Choose an arm64 ISO on Apple Silicon.") .font(.system(size: 11)).foregroundStyle(p.text3) + Label( + "Dory starts installers with a balanced 4-vCPU default. EFI architecture and exact-media runtime qualification are checked separately.", + systemImage: "cpu" + ) + .font(.system(size: 11)).foregroundStyle(p.text3) + } + } + + @ViewBuilder private var installerISOStatus: some View { + switch installerISOCheck { + case .none: + EmptyView() + case .checking: + HStack(spacing: 7) { + ProgressView().controlSize(.mini) + Text("Checking EFI architecture before import…") + } + .font(.system(size: 11, weight: .medium)).foregroundStyle(p.text2) + case let .compatible(architecture, qualification): + switch qualification { + case let .qualified(message): + isoStatusRow(icon: "checkmark.circle.fill", color: p.green, text: message) + case .unqualified: + isoStatusRow( + icon: "questionmark.circle.fill", + color: p.amber, + text: architecture == .multiArchitecture + ? "Universal EFI architecture confirmed — this exact media is not yet runtime-qualified by Dory." + : "ARM64 EFI architecture confirmed — this exact media is not yet runtime-qualified by Dory." + ) + case let .knownUnstable(message): + isoStatusRow(icon: "exclamationmark.octagon.fill", color: p.red, text: message) + } + case .unknown: + isoStatusRow( + icon: "xmark.octagon.fill", + color: p.red, + text: "Dory could not prove a portable ARM64 EFI loader in this ISO. Choose different media." + ) + case let .unstable(message): + isoStatusRow(icon: "exclamationmark.octagon.fill", color: p.red, text: message) + case let .incompatible(message): + isoStatusRow(icon: "xmark.octagon.fill", color: p.red, text: message) + case let .failed(message): + isoStatusRow(icon: "exclamationmark.triangle.fill", color: p.red, text: message) + } + } + + private func isoStatusRow(icon: String, color: Color, text: String) -> some View { + HStack(alignment: .top, spacing: 7) { + Image(systemName: icon).font(.system(size: 11, weight: .semibold)).foregroundStyle(color) + Text(text).font(.system(size: 11, weight: .medium)).foregroundStyle(p.text2) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) } + .accessibilityIdentifier("custom-linux-iso-compatibility") } private var installedDesktopDistros: [DesktopMachineDistro] { @@ -284,7 +443,7 @@ struct NewMachineSheet: View { .background(p.accentSoft, in: RoundedRectangle(cornerRadius: 9)) VStack(alignment: .leading, spacing: 3) { Text("Dory Linux Server").font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text) - Text("Lightweight headless Linux for terminals, tools, and local services") + Text("User-managed headless VM for terminals, tools, and local services — Agent Sandboxes are created from the Dory CLI or MCP") .font(.system(size: 10.5)).foregroundStyle(p.text3) } Spacer(minLength: 0) @@ -298,6 +457,10 @@ struct NewMachineSheet: View { private var devEnvironmentSection: some View { VStack(alignment: .leading, spacing: 9) { sectionLabel("DEV ENVIRONMENT") + if customISOInstall { + Text("Choose packages and applications inside the Linux installer.") + .font(.system(size: 11.5)).foregroundStyle(p.text2) + } else { Picker("", selection: Binding( get: { selectedRecipe?.id ?? "" }, set: { selectedRecipe = $0.isEmpty ? nil : DevRecipe.forID($0) } @@ -310,6 +473,7 @@ struct NewMachineSheet: View { ? "Recipes install verified apt packages after the desktop starts." : "Recipes install verified Alpine packages after the VM starts.") .font(.system(size: 11)).foregroundStyle(p.text3) + } } } @@ -319,7 +483,7 @@ struct NewMachineSheet: View { HStack(spacing: 9) { Image(systemName: "person.crop.circle.badge.checkmark") .font(.system(size: 14)).foregroundStyle(p.accent) - if displayMode == .desktop { + if displayMode == .desktop, !customISOInstall { Text("Linux user").font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text) Spacer(minLength: 0) TextField("dory", text: $guestUsername) @@ -331,16 +495,23 @@ struct NewMachineSheet: View { .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(guestUsernameInvalid ? p.red : p.border)) .accessibilityIdentifier("new-machine-guest-user") } else { - Text("Administrator shell").font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text) + Text(customISOInstall ? "Linux account" : "Administrator shell") + .font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text) Spacer(minLength: 0) - Text("root · /bin/sh").font(.mono(11.5)).foregroundStyle(p.text3) + Text(customISOInstall ? "Created in the installer" : "root · /bin/sh") + .font(.mono(11.5)).foregroundStyle(p.text3) } } if guestUsernameInvalid { Text("Use 1–32 lowercase letters, numbers, underscores or dashes; start with a letter or underscore.") .font(.system(size: 11)).foregroundStyle(p.red) } - Toggle("Share my Mac home (read-write)", isOn: $shareHome) + Toggle( + customISOInstall + ? "Expose my Mac home to this VM (read-write)" + : "Share my Mac home (read-write)", + isOn: $shareHome + ) .toggleStyle(.switch).tint(p.accent) .font(.system(size: 12.5)).foregroundStyle(p.text) Text(shareHome ? sharedHomeDescription : "No Mac home folder is shared unless you turn this on or add scoped mounts.") @@ -373,6 +544,94 @@ struct NewMachineSheet: View { } } + private var networkBlock: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("NETWORK") + Picker("Network", selection: $networkMode) { + Text("Shared NAT").tag(DoryVMNetworkMode.sharedNAT) + Text("Host-only").tag(DoryVMNetworkMode.isolated) + Text("Disconnected").tag(DoryVMNetworkMode.disconnected) + } + .labelsHidden() + .pickerStyle(.segmented) + .accessibilityIdentifier("new-machine-network-mode") + Text(networkMode == .disconnected + ? "Disconnected keeps the virtual adapter present with its link down." + : networkMode == .isolated + ? "Host-only allows private Mac-to-machine connectivity with no external route." + : "Shared NAT provides outbound access through your Mac without exposing the machine directly.") + .font(.system(size: 11)).foregroundStyle(p.text3) + } + } + + @ViewBuilder private var audioBlock: some View { + if displayMode == .desktop { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("AUDIO & VIDEO") + HStack(spacing: 24) { + Toggle("Microphone", isOn: $audioInputEnabled) + .toggleStyle(.switch) + .tint(p.accent) + .accessibilityIdentifier("new-machine-audio-input") + Toggle("Speakers", isOn: $audioOutputEnabled) + .toggleStyle(.switch) + .tint(p.accent) + .accessibilityIdentifier("new-machine-audio-output") + Toggle("Camera", isOn: $cameraEnabled) + .toggleStyle(.switch) + .tint(p.accent) + .accessibilityIdentifier("new-machine-camera") + .disabled(customISOInstall) + Spacer(minLength: 0) + } + .font(.system(size: 12.5)) + .foregroundStyle(p.text) + Text(customISOInstall + ? "Speakers and microphone use standard VirtIO audio. Camera sharing is currently available on Dory-managed accelerated desktops, not custom ISO compatibility guests." + : "Enabled devices are attached explicitly. Camera sharing appears in Linux as a standard UVC webcam and follows macOS camera permission.") + .font(.system(size: 11)) + .foregroundStyle(p.text3) + } + } + } + + @ViewBuilder private var desktopGraphicsBlock: some View { + if displayMode == .desktop, !customISOInstall { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("GRAPHICS") + Toggle("GPU acceleration", isOn: $gpuAccelerationEnabled) + .toggleStyle(.switch) + .tint(p.accent) + .accessibilityIdentifier("new-machine-gpu-acceleration") + Text(gpuAccelerationEnabled + ? "Require Dory's Metal-backed VirGL + Venus runtime for accelerated OpenGL and Vulkan. Creation fails instead of silently falling back to software graphics." + : "Use Apple's compatibility display with software 3D. You can enable GPU acceleration later from the desktop's settings.") + .font(.system(size: 11)) + .foregroundStyle(p.text3) + } + } + } + + @ViewBuilder private var displayAssignmentBlock: some View { + if displayMode == .desktop { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("MAC DISPLAY") + Picker("Guest presentation", selection: $dedicatedHostDisplayUUID) { + Text("Windowed").tag(String?.none) + ForEach(hostDisplays) { display in + Text("Dedicated — \(display.name)").tag(Optional(display.id)) + } + } + .accessibilityIdentifier("new-machine-host-display") + Text(dedicatedHostDisplayUUID == nil + ? "Open the Linux desktop as a normal Mac window." + : "Give the guest a native full-screen Space on this monitor. Command-Control-F returns to a window.") + .font(.system(size: 11)) + .foregroundStyle(p.text3) + } + } + } + private var advancedSection: some View { VStack(alignment: .leading, spacing: 0) { Button { @@ -572,7 +831,9 @@ struct NewMachineSheet: View { Image(systemName: "terminal") .font(.system(size: 11)).foregroundStyle(p.text3) Text(displayMode == .desktop - ? "\(desktopDistro.displayName) \(desktopDistro.version) · arm64 · \(normalizedGuestUsername)" + ? (customISOInstall + ? customISOFooterDescription + : "\(desktopDistro.displayName) \(desktopDistro.version) · arm64 · \(normalizedGuestUsername)") : "Dory Linux · arm64 · root shell") .font(.mono(11.5)).foregroundStyle(p.text3).lineLimit(1) } @@ -611,10 +872,13 @@ struct NewMachineSheet: View { private var createDisabled: Bool { name.trimmingCharacters(in: .whitespaces).isEmpty || !nameValid - || guestUsernameInvalid + || (!customISOInstall && guestUsernameInvalid) + || (customISOInstall && installerISOPath.isEmpty) + || (customISOInstall && installerISOCheckBlocksCreate) || store.machineBusy || !engineReady || mountsOutsideHome + || resolvedPortForwards == nil } private var mountsOutsideHome: Bool { @@ -634,17 +898,97 @@ struct NewMachineSheet: View { private func create() { var settings = collectedSettings() - let sharedHomeGuestPath = displayMode == .desktop - ? "/home/\(normalizedGuestUsername)/Mac" - : NSHomeDirectory() - if shareHome, !settings.mounts.contains(where: { $0.guest == sharedHomeGuestPath }) { - settings.mounts.append(MountPair(host: NSHomeDirectory(), guest: sharedHomeGuestPath)) + let homeMount = Self.sharedHomeMount( + home: NSHomeDirectory(), + displayMode: displayMode, + customISOInstall: customISOInstall, + guestUsername: normalizedGuestUsername + ) + if shareHome, !settings.mounts.contains(where: { $0.guest == homeMount.guest }) { + settings.mounts.append(homeMount) } let machineName = name - let recipe = selectedRecipe + let recipe = customISOInstall ? nil : selectedRecipe Task { _ = await store.createMachine(name: machineName, recipe: recipe, settings: settings) } } + private func chooseInstallerISO() { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + if let isoType = UTType(filenameExtension: "iso") { + panel.allowedContentTypes = [isoType] + } + panel.message = "Choose an arm64 Linux installer ISO" + guard panel.runModal() == .OK, let url = panel.url else { return } + installerISOPath = url.path + installerISOCheck = .checking + let selectedPath = url.path + Task { + let result: (DoryInstallerISOMediaIdentity?, String?) = await Task.detached( + priority: .userInitiated + ) { + let hasSecurityScope = url.startAccessingSecurityScopedResource() + defer { + if hasSecurityScope { url.stopAccessingSecurityScopedResource() } + } + do { + return ( + try DoryInstallerISOInspector.portableEFIMediaIdentity( + atPath: selectedPath + ), + nil + ) + } catch { + return (nil, error.localizedDescription) + } + }.value + guard installerISOPath == selectedPath else { return } + guard let identity = result.0 else { + installerISOCheck = .failed(result.1 ?? "Dory could not inspect this ISO.") + return + } + switch DoryInstallerISOInspector.compatibility( + of: identity.architecture, + hostArchitecture: DoryInstallerISOInspector.currentHostArchitecture + ) { + case .compatible: + let qualification = DoryInstallerISORuntimeCatalog.qualification(of: identity) + if case let .knownUnstable(message) = qualification { + installerISOCheck = .unstable(message) + } else { + installerISOCheck = .compatible(identity.architecture, qualification) + } + case .unknown: + installerISOCheck = .unknown(identity) + case let .incompatible(message): + installerISOCheck = .incompatible(message) + } + } + } + + private var installerISOCheckBlocksCreate: Bool { + switch installerISOCheck { + case .compatible: + false + case .none, .checking, .unknown, .unstable, .incompatible, .failed: + true + } + } + + private var customISOFooterDescription: String { + switch installerISOCheck { + case .compatible(.multiArchitecture, _): "Custom universal Linux · EFI · \(diskSizeGB) GB" + case .compatible(.arm64, _): "Custom arm64 Linux · EFI · \(diskSizeGB) GB" + case .compatible(.x86_64, _): "Custom x86_64 Linux · EFI · \(diskSizeGB) GB" + case .compatible(.unknown, _), .unknown: "Custom Linux · EFI architecture unknown · \(diskSizeGB) GB" + case .incompatible: "Intel x86_64 ISO · incompatible" + case .unstable: "Known-unstable installer · choose different media" + case .none, .checking, .failed: "Custom Linux · EFI · \(diskSizeGB) GB" + } + } + static func buildSettings( cpus: Int, memoryGB: Int, @@ -653,27 +997,78 @@ struct NewMachineSheet: View { displayMode: MachineDisplayMode = .desktop, desktopDistro: DesktopMachineDistro = .debian, guestUsername: String = "dory", - guestUID: uid_t = getuid() + guestUID: uid_t = getuid(), + networkMode: DoryVMNetworkMode = .sharedNAT, + portForwards: [DoryVMPortForward] = [], + audioInputEnabled: Bool = true, + audioOutputEnabled: Bool = true, + cameraEnabled: Bool = true, + gpuAccelerationEnabled: Bool = true ) -> MachineSettings { - var environment: [String: String] = [:] + let typedSettings: DorydMachineTypedSettings if displayMode == .desktop { - environment["DORY_GUEST_USER"] = guestUsername - environment["DORY_GUEST_UID"] = String(guestUID) - environment["DORY_DESKTOP_DISTRO"] = desktopDistro.rawValue - environment["DORY_DESKTOP_NAME"] = desktopDistro.displayName - environment["DORY_DESKTOP_VERSION"] = desktopDistro.version - environment["DORY_DESKTOP_ENVIRONMENT"] = desktopDistro.desktopName + typedSettings = DorydMachineTypedSettings( + guestIdentityIntent: DoryVMGuestIdentityIntent( + account: DoryVMGuestAccountIntent( + username: guestUsername, + numericUserID: UInt32(guestUID) + ), + desktop: DoryVMDesktopIdentityIntent( + distributionIdentifier: desktopDistro.rawValue, + displayName: desktopDistro.displayName, + version: desktopDistro.version, + desktopEnvironment: desktopDistro.desktopName + ) + ), + // Text and image clipboard are supported by both the compatibility runtime and + // resolved workspace plans. File transfer uses Dory's separately authorized + // machine transfer channel; advertising it as clipboard intent makes a clean + // install unrepresentable before a schema-v2 qualification catalog is active. + clipboardPolicy: .legacyDesktop(.bidirectional), + runtimePreference: gpuAccelerationEnabled ? .accelerated : .compatible, + graphicsPreference: gpuAccelerationEnabled ? .virglVenus : .software, + networkMode: networkMode, + portForwards: portForwards, + audioConfiguration: DoryVMAudioConfiguration( + inputEnabled: audioInputEnabled, + outputEnabled: audioOutputEnabled + ), + cameraConfiguration: DoryVMCameraConfiguration(enabled: cameraEnabled) + ) + } else { + typedSettings = DorydMachineTypedSettings( + networkMode: networkMode, + portForwards: portForwards + ) } return MachineSettings( cpus: cpus, memoryMB: memoryGB * 1024, mounts: mounts, - env: environment, + env: [:], + virtualMachineSettings: typedSettings, address: address, displayMode: displayMode ) } + static func recommendedDesktopResources( + activeProcessorCount: Int = ProcessInfo.processInfo.activeProcessorCount, + physicalMemory: UInt64 = ProcessInfo.processInfo.physicalMemory + ) -> (cpus: Int, memoryGB: Int) { + let cpus = max(4, min(8, activeProcessorCount / 2)) + let hostMemoryGB = Int(physicalMemory / 1_073_741_824) + let memoryGB: Int + if hostMemoryGB >= 24 { + memoryGB = 8 + } else if hostMemoryGB >= 16 { + memoryGB = 6 + } else { + memoryGB = 4 + } + return (cpus, memoryGB) + } + private func collectedSettings() -> MachineSettings { let mounts = mountRows.compactMap { row -> MountPair? in let host = row.host.trimmingCharacters(in: .whitespaces) @@ -681,21 +1076,77 @@ struct NewMachineSheet: View { guard !host.isEmpty, !guest.isEmpty else { return nil } return MountPair(host: host, guest: guest) } - return Self.buildSettings( + var settings = Self.buildSettings( cpus: cpus, memoryGB: memoryGB, mounts: mounts, address: trimmedAddress, displayMode: displayMode, desktopDistro: desktopDistro, - guestUsername: normalizedGuestUsername + guestUsername: normalizedGuestUsername, + networkMode: networkMode, + portForwards: resolvedPortForwards ?? [], + audioInputEnabled: audioInputEnabled, + audioOutputEnabled: audioOutputEnabled, + cameraEnabled: cameraEnabled && !customISOInstall, + gpuAccelerationEnabled: gpuAccelerationEnabled + ) + if customISOInstall { + settings.bootMode = .efi + settings.installerISOPath = installerISOPath + settings.diskSizeGB = diskSizeGB + // EFI boot/media authority is explicit. It must never be inferred from a reserved + // environment marker or carry managed-desktop provisioning intent. + settings.env = [:] + settings.virtualMachineSettings = DorydMachineTypedSettings( + networkMode: networkMode, + portForwards: resolvedPortForwards ?? [], + audioConfiguration: DoryVMAudioConfiguration( + inputEnabled: audioInputEnabled, + outputEnabled: audioOutputEnabled + ), + cameraConfiguration: DoryVMCameraConfiguration(enabled: false) + ) + } + settings.displayPresentation = DoryMachineDisplayPresentation( + assignments: dedicatedHostDisplayUUID.map { + [DoryGuestDisplayPresentationAssignment( + guestDisplayID: "display-0", + mode: .dedicatedFullscreen, + hostDisplayUUID: $0 + )] + } ?? [] ) + return settings + } + + private var resolvedPortForwards: [DoryVMPortForward]? { + MachinePortForwardDraft.resolved(portForwardRows, networkMode: networkMode) } static func defaultName() -> String { "dory-\(AppStore.generatedMachineToken())" } + static func sharedHomeMount( + home: String, + displayMode: MachineDisplayMode, + customISOInstall: Bool, + guestUsername: String + ) -> MountPair { + if displayMode == .desktop, customISOInstall { + return MountPair( + host: home, + guest: "/mnt/dory-mac-home", + shareTag: "mac-home" + ) + } + return MountPair( + host: home, + guest: displayMode == .desktop ? "/home/\(guestUsername)/Mac" : home + ) + } + static func defaultGuestUsername() -> String { let normalized = NSUserName().lowercased().map { character -> Character in character.isLetter || character.isNumber || character == "_" || character == "-" ? character : "-" @@ -721,8 +1172,11 @@ struct NewMachineSheet: View { } private var sharedHomeDescription: String { - displayMode == .desktop - ? "Your Mac home is available in the desktop at ~/Mac with your Mac user ID." + if displayMode == .desktop, customISOInstall { + return "Dory exposes the virtiofs tag mac-home. After installation, mount it where you want (for example /mnt/dory-mac-home); an arbitrary distro is not configured automatically." + } + return displayMode == .desktop + ? "Your Mac home is available in the Dory-managed desktop at ~/Mac with your Mac user ID." : "Your Mac home is mounted at its native path inside the machine." } @@ -736,3 +1190,123 @@ struct NewMachineSheet: View { return trimmed.isEmpty ? nil : trimmed } } + +enum IntelApplicationTranslationHostAvailability: Sendable, Equatable { + case installed + case notInstalled + case unsupported +} + +@MainActor +enum IntelApplicationTranslationHost { + static var availability: IntelApplicationTranslationHostAvailability { + switch VZLinuxRosettaDirectoryShare.availability { + case .installed: + .installed + case .notInstalled: + .notInstalled + case .notSupported: + .unsupported + @unknown default: + .unsupported + } + } + + static func install() async throws { + try await VZLinuxRosettaDirectoryShare.installRosetta() + } +} + +struct MachineIntelApplicationTranslationControl: View { + @Environment(\.palette) private var p + @Binding var isEnabled: Bool + let editable: Bool + let runtimeCompatible: Bool + let accessibilityPrefix: String + + @State private var availability = IntelApplicationTranslationHost.availability + @State private var installing = false + @State private var installationError: String? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("INTEL APPLICATIONS") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(p.text3) + .tracking(0.5) + HStack(spacing: 12) { + Toggle("Run Intel Linux applications", isOn: $isEnabled) + .toggleStyle(.switch) + .tint(p.accent) + .font(.system(size: 12.5)) + .foregroundStyle(p.text) + .disabled(!editable || (availability != .installed && !isEnabled)) + .accessibilityIdentifier( + "\(accessibilityPrefix)-intel-application-translation" + ) + Spacer(minLength: 0) + if availability == .notInstalled { + Button { + Task { await installRosetta() } + } label: { + HStack(spacing: 6) { + if installing { ProgressView().controlSize(.mini) } + Text(installing ? "Installing…" : "Install Rosetta") + .font(.system(size: 11.5, weight: .semibold)) + } + } + .buttonStyle(.bordered) + .disabled(installing || !editable) + .accessibilityIdentifier( + "\(accessibilityPrefix)-install-intel-application-translation" + ) + } + } + Text(statusMessage) + .font(.system(size: 11)) + .foregroundStyle(statusColor) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var statusMessage: String { + if let installationError { + return "Rosetta could not be installed: \(installationError)" + } + guard runtimeCompatible else { + return "Intel application translation requires Automatic or Compatibility runtime selection." + } + guard editable else { + return "Replan this compatibility machine into the resolved runtime before changing Intel application translation." + } + switch availability { + case .installed: + return "Uses Apple's Rosetta runtime inside this ARM64 Linux desktop. The runtime is attached only when the resolved plan qualifies it." + case .notInstalled: + return "Rosetta is not installed on this Mac. Installation is an explicit Apple system action." + case .unsupported: + return "This Mac does not support Rosetta for Linux virtual machines." + } + } + + private var statusColor: Color { + installationError == nil && availability == .installed && runtimeCompatible + ? p.text3 : p.amber + } + + private func installRosetta() async { + installationError = nil + installing = true + defer { installing = false } + do { + try await IntelApplicationTranslationHost.install() + availability = IntelApplicationTranslationHost.availability + if availability != .installed { + installationError = "macOS did not report the runtime as installed." + } + } catch { + installationError = error.localizedDescription + availability = IntelApplicationTranslationHost.availability + } + } +} diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index 5da45c84..df2a1be3 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -16,6 +16,47 @@ struct SettingsNotice: Identifiable, Equatable, Sendable { var message: String } +private enum DoryMachineFileTransferUIError: LocalizedError { + case authorityChanged + case invalidCompletion + case remoteFailure(String) + + var errorDescription: String? { + switch self { + case .authorityChanged: + "The file transfer identity changed unexpectedly." + case .invalidCompletion: + "The guest returned incomplete file transfer evidence." + case .remoteFailure(let message): + message + } + } +} + +private enum DorydBackendReadinessError: LocalizedError, CustomStringConvertible { + case engineUnavailable(state: String, detail: String) + case dockerTimedOut(socketPath: String, detail: String) + + var errorDescription: String? { + switch self { + case let .engineUnavailable(state, detail): + if detail.isEmpty { + "Dory's engine became \(state) before Docker was ready." + } else { + "Dory's engine became \(state): \(detail)" + } + case let .dockerTimedOut(socketPath, detail): + if detail.isEmpty { + "Dory's engine is running, but Docker did not answer at \(socketPath)." + } else { + "Dory's engine is running, but Docker did not answer at \(socketPath): \(detail)" + } + } + } + + var description: String { errorDescription ?? "Dory's engine did not become ready." } +} + enum ContainerFilter: String, CaseIterable, Sendable { case running, all, stopped var label: String { @@ -80,7 +121,9 @@ final class AppStore { var showMenuBarIcon = true var routeDockerCLI = true var keepDorydRunningAfterQuit = false - var machineEnvAllowList: [String] = MachineEnvImport.defaultNames + /// Retained as an empty v1 managed-settings compatibility field. Host environment import is + /// disabled: credentials belong in scoped secret grants, never persisted machine environment. + var machineEnvAllowList: [String] = [] var openLoginsOnMac = true var externalTerminalPreference = ExternalTerminalPreference(terminal: .terminal, customApplicationPath: nil) var dockerHostConflict: DockerHostConflict.Conflict? @@ -191,7 +234,6 @@ final class AppStore { @ObservationIgnored private let authorizedNetworkingRemover: @Sendable () async throws -> Void @ObservationIgnored private let localCATrustManager: any LocalCATrustManaging @ObservationIgnored private let environment: [String: String] - @ObservationIgnored private let machineEnvResolver: @Sendable ([String]) async -> [String: String] @ObservationIgnored private let desktopMachineAssetPreparer: @Sendable ( _ home: String, _ environment: [String: String], @@ -199,6 +241,7 @@ final class AppStore { ) async throws -> DesktopMachineAssets @ObservationIgnored private let composeCommandRunner: any ToolCommandRunning @ObservationIgnored private let buildCommandRunner: any ToolCommandRunning + @ObservationIgnored private let userFacingDoryCommandResolver: @MainActor @Sendable () -> String? @ObservationIgnored private let finderStorageLocation = DoryFinderStorageLocation() @ObservationIgnored private var lastFinderStorageRefresh = Date.distantPast @ObservationIgnored private var lastFinderStorageGroups: [DoryStorageInventoryGroup]? @@ -219,8 +262,8 @@ final class AppStore { environment: [String: String] = ProcessInfo.processInfo.environment, composeCommandRunner: any ToolCommandRunning = BoundedToolProcessRunner(), buildCommandRunner: any ToolCommandRunning = BoundedToolProcessRunner(), - machineEnvResolver: @escaping @Sendable ([String]) async -> [String: String] = { names in - await MachineEnvImport.resolve(names: names) + userFacingDoryCommandResolver: @escaping @MainActor @Sendable () -> String? = { + HostTools.userFacingDoryCommand() }, desktopMachineAssetPreparer: @escaping @Sendable ( _ home: String, @@ -241,7 +284,7 @@ final class AppStore { self.environment = env self.composeCommandRunner = composeCommandRunner self.buildCommandRunner = buildCommandRunner - self.machineEnvResolver = machineEnvResolver + self.userFacingDoryCommandResolver = userFacingDoryCommandResolver self.desktopMachineAssetPreparer = desktopMachineAssetPreparer self.dorydClient = dorydClient self.dorydEngineEnabled = dorydFlags.enabled @@ -259,9 +302,10 @@ final class AppStore { } self.localCATrustManager = localCATrustManager let networkHelperMaintenance = DoryAppDelegate.isNetworkHelperMaintenance() - let realLaunch = !networkHelperMaintenance + let realLaunch = !networkHelperMaintenance && !DoryAppDelegate.isTestHost && env["DORY_SECTION"] == nil && env["DORY_APPEARANCE"] == nil - && env["XCTestConfigurationFilePath"] == nil && env["DORY_UI_TEST"] != "1" + && env["XCTestConfigurationFilePath"] == nil + && env["XCTestSessionIdentifier"] == nil && env["DORY_UI_TEST"] != "1" // Every launch starts disconnected (empty, engine-off) until a real engine connects: the app // ships no demo data. Tests inject their own fixture runtime through the parameter. self.runtime = runtime ?? DisconnectedRuntime() @@ -278,9 +322,10 @@ final class AppStore { _ = DoryUpdater.shared if let v = UserDefaults.standard.object(forKey: Self.routeDockerKey) as? Bool { routeDockerCLI = v } keepDorydRunningAfterQuit = Self.resolvedKeepDorydRunningAfterQuit(defaults: .standard) - if let raw = UserDefaults.standard.string(forKey: Self.machineEnvAllowListKey) { - machineEnvAllowList = MachineEnvImport.parse(raw) - } + // Earlier builds stored environment *names* here and copied their values into new VM + // definitions. Clear that opt-in permanently; legacy VM records remain launchable but + // no new machine inherits host credentials. + UserDefaults.standard.removeObject(forKey: Self.machineEnvAllowListKey) if let v = UserDefaults.standard.object(forKey: Self.openLoginsOnMacKey) as? Bool { openLoginsOnMac = v } externalTerminalPreference = ExternalTerminalPreferenceStore.load() if let saved = UserDefaults.standard.string(forKey: Self.kubernetesVersionKey) { @@ -467,7 +512,7 @@ final class AppStore { if MacHostPlatform.current().isAppleSilicon { "Dory can still run on this Mac by proxying a local Docker-compatible engine such as Docker Desktop, Colima, Rancher Desktop, Podman, or OrbStack." } else { - "Dory's built-in Intel engine needs bundled engine assets and Hypervisor.framework support. This install can still proxy a local Docker-compatible engine such as Colima, Docker Desktop, Rancher Desktop, Podman, or OrbStack." + "Dory does not ship a built-in Intel engine. This install can proxy a local Docker-compatible engine such as Colima, Docker Desktop, Rancher Desktop, Podman, or OrbStack." } } @@ -526,6 +571,12 @@ final class AppStore { dorydEngineFlags(environment: environment).enabled } + /// A clean installed app can spend tens of seconds registering and validating its signed + /// LaunchAgent before doryd publishes the Mach service. Keep the app in its truthful starting + /// state for that whole first-launch window instead of converting a still-progressing launch + /// into a sticky engine error. Docker readiness has its own subsequent bounded wait. + nonisolated static let dorydBackendAttachTimeout: TimeInterval = 60 + private nonisolated static func dorydEngineFlags(environment: [String: String]) -> (enabled: Bool, required: Bool, explicit: Bool) { // Dory 0.4 has one production local-engine owner. Keep the positive flags only as an // automation signal that a test explicitly wants to attach to doryd; historical disable @@ -638,7 +689,8 @@ final class AppStore { private var isAutomationContext: Bool { let env = environment - return env["XCTestConfigurationFilePath"] != nil || env["XCTestSessionIdentifier"] != nil + return DoryAppDelegate.isTestHost + || env["XCTestConfigurationFilePath"] != nil || env["XCTestSessionIdentifier"] != nil || env["DORY_UI_TEST"] == "1" || env["DORY_SECTION"] != nil || env["DORY_SHEET"] != nil || env["DORY_DETAIL_TAB"] != nil || env["DORY_APPEARANCE"] != nil || env["DORY_ONBOARDING"] != nil @@ -697,13 +749,10 @@ final class AppStore { showSettingsSuccess(showMenuBarIcon ? "Menu bar icon enabled." : "Menu bar icon hidden.") } - func setMachineEnvAllowList(_ names: [String]) { - let normalized = MachineEnvImport.normalize(names) - machineEnvAllowList = normalized - UserDefaults.standard.set(MachineEnvImport.serialize(normalized), forKey: Self.machineEnvAllowListKey) - showSettingsSuccess(normalized.isEmpty - ? "New machines will not copy host environment variables." - : "New machines will copy \(normalized.count) allowed environment variable\(normalized.count == 1 ? "" : "s") when present.") + func setMachineEnvAllowList(_: [String]) { + machineEnvAllowList = [] + UserDefaults.standard.removeObject(forKey: Self.machineEnvAllowListKey) + showSettingsSuccess("Host environment import is disabled for new machines.") } func completeOnboarding() { @@ -737,8 +786,9 @@ final class AppStore { private var shimServer: ShimHTTPServer? var shimSocketPath: String { daemonSocketPath ?? DockerShim.defaultSocketPath } private(set) var shimRunning = false - /// Apple Silicon FEX translation for linux/amd64 images. Enabled on new installations and still - /// user-disableable; Rosetta remains a separate one-off `dory vm --rosetta` path. + /// Apple Silicon FEX translation for x86_64 Linux applications inside Dory's ARM64 container + /// VM. Enabled on new installations and still user-disableable. This does not boot an Intel + /// distro or ISO; Rosetta likewise translates applications inside an eligible ARM64 Linux VM. var rosettaX86Enabled = false /// Opt-in experimental GPU acceleration (virtio-gpu/Venus → virglrenderer → MoltenVK → Metal) for /// Vulkan and AI compute inside containers. Applied transactionally at engine restart; missing @@ -874,13 +924,29 @@ final class AppStore { let (status, socketPath) = try await waitForDorydBackend() daemonSocketPath = socketPath runtimeOwnedByDoryd = true - runtime = DockerEngineRuntime(socketPath: socketPath, kind: .sharedVM) + let client = dorydClient + let home = environment["HOME"] ?? NSHomeDirectory() + let dockerRuntime = DockerEngineRuntime( + socketPath: socketPath, + kind: .sharedVM, + migrationTargetStorageUsageProbe: { + let usage = try await client.dockerGuestDataDiskUsage() + return try Self.verifiedMigrationTargetStorageUsage( + usage, + expectedSocketPath: socketPath, + selectedDataDriveHome: home + ) + } + ) + runtime = dockerRuntime if status.state != "running" { // Opening Dory is an explicit "I want the engine" signal. doryd may arm a sleeping // socket at login or after Auto-Idle, but the app should promote it to a live engine // on attach; idle policy decides only whether it may sleep again later. await refreshDorydRuntimeMode() + loadState = .connecting + sharedVMStatus = "Starting Dory's engine… The first launch can take a minute." let started = try await dorydClient.engineStart() guard started.ok else { sharedVMStatus = started.message.isEmpty ? "doryd could not start the engine." : started.message @@ -894,28 +960,136 @@ final class AppStore { engineSleeping = false engineActivity.setSleeping(false) + loadState = .connecting + sharedVMStatus = "Dory's engine is running; connecting to Docker…" + let snapshot = try await waitForDorydDockerReadiness( + runtime: dockerRuntime, + socketPath: socketPath + ) + await applyRuntimeSnapshot(snapshot, synchronizeFinderStorage: true) sharedVMStatus = "Running through doryd" - await reload() - if loadState == .engineOff { - sharedVMStatus = "doryd started, but Docker did not answer at \(socketPath)." - _ = try? await dorydClient.engineStop() - runtimeOwnedByDoryd = false - daemonSocketPath = nil - runtime = DisconnectedRuntime() - return false - } await loadCustomDomainRoutes() return true } catch { runtimeOwnedByDoryd = false daemonSocketPath = nil - sharedVMStatus = "doryd is unavailable: \(error)" + if let readinessError = error as? DorydBackendReadinessError { + sharedVMStatus = readinessError.description + } else { + sharedVMStatus = "doryd is unavailable: \(error)" + } loadState = .engineOff + runtime = DisconnectedRuntime() return false } } - private func waitForDorydBackend(timeout: TimeInterval = 8) async throws -> (DorydEngineStatus, String) { + /// Binds a daemon-owned guest measurement to both authorities used by migration: the exact + /// Docker socket and the currently verified selected data drive. Capacity similarity is not an + /// identity proof; two different drives commonly have the same default 128 GiB ceiling. + nonisolated static func verifiedMigrationTargetStorageUsage( + _ usage: DorydDockerGuestDataDiskUsage, + expectedSocketPath: String, + selectedDataDriveHome home: String + ) throws -> MigrationTargetStorageUsage { + guard usage.engineSocketPath == expectedSocketPath else { + throw MigrationStrictInventoryError.incomplete( + "doryd measured a different engine socket than the migration target" + ) + } + + let selectedDriveID: UUID + do { + let store = try DoryDataDriveSelectionStore(home: home) + guard let selection = try store.read(), selection.phase == .ready, + let drive = try store.inspectSelection() else { + throw MigrationStrictInventoryError.incomplete( + "Dory has no verified selected data drive" + ) + } + let manifest = try drive.readManifest() + guard manifest.id == selection.driveID else { + throw MigrationStrictInventoryError.incomplete( + "Dory's selected data-drive record changed during verification" + ) + } + selectedDriveID = selection.driveID + } catch let error as MigrationStrictInventoryError { + throw error + } catch { + throw MigrationStrictInventoryError.incomplete( + "Dory's selected data-drive identity could not be verified: \(error)" + ) + } + guard usage.dataDriveID == selectedDriveID else { + throw MigrationStrictInventoryError.incomplete( + "doryd measured a different data drive than the migration target" + ) + } + guard let totalBytes = Int64(exactly: usage.totalBytes), + let usedBytes = Int64(exactly: usage.usedBytes), + let availableBytes = Int64(exactly: usage.availableBytes) else { + throw MigrationStrictInventoryError.incomplete( + "Dory guest data-disk usage exceeds the supported signed byte range" + ) + } + return MigrationTargetStorageUsage( + totalBytes: totalBytes, + usedBytes: usedBytes, + availableBytes: availableBytes + ) + } + + /// `engineStart` confirms doryd's lifecycle promotion, but attaching clients can still race the + /// publication of the forwarded Unix socket or the first complete Docker inventory response. + /// Keep the UI in a truthful connecting state and retry that narrow handoff. A failed probe must + /// never stop daemon-owned work that is still becoming ready. + private func waitForDorydDockerReadiness( + runtime: DockerEngineRuntime, + socketPath: String, + timeout: TimeInterval = 30 + ) async throws -> RuntimeSnapshot { + let deadline = Date().addingTimeInterval(timeout) + var lastRuntimeError = "" + + repeat { + do { + return try await runtime.snapshot() + } catch { + lastRuntimeError = error.localizedDescription + } + + if let status = try? await dorydClient.engineStatus() { + switch status.state { + case "failed", "stopped", "unconfigured": + throw DorydBackendReadinessError.engineUnavailable( + state: status.state, + detail: status.detail + ) + case "starting": + sharedVMStatus = "Starting Dory's engine… The first launch can take a minute." + case "running": + sharedVMStatus = "Dory's engine is running; connecting to Docker…" + case "sleeping": + sharedVMStatus = "Waking Dory's engine…" + default: + sharedVMStatus = "Waiting for Dory's engine…" + } + } + + try Task.checkCancellation() + try await Task.sleep(for: .milliseconds(250)) + } while Date() < deadline + + throw DorydBackendReadinessError.dockerTimedOut( + socketPath: socketPath, + detail: lastRuntimeError + ) + } + + private func waitForDorydBackend( + timeout: TimeInterval = AppStore.dorydBackendAttachTimeout + ) async throws -> (DorydEngineStatus, String) { let deadline = Date().addingTimeInterval(timeout) var lastError: Error? repeat { @@ -972,10 +1146,12 @@ final class AppStore { func connectBackend() async { // Automation launches (UI tests, screenshot harnesses) never boot the real engine: they // exercise the app against the honest disconnected state unless they opt into a backend - // with an explicit DORY_RUNTIME. + // with an explicit runtime, daemon flag, or injected non-Mach-service endpoint. let runtimeOverride = environment["DORY_RUNTIME"] - if isAutomationContext, runtimeOverride == nil, - !(dorydEngineExplicitlyRequested && dorydEngineEnabled && enginePreference == .dory) { + let explicitlyAuthorizedBackend = runtimeOverride != nil + || !dorydClient.usesMachService + || (dorydEngineExplicitlyRequested && dorydEngineEnabled && enginePreference == .dory) + if isAutomationContext, !explicitlyAuthorizedBackend { loadState = .engineOff return } @@ -1031,9 +1207,9 @@ final class AppStore { await connectBackend() } - /// Stops and re-provisions the shared engine so engine-level settings (GPU, amd64 emulation, - /// memory) or a newly installed Venus runtime take effect. The daemon-owned path captures the - /// exact running-container set and explicitly starts only those containers after reconnecting. + /// Stops and re-provisions the shared engine so engine-level settings (GPU, x86_64 application + /// compatibility, memory) or a newly installed Venus runtime take effect. The daemon-owned path + /// captures the exact running-container set and explicitly starts only those containers after reconnecting. func restartEngine() async { guard runtimeKind == .sharedVM || runtimeKind == .disconnected, !isConnecting else { return } sharedVMStatus = "Restarting the engine…" @@ -1618,7 +1794,9 @@ final class AppStore { var checks: [DoryUpgradeSmokeCheck] = [] if Bundle.main.object(forInfoDictionaryKey: "DoryUpgradeGateForceSmokeFailure") as? Bool == true { do { - let component = try await applyReleaseGateComponentUpdate() + let component = try await applyReleaseGateComponentUpdate( + operationID: record.id + ) checks.append(.init( id: "release-gate.component-update", passed: true, @@ -1701,7 +1879,9 @@ final class AppStore { return checks } - private func applyReleaseGateComponentUpdate() async throws -> DoryInstalledComponent { + private func applyReleaseGateComponentUpdate( + operationID: UUID + ) async throws -> DoryInstalledComponent { guard let rawURL = Bundle.main.object(forInfoDictionaryKey: "DoryUpgradeGateComponentCatalogURL") as? String, let url = URL(string: rawURL), url.scheme?.lowercased() == "https", ["127.0.0.1", "::1"].contains(url.host ?? ""), @@ -1730,7 +1910,8 @@ final class AppStore { } return try await DoryComponentInstaller(store: store).install( release, - catalogData: fetched.data + catalogData: fetched.data, + operationID: operationID ) { _ in } } @@ -1799,11 +1980,12 @@ final class AppStore { return getsockopt(descriptor, SOL_SOCKET, SO_ERROR, &socketError, &length) == 0 && socketError == 0 } - /// Toggles the FEX x86/amd64 path and restarts the shared engine so the new mode takes effect. + /// Toggles FEX for x86_64 Linux applications inside the ARM64 container VM and restarts the + /// shared engine. It never exposes an x86_64 guest OS or installer path. func setRosettaX86(_ on: Bool) async { guard on != rosettaX86Enabled else { return } guard !on || MacHostPlatform.current().isAppleSilicon else { - showSettingsFailure("x86/amd64 emulation is an Apple-silicon-only option; amd64 is native on Intel Macs.") + showSettingsFailure("x86_64 Linux application compatibility is available only inside Dory's ARM64 engine on Apple Silicon; Dory does not ship an Intel engine.") return } guard !engineSettingChangeInFlight else { @@ -1828,18 +2010,18 @@ final class AppStore { UserDefaults.standard.set(value, forKey: SharedVMProvisioner.Config.rosettaX86Key) UserDefaults.standard.set(previousGPU, forKey: SharedVMProvisioner.Config.gpuVenusKey) }, - applyingMessage: on ? "Enabling x86/amd64 emulation…" : "Disabling x86/amd64 emulation…", + applyingMessage: on ? "Enabling x86_64 application compatibility…" : "Disabling x86_64 application compatibility…", successMessage: on && previousGPU - ? "x86/amd64 emulation enabled. GPU acceleration was disabled to keep the required 4 KiB page size." - : (on ? "x86/amd64 emulation enabled." : "x86/amd64 emulation disabled.") + ? "x86_64 application compatibility enabled. GPU acceleration was disabled to keep the required 4 KiB page size." + : (on ? "x86_64 application compatibility enabled." : "x86_64 application compatibility disabled.") ) { return } guard runtimeKind == .sharedVM || runtimeKind == .disconnected, !isConnecting else { return } - sharedVMStatus = on ? "Enabling x86/amd64 emulation…" : "Disabling x86/amd64 emulation…" + sharedVMStatus = on ? "Enabling x86_64 application compatibility…" : "Disabling x86_64 application compatibility…" await SharedVMProvisioner.stopEngine() await connectBackend() - showSettingsSuccess(on ? "x86/amd64 emulation enabled." : "x86/amd64 emulation disabled.") + showSettingsSuccess(on ? "x86_64 application compatibility enabled." : "x86_64 application compatibility disabled.") } /// Toggles experimental GPU acceleration (virtio-gpu/Venus) and restarts the shared engine so the @@ -1856,7 +2038,7 @@ final class AppStore { return } guard !on || !rosettaX86Enabled else { - showSettingsFailure("GPU acceleration cannot be enabled while x86/amd64 emulation is on. FEX requires Dory's 4 KiB guest kernel.") + showSettingsFailure("GPU acceleration cannot be enabled while x86_64 application compatibility is on. FEX requires Dory's 4 KiB ARM64 guest kernel.") return } engineSettingChangeInFlight = true @@ -1892,8 +2074,9 @@ final class AppStore { physicalMemory: UInt64 = ProcessInfo.processInfo.physicalMemory ) -> EngineResourceLimits { let maximumCPUCount = max(1, min(activeProcessorCount, Int(UInt16.max))) - let hostMemoryMB = Int(clamping: physicalMemory / (1024 * 1024)) - let maximumMemoryMB = max(2048, min(hostMemoryMB - 4096, Int(UInt32.max))) + let maximumMemoryMB = DoryEngineMemoryPolicy.maximumConfigurableMemoryMB( + physicalMemory: physicalMemory + ) return EngineResourceLimits( maximumCPUCount: maximumCPUCount, maximumMemoryMB: maximumMemoryMB @@ -1959,7 +2142,8 @@ final class AppStore { /// long shutdown timeout, let launchd replace doryd with the new explicit environment, then /// reconnect and explicitly restart the exact containers that were running before the stop. /// Any failure restores the persisted value and makes one recovery attempt with the prior - /// configuration so a rejected GPU/amd64 choice cannot strand the engine or user workloads. + /// configuration so a rejected GPU/x86_64-application choice cannot strand the engine or user + /// workloads. private func applyDorydOwnedEngineSetting( previousValue: Value, restore: @MainActor (Value) -> Void, @@ -2145,6 +2329,7 @@ final class AppStore { var failures: [String] = [] for machineID in machineIDs { do { + try await refreshManagedDesktopKernelBeforeStart(machineID) _ = try await dorydClient.machineStart(machineID) } catch { failures.append("\(machineID) (\(error.localizedDescription))") @@ -2154,6 +2339,45 @@ final class AppStore { return failures } + /// Existing managed desktops retain their root disk across app/component upgrades. Refresh + /// only Dory's copied direct-boot kernel before launch so renderer qualification is bound to + /// the current verified runtime without touching the guest filesystem or user data. + private func refreshManagedDesktopKernelBeforeStart(_ machineID: String) async throws { + guard let status = try await dorydClient.machineList().first(where: { + $0.id == machineID + }) else { + throw DesktopMachineAssetError.filesystem( + "machine \(machineID) is unavailable" + ) + } + guard status.bootMode == .linuxKernel, + status.displayMode == .desktop else { + return + } + let typedSettings = status.typedSettings ?? DorydMachineTypedSettings( + legacyEnvironment: status.environment, + displayMode: status.displayMode + ) + let distro = DesktopMachineDistro.resolve( + typedSettings.guestIdentityIntent.desktop?.distributionIdentifier + ) + let home = environment["HOME"] ?? NSHomeDirectory() + let assets = try await desktopMachineAssetPreparer( + home, + Self.desktopAssetEnvironment(processEnvironment: environment, distro: distro), + Bundle.main.resourcePath + ) + let kernelPath = assets.kernelPath + let kernelSHA256 = try await Task.detached(priority: .userInitiated) { + try DesktopMachineAssetProvisioner.preparedKernelSHA256(at: kernelPath) + }.value + _ = try await dorydClient.machineRefreshManagedDesktopKernel( + machineID, + sourcePath: kernelPath, + sourceSHA256: kernelSHA256 + ) + } + private nonisolated static func workloadFailureSummary(_ failures: [String]) -> String { let visible = failures.prefix(3).joined(separator: ", ") let remaining = failures.count - min(failures.count, 3) @@ -2187,8 +2411,6 @@ final class AppStore { enabled: openLoginsOnMac, open: { url in DispatchQueue.main.async { NSWorkspace.shared.open(url) } } ) - @ObservationIgnored private let usbAttachments = UsbAttachmentStore() - @ObservationIgnored private var usbReplayedMachines: Set = [] private let domainTable = DomainTable() @ObservationIgnored private var dns = DoryDNS() @ObservationIgnored private let reverseProxy: DoryReverseProxy @@ -2441,6 +2663,7 @@ final class AppStore { httpProxyPort: httpProxyPort, httpsProxyPort: httpsProxyPort, hostCLIEnabled: routeDockerCLI, + vmQualificationBootstrapEnabled: AppInfo.vmQualificationBootstrapEnabled, amd64EmulationEnabled: rosettaX86Enabled && MacHostPlatform.current().isAppleSilicon, gpuVenusEnabled: gpuVenusEnabled, cpuCount: UInt16(clamping: engineCPUCount), @@ -2619,26 +2842,11 @@ final class AppStore { func registerMachineBridge(_ name: String) { try? FileManager.default.createDirectory(atPath: MachineService.bridgeHostDir(for: name), withIntermediateDirectories: true) hostBridge.startWatching(machine: name) - replayRememberedUSB(machine: name) } func unregisterMachineBridge(_ name: String) { hostBridge.stopWatching(machine: name) portForwarder.teardownLoopback(forMachine: name) - usbReplayedMachines.remove(name) - } - - private func replayRememberedUSB(machine: String) { - guard UsbPassthroughAvailability.attachSupported else { return } - guard !usbReplayedMachines.contains(machine) else { return } - let commands = usbAttachments.reattachCommands(for: machine) - usbReplayedMachines.insert(machine) - guard !commands.isEmpty else { return } - Task.detached(priority: .utility) { - for arguments in commands { - _ = await UsbDevicesView.runDory(arguments) - } - } } /// `.dory.local` → the published host port that reaches the container. Containers without a @@ -3450,6 +3658,22 @@ final class AppStore { try? await syncFinderStorageLocation(force: true) return } + await applyRuntimeSnapshot(snap, synchronizeFinderStorage: true) + } + + private func reloadWithoutExtendingEngineIdle() async { + guard runtimeOwnedByDoryd, let docker = runtime as? DockerEngineRuntime, + let payload = try? await dorydClient.engineDashboardSnapshot(), + let snapshot = try? docker.dashboardSnapshot(from: payload) else { + return + } + await applyRuntimeSnapshot(snapshot, synchronizeFinderStorage: false) + } + + private func applyRuntimeSnapshot( + _ snap: RuntimeSnapshot, + synchronizeFinderStorage: Bool + ) async { if containers != snap.containers { containers = snap.containers; syncMachineStats(); noteEngineActivity() } if images != snap.images { images = snap.images; noteEngineActivity() } if volumes != snap.volumes { volumes = snap.volumes } @@ -3465,7 +3689,9 @@ final class AppStore { cpuHistory = cpuHistory.filter { liveIDs.contains($0.key) } let newState: LoadState = snap.engineRunning ? .ready : .engineOff if loadState != newState { loadState = newState } - try? await syncFinderStorageLocation() + if synchronizeFinderStorage { + try? await syncFinderStorageLocation() + } } var canBrowseDoryStorage: Bool { @@ -3488,6 +3714,10 @@ final class AppStore { } private func syncFinderStorageLocation(force: Bool = false) async throws { + // An injected test environment can intentionally omit XCTest's variables while exercising + // a real-shaped runtime. The host-process classification is authoritative: tests must + // never register, hide, materialize, or publish into the user's live File Provider domain. + guard !isAutomationContext else { return } guard canBrowseDoryStorage, let docker = runtime as? DockerEngineRuntime else { await finderStorageLocation.hide() finderStorageLocationActive = false @@ -3538,7 +3768,11 @@ final class AppStore { // or an external docker request wakes it through doryd's data plane. if engineSleeping { return } capEngineLogIfDue() - await reload() + if runtimeOwnedByDoryd { + await reloadWithoutExtendingEngineIdle() + } else { + await reload() + } loadMachines() if runtimeKind == .sharedVM { await loadKubernetes() } await evaluateIdleSleep() @@ -3552,7 +3786,11 @@ final class AppStore { loadMachines() if await syncDorydEngineStateBeforeDockerPoll() { return } if engineSleeping { return } - await reload() + if runtimeOwnedByDoryd { + await reloadWithoutExtendingEngineIdle() + } else { + await reload() + } if runtimeKind == .sharedVM { await loadKubernetes() } } @@ -3576,6 +3814,13 @@ final class AppStore { engineActivity.touch() sharedVMStatus = status.detail.isEmpty ? "Running through doryd" : status.detail return false + case "starting": + engineSleeping = false + engineActivity.setSleeping(false) + engineRunning = false + loadState = .connecting + sharedVMStatus = status.detail.isEmpty ? "Starting the engine…" : status.detail + return true case "stopped", "failed", "unconfigured": engineSleeping = false engineActivity.setSleeping(false) @@ -5083,9 +5328,16 @@ final class AppStore { cpus: status.cpuCount, memoryMB: status.memoryMB.flatMap { Int(exactly: $0) }, mounts: status.shares.map(Self.mountPair(fromDoryd:)), - env: status.environment, + env: [:], + virtualMachineSettings: status.typedSettings + ?? DorydMachineTypedSettings( + legacyEnvironment: status.environment, + displayMode: status.displayMode + ), + displayPresentation: status.displayPresentation, address: status.configuredAddress, - displayMode: status.displayMode + displayMode: status.displayMode, + bootMode: status.bootMode ) } catch { actionError = "Could not load doryd machine settings: \(error)" @@ -5094,8 +5346,23 @@ final class AppStore { } private(set) var busyMachines: Set = [] + @ObservationIgnored private var machineEventSequence: UInt64 = 0 + private(set) var machineFileTransfers: [String: DorydMachineFileTransferOperation] = [:] + private(set) var machineGuestFileExports: [String: DorydMachineGuestFileExportOperation] = [:] + private var recoveringMachineFileTransfers: Set = [] + private var recoveringMachineGuestFileExports: Set = [] + private var machineGuestFileExportSuggestedNames: [String: String] = [:] var machineBusy: Bool { !busyMachines.isEmpty } func isMachineBusy(_ name: String) -> Bool { busyMachines.contains(name) } + func machineFileTransfer(for name: String) -> DorydMachineFileTransferOperation? { + machineFileTransfers[name] + } + func machineGuestFileExport(for name: String) -> DorydMachineGuestFileExportOperation? { + machineGuestFileExports[name] + } + func suggestedGuestFileExportName(for name: String) -> String { + machineGuestFileExportSuggestedNames[name] ?? "\(name)-export" + } static let importBusyKey = "__dory_import__" var machineCreationTitle = "" var machineCreationLog = "" @@ -5103,20 +5370,54 @@ final class AppStore { var machineCreated: Machine? func loadMachines() { - guard runtimeOwnedByDoryd else { machines = []; return } - Task { await refreshMachines() } + guard runtimeOwnedByDoryd else { + machineEventSequence = 0 + machines = [] + return + } + Task { await refreshMachines(useEventCursor: true) } } @discardableResult - private func refreshMachines() async -> [Machine] { + private func refreshMachines(useEventCursor: Bool = false) async -> [Machine] { guard runtimeOwnedByDoryd else { + machineEventSequence = 0 machines = [] dns.replaceHostIPs([:]) return [] } + var eventBatch: DorydMachineEventBatch? + var requiresMachineSnapshot = true + let requestedEventSequence = machineEventSequence + if useEventCursor { + eventBatch = try? await dorydClient.machineEvents( + afterSequence: requestedEventSequence + ) + if let eventBatch { + requiresMachineSnapshot = eventBatch.snapshotRequired + || !eventBatch.events.isEmpty + if !requiresMachineSnapshot { + advanceMachineEventSequence( + eventBatch.headSequence, + requestedSequence: requestedEventSequence + ) + } + } + } do { - machines = try await dorydClient.machineList().map { - Self.machine(fromDoryd: $0, domainSuffix: domainSuffix) + if requiresMachineSnapshot { + machines = try await dorydClient.machineList().map { + Self.machine(fromDoryd: $0, domainSuffix: domainSuffix) + } + if actionError?.hasPrefix("doryd machine list failed:") == true { + actionError = nil + } + if let eventBatch { + advanceMachineEventSequence( + eventBatch.headSequence, + requestedSequence: requestedEventSequence + ) + } } for machine in machines where machine.status == .running { Task { [weak self] in @@ -5127,6 +5428,8 @@ final class AppStore { self.machines[index].cpuPercent = stats.cpuPercent self.machines[index].memoryDisplay = Self.machineMemoryDisplay(stats) } + recoverActiveFileTransfer(for: machine) + recoverGuestFileExport(for: machine) } return machines } catch { @@ -5136,6 +5439,19 @@ final class AppStore { } } + private func advanceMachineEventSequence( + _ headSequence: UInt64, + requestedSequence: UInt64 + ) { + // Async refreshes can overlap at suspension points. Never let an older response move a + // newer cursor backwards; allow a daemon-reset snapshot to lower only the cursor that made + // that exact request. + if machineEventSequence == requestedSequence + || headSequence > machineEventSequence { + machineEventSequence = headSequence + } + } + nonisolated static func machineDNSName(name: String, suffix: String) -> String { "\(name).\(suffix)".lowercased() } @@ -5151,8 +5467,10 @@ final class AppStore { switch status.state { case "running": runState = .running - case "starting": + case "paused", "starting": runState = .paused + case "suspended": + runState = .suspended default: runState = .stopped } @@ -5160,26 +5478,51 @@ final class AppStore { .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .first { !$0.isEmpty } ?? status.state let isDesktop = status.displayMode == .desktop - let desktopDistro = DesktopMachineDistro.resolve(status.environment["DORY_DESKTOP_DISTRO"]) - let guestUsername = status.environment["DORY_GUEST_USER"] ?? (isDesktop ? "dory" : "root") + let isCustomLinux = status.bootMode == .efi + let typedSettings = status.typedSettings ?? DorydMachineTypedSettings( + legacyEnvironment: status.environment, + displayMode: status.displayMode + ) + let desktopDistro = DesktopMachineDistro.resolve( + typedSettings.guestIdentityIntent.desktop?.distributionIdentifier + ) + let guestUsername = typedSettings.guestIdentityIntent.account?.username + ?? (isCustomLinux ? "installer" : (isDesktop ? "dory" : "root")) + let fileTransferPolicy = status.runtimeIdentity.mode == "legacy-compatibility" + ? DoryVMClipboardDirection.bidirectional + : typedSettings.clipboardPolicy?.files ?? .off return Machine( name: status.id, - distro: isDesktop ? desktopDistro.displayName : "Dory Linux", - version: isDesktop ? "\(desktopDistro.version) · \(desktopDistro.desktopName)" : detail, + distro: isCustomLinux ? "Custom Linux" : (isDesktop ? desktopDistro.displayName : "Dory Linux"), + version: isCustomLinux ? "EFI · arm64" : (isDesktop ? "\(desktopDistro.version) · \(desktopDistro.desktopName)" : detail), status: runState, cpuPercent: 0, memoryDisplay: "—", ip: status.address ?? Self.machineDNSName(name: status.id, suffix: domainSuffix), - letter: isDesktop ? String(desktopDistro.displayName.prefix(1)) : "D", - badgeHex: isDesktop ? desktopDistro.badgeHex : 0x3B82F6, + letter: isCustomLinux ? "L" : (isDesktop ? String(desktopDistro.displayName.prefix(1)) : "D"), + badgeHex: isCustomLinux ? 0x7C3AED : (isDesktop ? desktopDistro.badgeHex : 0x3B82F6), containerID: "", arch: "", recipe: "doryd", username: guestUsername, - loginShell: isDesktop ? "/bin/bash" : "/bin/sh", + loginShell: isCustomLinux ? "" : (isDesktop ? "/bin/bash" : "/bin/sh"), shellSocketPath: status.shellSocketPath ?? "", processID: status.pid, + failure: status.failure, + activeOperation: status.activeOperation, + flightRecorderHeadSequence: status.flightRecorderHeadSequence, + flightRecorderAvailable: status.flightRecorderAvailable, displayMode: status.displayMode, + bootMode: status.bootMode, + installerMediaAttached: status.installerMediaAttached, + runtimeIdentity: status.runtimeIdentity, + runtimeGraphicsSelection: status.runtimeGraphicsSelection, + cloneReceipt: status.cloneReceipt, + agentBuild: status.agentBuild, + agentProtocolVersion: status.agentProtocolVersion, + agentCapabilities: status.agentCapabilities, + integrationHealth: status.integrationHealth, + fileTransferPolicy: fileTransferPolicy, mounts: status.shares.map(Self.mountPair(fromDoryd:)) ) } @@ -5191,32 +5534,85 @@ final class AppStore { } nonisolated private static func mountPair(fromDoryd share: DorydMachineShareConfiguration) -> MountPair { - MountPair(host: share.hostPath, guest: share.guestPath, readOnly: share.readOnly) + MountPair( + host: share.hostPath, + guest: share.guestPath, + readOnly: share.readOnly, + shareTag: share.tag + ) } - nonisolated private static func dorydShares(from mounts: [MountPair]) -> [DorydMachineShareConfiguration] { - mounts.enumerated().compactMap { index, mount in + nonisolated static func dorydShares(from mounts: [MountPair]) -> [DorydMachineShareConfiguration] { + var usedTags = Set(mounts.compactMap { mount -> String? in + let tag = mount.shareTag?.trimmingCharacters(in: .whitespacesAndNewlines) + return tag?.isEmpty == false ? tag : nil + }) + var nextGeneratedTag = 0 + var shares: [DorydMachineShareConfiguration] = [] + + for mount in mounts { let host = mount.host.trimmingCharacters(in: .whitespacesAndNewlines) let guest = mount.guest.trimmingCharacters(in: .whitespacesAndNewlines) - guard !host.isEmpty, !guest.isEmpty else { return nil } - return DorydMachineShareConfiguration( - tag: "doryapp\(index)", + guard !host.isEmpty, !guest.isEmpty else { continue } + let explicitTag = mount.shareTag?.trimmingCharacters(in: .whitespacesAndNewlines) + let tag: String + if let explicitTag, !explicitTag.isEmpty { + tag = explicitTag + } else { + while usedTags.contains("doryapp\(nextGeneratedTag)") { + nextGeneratedTag += 1 + } + tag = "doryapp\(nextGeneratedTag)" + nextGeneratedTag += 1 + usedTags.insert(tag) + } + let bookmark = try? URL(fileURLWithPath: host).bookmarkData( + options: [.minimalBookmark], + includingResourceValuesForKeys: [.fileResourceIdentifierKey, .volumeIdentifierKey], + relativeTo: nil + ) + shares.append(DorydMachineShareConfiguration( + tag: tag, hostPath: host, guestPath: guest, - readOnly: mount.readOnly - ) + readOnly: mount.readOnly, + authorizationBookmark: bookmark + )) } + return shares } func machineTerminalCommand(_ machine: Machine) -> String? { - guard runtimeOwnedByDoryd else { return nil } + guard canOpenMachineTerminal(machine) else { return nil } return TerminalLauncher.userFacingMachineShellCommand(target: UserFacingMachineShellTarget( machineID: machine.name )) } func canOpenMachineTerminal(_ machine: Machine) -> Bool { - runtimeOwnedByDoryd && !machine.shellSocketPath.isEmpty && HostTools.userFacingDoryCommand() != nil + runtimeOwnedByDoryd && !machine.shellSocketPath.isEmpty && userFacingDoryCommandResolver() != nil + } + + func readMachineSerialConsole( + _ machine: Machine, + cursor: DorydMachineSerialConsoleCursor, + limit: UInt32 = 64 * 1_024 + ) async throws -> DorydMachineSerialConsoleBatch { + guard runtimeOwnedByDoryd else { + throw DorydClientError.daemon(Self.dorydMachineManagerRequired("machine serial console")) + } + return try await dorydClient.machineSerialConsole( + machineID: machine.name, + cursor: cursor, + limit: limit + ) + } + + func writeMachineSerialConsole(_ machine: Machine, data: Data) async throws { + guard runtimeOwnedByDoryd else { + throw DorydClientError.daemon(Self.dorydMachineManagerRequired("machine serial console")) + } + _ = try await dorydClient.writeMachineSerialConsole(machineID: machine.name, data: data) } func canOpenMachineDesktop(_ machine: Machine) -> Bool { @@ -5232,7 +5628,10 @@ final class AppStore { actionError = "The Desktop Linux display is not available yet. Start the machine and try again." return } - guard application.activate(options: [.activateAllWindows]) else { + if application.activate(options: [.activateAllWindows]) { return } + // Raw-HV desktops run in an unbundled helper, which LaunchServices can discover by PID but + // does not always activate. The helper handles SIGUSR1 by raising its own display window. + guard Darwin.kill(processID, SIGUSR1) == 0 else { actionError = "Dory could not bring \(machine.name)'s desktop window forward." return } @@ -5242,6 +5641,588 @@ final class AppStore { runtimeOwnedByDoryd } + func canTransferFiles(to machine: Machine) -> Bool { + guard runtimeOwnedByDoryd, + machine.status == .running, + machine.fileTransferPolicy.allowsHostToGuest, + machine.agentProtocolVersion == 1 else { + return false + } + func supports(_ id: String) -> Bool { + machine.agentCapabilities.contains { $0.id == id && $0.version >= 1 } + } + return supports("exec") && supports("sync-push") + } + + func canTransferFolders(to machine: Machine) -> Bool { + canTransferFiles(to: machine) + && machine.agentCapabilities.contains { + $0.id == "sync-push" && $0.version >= 2 + } + } + + func canExportGuestFiles(from machine: Machine) -> Bool { + runtimeOwnedByDoryd + && machine.status == .running + && machine.fileTransferPolicy.allowsGuestToHost + && machine.agentProtocolVersion == 1 + && machine.username != "root" + && machine.agentCapabilities.contains { + $0.id == "sync-pull" && $0.version >= 1 + } + } + + func canRepairMachineTools(_ machine: Machine) -> Bool { + runtimeOwnedByDoryd + && machine.displayMode == .desktop + && machine.bootMode == .linuxKernel + } + + func repairMachineTools(_ machine: Machine) { + guard canRepairMachineTools(machine), !busyMachines.contains(machine.name) else { return } + Task { + do { + let updates = try await updateManagedDesktops( + affectedBy: [ + .linuxDesktop, + .desktopDebian, + .desktopUbuntu, + .desktopKali, + ], + force: true, + machineID: machine.name + ) + guard let update = updates.first, updates.count == 1 else { + throw DesktopMachineAssetError.missingAsset( + "a verified Dory Tools update for \(machine.name)" + ) + } + showSettingsSuccess( + "Repaired Dory Tools in \(machine.name) and verified \(update.version)." + ) + } catch { + actionError = "Could not repair Dory Tools in \(machine.name): \(Self.userFacingError(error))" + } + } + } + + func cancelFileTransfer(to machine: Machine) async { + guard let operation = machineFileTransfers[machine.name], + !operation.phase.isTerminal else { + return + } + do { + let updated = try await dorydClient.machineTransferCancel( + machine.name, + operationID: operation.operationID + ) + guard machineFileTransfers[machine.name]?.operationID == operation.operationID else { + return + } + machineFileTransfers[machine.name] = updated + } catch { + actionError = "Could not cancel the file transfer to \(machine.name): \(Self.userFacingError(error))" + } + } + + func cancelGuestFileExport(from machine: Machine) async { + guard let operation = machineGuestFileExports[machine.name], + !operation.phase.isTerminal else { + return + } + do { + let updated = try await dorydClient.machineGuestExportCancel( + machine.name, + operationID: operation.operationID + ) + guard machineGuestFileExports[machine.name]?.operationID + == operation.operationID else { + return + } + machineGuestFileExports[machine.name] = updated + } catch { + actionError = "Could not cancel the file export from \(machine.name): \(Self.userFacingError(error))" + } + } + + func discardGuestFileExport(from machine: Machine) async { + guard let operation = machineGuestFileExports[machine.name], + operation.phase == .completed else { + return + } + do { + let discarded = try await dorydClient.machineGuestExportDiscard( + machine.name, + operationID: operation.operationID + ) + guard discarded.ok, + machineGuestFileExports[machine.name]?.operationID + == operation.operationID else { + throw DoryMachineFileTransferUIError.authorityChanged + } + machineGuestFileExports.removeValue(forKey: machine.name) + machineGuestFileExportSuggestedNames.removeValue(forKey: machine.name) + showSettingsSuccess("Discarded the pending file export from \(machine.name).") + } catch { + actionError = "Could not discard the file export from \(machine.name): \(Self.userFacingError(error))" + } + } + + private func recoverActiveFileTransfer(for machine: Machine) { + guard machineFileTransfers[machine.name] == nil, + !busyMachines.contains(machine.name), + recoveringMachineFileTransfers.insert(machine.name).inserted else { + return + } + Task { [weak self] in + await self?.recoverActiveFileTransfer(machineID: machine.name) + } + } + + private func recoverActiveFileTransfer(machineID: String) async { + defer { recoveringMachineFileTransfers.remove(machineID) } + let discovered: DorydMachineFileTransferOperation? + do { + discovered = try await dorydClient.machineTransferCurrent(machineID) + } catch { + return + } + guard let operation = discovered, + !operation.phase.isTerminal, + machineFileTransfers[machineID] == nil, + !busyMachines.contains(machineID) else { + return + } + + let operationID = operation.operationID + busyMachines.insert(machineID) + machineFileTransfers[machineID] = operation + defer { + if machineFileTransfers[machineID]?.operationID == operationID { + machineFileTransfers.removeValue(forKey: machineID) + busyMachines.remove(machineID) + } + } + + do { + var current = operation + while !current.phase.isTerminal { + try await Task.sleep(for: .milliseconds(150)) + current = try await dorydClient.machineTransferStatus( + machineID, + operationID: operationID + ) + guard current.operationID == operationID, + machineFileTransfers[machineID]?.operationID == operationID else { + throw DoryMachineFileTransferUIError.authorityChanged + } + machineFileTransfers[machineID] = current + } + + switch current.phase { + case .completed: + guard let result = current.result else { + throw DoryMachineFileTransferUIError.invalidCompletion + } + let fileLabel = result.filesSent == 1 ? "file" : "files" + let bytes = ByteCountFormatter.string( + fromByteCount: Int64(clamping: result.bytesSent), + countStyle: .file + ) + showSettingsSuccess( + "Sent \(result.filesSent) \(fileLabel) (\(bytes)) to \(result.guestDestination)." + ) + case .cancelled: + showSettingsSuccess("Cancelled the file transfer to \(machineID).") + case .failed: + let message = current.failure?.message ?? "The daemon reported a failed transfer." + throw DoryMachineFileTransferUIError.remoteFailure(message) + case .preparing, .transferring, .finalizing, .cancelling: + throw DoryMachineFileTransferUIError.invalidCompletion + } + } catch is CancellationError { + return + } catch { + actionError = "Could not restore the file transfer to \(machineID): \(Self.userFacingError(error))" + } + } + + private func recoverGuestFileExport(for machine: Machine) { + guard machineGuestFileExports[machine.name] == nil, + recoveringMachineGuestFileExports.insert(machine.name).inserted else { + return + } + Task { [weak self] in + await self?.recoverGuestFileExport(machineID: machine.name) + } + } + + private func recoverGuestFileExport(machineID: String) async { + defer { recoveringMachineGuestFileExports.remove(machineID) } + let discovered: DorydMachineGuestFileExportOperation? + do { + discovered = try await dorydClient.machineGuestExportCurrent(machineID) + } catch { + return + } + guard var operation = discovered, + machineGuestFileExports[machineID] == nil, + machineFileTransfers[machineID] == nil else { + return + } + + let operationID = operation.operationID + machineGuestFileExportSuggestedNames[machineID] = "\(machineID)-export" + machineGuestFileExports[machineID] = operation + if operation.phase == .completed { + showSettingsSuccess("Files from \(machineID) are ready to save.") + return + } + guard !operation.phase.isTerminal, !busyMachines.contains(machineID) else { + machineGuestFileExports.removeValue(forKey: machineID) + machineGuestFileExportSuggestedNames.removeValue(forKey: machineID) + return + } + + busyMachines.insert(machineID) + defer { busyMachines.remove(machineID) } + do { + operation = try await awaitGuestFileExport( + machineID: machineID, + operationID: operationID, + initial: operation + ) + switch operation.phase { + case .completed: + showSettingsSuccess("Files from \(machineID) are ready to save.") + case .cancelled: + machineGuestFileExports.removeValue(forKey: machineID) + machineGuestFileExportSuggestedNames.removeValue(forKey: machineID) + showSettingsSuccess("Cancelled the file export from \(machineID).") + case .failed: + machineGuestFileExports.removeValue(forKey: machineID) + machineGuestFileExportSuggestedNames.removeValue(forKey: machineID) + let message = operation.failure?.message + ?? "The daemon reported a failed file export." + throw DoryMachineFileTransferUIError.remoteFailure(message) + case .preparing, .transferring, .finalizing, .cancelling: + throw DoryMachineFileTransferUIError.invalidCompletion + } + } catch is CancellationError { + return + } catch { + actionError = "Could not restore the file export from \(machineID): \(Self.userFacingError(error))" + } + } + + @discardableResult + func exportGuestFiles( + _ guestSource: String, + from machine: Machine, + to destinationURL: URL + ) async -> URL? { + guard canExportGuestFiles(from: machine) else { + actionError = "Start \(machine.name) and update Dory Tools before receiving files." + return nil + } + guard !busyMachines.contains(machine.name), + !recoveringMachineGuestFileExports.contains(machine.name), + machineGuestFileExports[machine.name] == nil else { + return nil + } + let suggestedName = destinationURL.lastPathComponent + guard !suggestedName.isEmpty else { + actionError = "Choose a name for the received files." + return nil + } + + busyMachines.insert(machine.name) + defer { busyMachines.remove(machine.name) } + actionError = nil + var operationID: String? + do { + var operation = try await dorydClient.machineGuestExportStart( + machine.name, + guestSource: guestSource + ) + operationID = operation.operationID + machineGuestFileExportSuggestedNames[machine.name] = suggestedName + machineGuestFileExports[machine.name] = operation + operation = try await awaitGuestFileExport( + machineID: machine.name, + operationID: operation.operationID, + initial: operation + ) + + switch operation.phase { + case .completed: + return await saveCompletedGuestFileExport( + from: machine, + operation: operation, + to: destinationURL + ) + case .cancelled: + machineGuestFileExports.removeValue(forKey: machine.name) + machineGuestFileExportSuggestedNames.removeValue(forKey: machine.name) + showSettingsSuccess("Cancelled the file export from \(machine.name).") + return nil + case .failed: + machineGuestFileExports.removeValue(forKey: machine.name) + machineGuestFileExportSuggestedNames.removeValue(forKey: machine.name) + let message = operation.failure?.message + ?? "The daemon reported a failed file export." + throw DoryMachineFileTransferUIError.remoteFailure(message) + case .preparing, .transferring, .finalizing, .cancelling: + throw DoryMachineFileTransferUIError.invalidCompletion + } + } catch is CancellationError { + if let operationID { + _ = try? await dorydClient.machineGuestExportCancel( + machine.name, + operationID: operationID + ) + } + machineGuestFileExports.removeValue(forKey: machine.name) + machineGuestFileExportSuggestedNames.removeValue(forKey: machine.name) + return nil + } catch { + if let operationID, + machineGuestFileExports[machine.name]?.phase.isTerminal == false { + _ = try? await dorydClient.machineGuestExportCancel( + machine.name, + operationID: operationID + ) + machineGuestFileExports.removeValue(forKey: machine.name) + machineGuestFileExportSuggestedNames.removeValue(forKey: machine.name) + } + actionError = "Could not receive files from \(machine.name): \(Self.userFacingError(error))" + return nil + } + } + + @discardableResult + func saveGuestFileExport( + from machine: Machine, + to destinationURL: URL + ) async -> URL? { + guard let operation = machineGuestFileExports[machine.name], + operation.phase == .completed else { + return nil + } + actionError = nil + return await saveCompletedGuestFileExport( + from: machine, + operation: operation, + to: destinationURL + ) + } + + private func awaitGuestFileExport( + machineID: String, + operationID: String, + initial: DorydMachineGuestFileExportOperation + ) async throws -> DorydMachineGuestFileExportOperation { + var operation = initial + while !operation.phase.isTerminal { + try await Task.sleep(for: .milliseconds(150)) + operation = try await dorydClient.machineGuestExportStatus( + machineID, + operationID: operationID + ) + guard operation.operationID == operationID, + machineGuestFileExports[machineID]?.operationID == operationID else { + throw DoryMachineFileTransferUIError.authorityChanged + } + machineGuestFileExports[machineID] = operation + } + return operation + } + + private func saveCompletedGuestFileExport( + from machine: Machine, + operation: DorydMachineGuestFileExportOperation, + to destinationURL: URL + ) async -> URL? { + guard machineGuestFileExports[machine.name]?.operationID == operation.operationID, + operation.phase == .completed, + let result = operation.result, + result.exportID == operation.operationID else { + actionError = "Could not save files from \(machine.name): The completed export evidence is invalid." + return nil + } + do { + let materialized = try await Task.detached(priority: .userInitiated) { + try DoryMachineFileTransferStager.materializeGuestExport( + privateStagingRoot: result.privateStagingRoot, + exportID: result.exportID, + expectedFileCount: result.filesReceived, + expectedDirectoryCount: result.directoriesReceived, + expectedByteCount: result.bytesReceived, + destinationDirectory: destinationURL.deletingLastPathComponent(), + destinationName: destinationURL.lastPathComponent + ) + }.value + do { + let discarded = try await dorydClient.machineGuestExportDiscard( + machine.name, + operationID: operation.operationID + ) + guard discarded.ok, + machineGuestFileExports[machine.name]?.operationID + == operation.operationID else { + throw DoryMachineFileTransferUIError.authorityChanged + } + machineGuestFileExports.removeValue(forKey: machine.name) + machineGuestFileExportSuggestedNames.removeValue(forKey: machine.name) + } catch { + actionError = "Saved files from \(machine.name) to \(materialized.rootURL.path), but Dory could not remove its private staging copy." + return materialized.rootURL + } + let fileLabel = result.filesReceived == 1 ? "file" : "files" + let folderLabel = result.directoriesReceived == 1 ? "folder" : "folders" + let itemSummary = result.directoriesReceived == 0 + ? "\(result.filesReceived) \(fileLabel)" + : "\(result.filesReceived) \(fileLabel) and \(result.directoriesReceived) \(folderLabel)" + let bytes = ByteCountFormatter.string( + fromByteCount: Int64(clamping: result.bytesReceived), + countStyle: .file + ) + showSettingsSuccess( + "Saved \(itemSummary) (\(bytes)) from \(machine.name) to \(materialized.rootURL.path)." + ) + return materialized.rootURL + } catch { + actionError = "Could not save files from \(machine.name): \(Self.userFacingError(error))" + return nil + } + } + + @discardableResult + func transferFiles( + _ fileURLs: [URL], + to machine: Machine + ) async -> DorydMachineFileTransferResult? { + guard canTransferFiles(to: machine) else { + actionError = "Start \(machine.name) and update Dory Tools before sending files." + return nil + } + guard !busyMachines.contains(machine.name), + !recoveringMachineFileTransfers.contains(machine.name) else { + return nil + } + busyMachines.insert(machine.name) + defer { + busyMachines.remove(machine.name) + machineFileTransfers.removeValue(forKey: machine.name) + } + actionError = nil + + var staged: DoryStagedMachineFileTransfer? + var operationID: String? + do { + staged = try await Task.detached(priority: .userInitiated) { + try DoryMachineFileTransferStager.stage(fileURLs: fileURLs) + }.value + guard let preparedStage = staged else { return nil } + guard preparedStage.directoryCount == 0 || canTransferFolders(to: machine) else { + do { + try await Task.detached(priority: .utility) { + try preparedStage.remove() + }.value + staged = nil + } catch { + actionError = "Dory could not remove its private staging copy." + return nil + } + actionError = "Update Dory Tools in \(machine.name) before sending folders." + return nil + } + var operation = try await dorydClient.machineTransferStart( + machine.name, + staged: preparedStage + ) + operationID = operation.operationID + machineFileTransfers[machine.name] = operation + while !operation.phase.isTerminal { + try await Task.sleep(for: .milliseconds(150)) + operation = try await dorydClient.machineTransferStatus( + machine.name, + operationID: operation.operationID + ) + guard operationID == operation.operationID else { + throw DoryMachineFileTransferUIError.authorityChanged + } + machineFileTransfers[machine.name] = operation + } + + guard operation.phase == .completed, + let result = operation.result else { + if operation.phase == .failed, let failure = operation.failure { + throw DoryMachineFileTransferUIError.remoteFailure(failure.message) + } + if operation.phase == .cancelled { + showSettingsSuccess("Cancelled the file transfer to \(machine.name).") + if let staged { + try? await Task.detached(priority: .utility) { try staged.remove() }.value + } + return nil + } + throw DoryMachineFileTransferUIError.invalidCompletion + } + guard result.filesSent == preparedStage.fileCount, + result.bytesSent == preparedStage.byteCount else { + throw DoryMachineFileTransferUIError.invalidCompletion + } + let fileLabel = result.filesSent == 1 ? "file" : "files" + let folderLabel = preparedStage.directoryCount == 1 ? "folder" : "folders" + let bytes = ByteCountFormatter.string( + fromByteCount: Int64(clamping: result.bytesSent), + countStyle: .file + ) + do { + try await Task.detached(priority: .utility) { + try preparedStage.remove() + }.value + } catch { + actionError = "Files reached \(machine.name), but Dory could not remove its private staging copy." + return result + } + let transferredItems = preparedStage.directoryCount == 0 + ? "\(result.filesSent) \(fileLabel)" + : "\(result.filesSent) \(fileLabel) and \(preparedStage.directoryCount) \(folderLabel)" + showSettingsSuccess("Sent \(transferredItems) (\(bytes)) to \(result.guestDestination).") + return result + } catch is CancellationError { + if let operationID { + _ = try? await dorydClient.machineTransferCancel( + machine.name, + operationID: operationID + ) + } + if let staged { + try? await Task.detached(priority: .utility) { + try staged.remove() + }.value + } + return nil + } catch { + if let operationID, + machineFileTransfers[machine.name]?.phase.isTerminal == false { + _ = try? await dorydClient.machineTransferCancel( + machine.name, + operationID: operationID + ) + } + if let staged { + try? await Task.detached(priority: .utility) { + try staged.remove() + }.value + } + actionError = "Could not send files to \(machine.name): \(Self.userFacingError(error))" + return nil + } + } + func syncMachineStats() { guard !machines.isEmpty else { return } for index in machines.indices { @@ -5345,19 +6326,105 @@ final class AppStore { func toggleMachine(_ machine: Machine) { guard requireDorydMachines() else { return } guard let idx = machines.firstIndex(where: { $0.id == machine.id }) else { return } - let wasRunning = machines[idx].status == .running + let previousState = machines[idx].status let name = machine.name busyMachines.insert(name) Task { defer { busyMachines.remove(name) } do { - if wasRunning { + switch previousState { + case .running: _ = try await dorydClient.machineStop(name) - } else { + case .paused: + _ = try await dorydClient.machineResume(name) + case .suspended: + _ = try await dorydClient.machineResume(name) + case .stopped: + try await refreshManagedDesktopKernelBeforeStart(name) _ = try await dorydClient.machineStart(name) } } catch { - actionError = "Could not \(wasRunning ? "stop" : "start") \(name): \(error)" + let action = switch previousState { + case .running: "stop" + case .paused: "resume" + case .suspended: "restore" + case .stopped: "start" + } + actionError = "Could not \(action) \(name): \(error)" + } + await refreshMachines() + } + } + + func pauseMachine(_ machine: Machine) { + guard requireDorydMachines(), machine.status == .running else { return } + guard !busyMachines.contains(machine.name) else { return } + busyMachines.insert(machine.name) + Task { + defer { busyMachines.remove(machine.name) } + do { + _ = try await dorydClient.machinePause(machine.name) + } catch { + actionError = "Could not pause \(machine.name): \(error)" + } + await refreshMachines() + } + } + + func suspendMachine(_ machine: Machine) { + guard requireDorydMachines(), machine.status == .running || machine.status == .paused else { + return + } + guard !busyMachines.contains(machine.name) else { return } + busyMachines.insert(machine.name) + Task { + defer { busyMachines.remove(machine.name) } + do { + _ = try await dorydClient.machineSuspend(machine.name) + } catch { + actionError = "Could not suspend \(machine.name): \(error)" + } + await refreshMachines() + } + } + + func restartMachine(_ machine: Machine) { + guard requireDorydMachines(), machine.status == .running || machine.status == .paused else { + return + } + guard !busyMachines.contains(machine.name) else { return } + busyMachines.insert(machine.name) + Task { + defer { busyMachines.remove(machine.name) } + do { + if machine.bootMode == .linuxKernel, + machine.displayMode == .desktop { + _ = try await dorydClient.machineStop(machine.name) + try await refreshManagedDesktopKernelBeforeStart(machine.name) + _ = try await dorydClient.machineStart(machine.name) + } else { + _ = try await dorydClient.machineRestart(machine.name) + } + } catch { + actionError = "Could not restart \(machine.name): \(error)" + } + await refreshMachines() + } + } + + func setMachineInstallerMedia(_ machine: Machine, attached: Bool) { + guard requireDorydMachines(), machine.bootMode == .efi else { return } + guard !busyMachines.contains(machine.name) else { return } + busyMachines.insert(machine.name) + Task { + defer { busyMachines.remove(machine.name) } + do { + _ = try await dorydClient.machineUpdate( + machine.name, + installerMediaAttached: attached + ) + } catch { + actionError = "Could not \(attached ? "attach" : "eject") the installer ISO for \(machine.name): \(error)" } await refreshMachines() } @@ -5384,15 +6451,6 @@ final class AppStore { return Int(UInt16(bigEndian: result.sin_port)) } - nonisolated static func mergingEnv(_ settings: MachineSettings, resolved: [String: String]) -> MachineSettings { - guard !resolved.isEmpty else { return settings } - var copy = settings - for (key, value) in resolved where copy.env[key] == nil && !value.isEmpty { - copy.env[key] = value - } - return copy - } - nonisolated static func desktopAssetEnvironment( processEnvironment: [String: String], distro: DesktopMachineDistro @@ -5402,9 +6460,181 @@ final class AppStore { return result } + /// Temporary compatibility projection until these fields move into native WorkspaceSpec + /// properties. New app-created machines never persist arbitrary environment values. + nonisolated static func sanitizedNewMachineEnvironment( + _ environment: [String: String] + ) -> [String: String] { + let allowed: Set = [ + "DORY_CLIPBOARD_POLICY", + "DORY_CUSTOM_LINUX", + "DORY_DESKTOP_DISTRO", + "DORY_DESKTOP_ENVIRONMENT", + "DORY_DESKTOP_GRAPHICS", + "DORY_DESKTOP_NAME", + "DORY_DESKTOP_VERSION", + "DORY_DESKTOP_VMM", + "DORY_GUEST_UID", + "DORY_GUEST_USER", + ] + return environment.filter { allowed.contains($0.key) } + } + + /// Brings persistent desktop machines forward after a signed desktop component activation. + /// The daemon preserves each guest's disk, applications, accounts, and settings; it owns the + /// last-good snapshot, reboot qualification, and automatic rollback transaction. + func updateManagedDesktops( + affectedBy components: Set, + operationID: UUID = UUID(), + force: Bool = false, + machineID: String? = nil + ) async throws -> [DorydDesktopUpdateResult] { + let statuses = try await dorydClient.machineList() + let desktopStatuses = statuses.filter { + $0.displayMode == .desktop && (machineID == nil || $0.id == machineID) + } + guard !desktopStatuses.isEmpty else { return [] } + + let runtimeChanged = components.contains(.linuxDesktop) + let store = try DoryComponentStore.selected() + guard let runtimeRelease = try store.installedComponent(.linuxDesktop) else { + throw DesktopMachineAssetError.missingAsset("runtime update") + } + _ = try store.verify(.linuxDesktop) + var results: [DorydDesktopUpdateResult] = [] + + for status in desktopStatuses { + // EFI machines are user-provided installer workspaces. Boot mode is authoritative; + // do not depend on the legacy DORY_CUSTOM_LINUX compatibility marker. + guard status.bootMode != .efi else { continue } + let typedSettings = status.typedSettings ?? DorydMachineTypedSettings( + legacyEnvironment: status.environment, + displayMode: status.displayMode + ) + let distro = DesktopMachineDistro.resolve( + Self.managedDesktopDistributionIdentifier( + configuredIdentifier: + typedSettings.guestIdentityIntent.desktop?.distributionIdentifier, + receipt: status.installedDesktopPayloadReceipt + ) + ) + guard runtimeChanged || components.contains(distro.componentID) else { continue } + guard let distroRelease = try store.installedComponent(distro.componentID) else { + if components.contains(distro.componentID) { + throw DesktopMachineAssetError.missingAsset(distro.displayName + " update") + } + // Removing a component intentionally preserves its machine disks. A runtime-only + // update cannot update that distro until the user reinstalls its signed payload. + continue + } + _ = try store.verify(distro.componentID) + let targetVersion = distroRelease.version + "+runtime." + runtimeRelease.version + let bundleAssetIdentifier = "dory-desktop-" + distro.rawValue + + "-update-arm64.tar" + let kernelAssetIdentifier = "dory-desktop-kernel-arm64.lzfse" + guard let bundleAsset = distroRelease.assets.first(where: { + $0.path == bundleAssetIdentifier + }), + let kernelAsset = runtimeRelease.assets.first(where: { + $0.path == kernelAssetIdentifier + }) else { + throw DesktopMachineAssetError.missingAsset(distro.displayName + " in-place update") + } + if !force && Self.desktopReceiptMatchesActiveComponents( + status.installedDesktopPayloadReceipt, + distributionIdentifier: distro.rawValue, + releaseVersion: targetVersion, + distributionComponentIdentifier: distro.componentID.rawValue, + distributionInstallationName: distroRelease.installationName, + distributionCatalogSHA256: distroRelease.catalogDigest, + bundleAssetIdentifier: bundleAsset.path, + bundleSHA256: bundleAsset.installedSHA256, + runtimeInstallationName: runtimeRelease.installationName, + runtimeCatalogSHA256: runtimeRelease.catalogDigest, + kernelAssetIdentifier: kernelAsset.path, + kernelSHA256: kernelAsset.installedSHA256 + ) { + continue + } + + guard !busyMachines.contains(status.id) else { + throw DesktopMachineAssetError.filesystem( + "\(status.id) is busy with another machine operation" + ) + } + busyMachines.insert(status.id) + defer { busyMachines.remove(status.id) } + do { + let result = try await dorydClient.machineDesktopUpdate( + status.id, + operationID: operationID, + distro: distro.rawValue, + version: targetVersion, + distributionInstallationName: distroRelease.installationName, + runtimeInstallationName: runtimeRelease.installationName + ) + results.append(result) + } catch { + throw DesktopMachineAssetError.filesystem( + "Could not update " + status.id + ". Dory restored its last-good snapshot. " + + String(describing: error) + ) + } + } + if !results.isEmpty { + _ = await refreshMachines() + } + return results + } + + /// Portable snapshots intentionally omit raw environment. Preserve an explicit typed guest + /// identity when one exists; otherwise use the validated installed-payload observation. + nonisolated static func managedDesktopDistributionIdentifier( + configuredIdentifier: String?, + receipt: DorydInstalledDesktopPayloadReceipt? + ) -> String? { + configuredIdentifier ?? (receipt?.isValid == true ? receipt?.distributionIdentifier : nil) + } + + nonisolated static func desktopReceiptMatchesActiveComponents( + _ receipt: DorydInstalledDesktopPayloadReceipt?, + distributionIdentifier: String, + releaseVersion: String, + distributionComponentIdentifier: String, + distributionInstallationName: String, + distributionCatalogSHA256: String, + bundleAssetIdentifier: String, + bundleSHA256: String, + runtimeInstallationName: String, + runtimeCatalogSHA256: String, + kernelAssetIdentifier: String, + kernelSHA256: String + ) -> Bool { + guard let receipt, receipt.isValid else { return false } + return receipt.provenance == "verified-update-bundle" + && receipt.distributionIdentifier == distributionIdentifier + && receipt.releaseVersion == releaseVersion + && receipt.distributionComponentIdentifier == distributionComponentIdentifier + && receipt.distributionInstallationName == distributionInstallationName + && receipt.distributionCatalogSHA256 == distributionCatalogSHA256.lowercased() + && receipt.bundleAssetIdentifier == bundleAssetIdentifier + && receipt.bundleSHA256 == bundleSHA256.lowercased() + && receipt.runtimeComponentIdentifier == DoryComponentID.linuxDesktop.rawValue + && receipt.runtimeInstallationName == runtimeInstallationName + && receipt.runtimeCatalogSHA256 == runtimeCatalogSHA256.lowercased() + && receipt.kernelAssetIdentifier == kernelAssetIdentifier + && receipt.kernelSHA256 == kernelSHA256.lowercased() + } + nonisolated static func preservingHiddenMachineSettings(_ settings: MachineSettings, existing: MachineSettings) -> MachineSettings { var copy = settings if copy.env.isEmpty { copy.env = existing.env } + if copy.virtualMachineSettings == nil { + copy.virtualMachineSettings = existing.virtualMachineSettings + } + if copy.displayPresentation == nil { + copy.displayPresentation = existing.displayPresentation + } if copy.identity == nil { copy.identity = existing.identity } if copy.address == nil { copy.address = existing.address } return copy @@ -5445,23 +6675,31 @@ final class AppStore { ) -> DorydMachineConfiguration? { let useBundledAssets = environment["DORYD_DISABLE_BUNDLED_MACHINE_ASSETS"] != "1" let arch = hostMachineAssetArch - let kernel = assets?.kernelPath - ?? firstMachinePath(["DORYD_MACHINE_KERNEL", "DORYD_GUEST_KERNEL"], environment: environment) - ?? installedMachinePath(["dory-hv-kernel-\(arch)", "dory-hv-kernel"]) - ?? (useBundledAssets ? bundledMachinePath(["dory-hv-kernel-\(arch)", "dory-hv-kernel"]) : nil) - let rootfs = assets?.rootfsPath - ?? firstMachinePath(["DORYD_MACHINE_ROOTFS", "DORYD_GUEST_ROOTFS"], environment: environment) - ?? installedMachinePath([ - "dory-machine-rootfs-\(arch).ext4", - "dory-machine-rootfs.ext4", - ]) - ?? (useBundledAssets ? bundledMachinePath([ - "dory-machine-rootfs-\(arch).ext4", - "dory-machine-rootfs.ext4", - "initfs-\(arch).ext4", - ]) : nil) - guard let kernel, let rootfs else { - return nil + let kernel: String + let rootfs: String + if settings.bootMode == .efi { + kernel = "" + rootfs = "" + } else { + guard let resolvedKernel = assets?.kernelPath + ?? firstMachinePath(["DORYD_MACHINE_KERNEL", "DORYD_GUEST_KERNEL"], environment: environment) + ?? installedMachinePath(["dory-hv-kernel-\(arch)", "dory-hv-kernel"]) + ?? (useBundledAssets ? bundledMachinePath(["dory-hv-kernel-\(arch)", "dory-hv-kernel"]) : nil), + let resolvedRootfs = assets?.rootfsPath + ?? firstMachinePath(["DORYD_MACHINE_ROOTFS", "DORYD_GUEST_ROOTFS"], environment: environment) + ?? installedMachinePath([ + "dory-machine-rootfs-\(arch).ext4", + "dory-machine-rootfs.ext4", + ]) + ?? (useBundledAssets ? bundledMachinePath([ + "dory-machine-rootfs-\(arch).ext4", + "dory-machine-rootfs.ext4", + "initfs-\(arch).ext4", + ]) : nil) else { + return nil + } + kernel = resolvedKernel + rootfs = resolvedRootfs } let memoryMB: UInt64 if let rawMemory = environment["DORYD_MACHINE_MEMORY_MB"] { @@ -5481,12 +6719,23 @@ final class AppStore { id: name, kernelPath: kernel, rootfsPath: rootfs, + bootMode: settings.bootMode, + installerISOPath: settings.installerISOPath, + diskSizeBytes: settings.diskSizeGB.flatMap { UInt64(exactly: $0) }.map { + $0 * 1024 * 1024 * 1024 + }, memoryMB: memoryMB, cpuCount: cpuCount, address: address, displayMode: settings.displayMode, shares: dorydShares(from: settings.mounts), - environment: settings.env + typedSettings: settings.virtualMachineSettings + ?? (settings.bootMode == .efi + ? DorydMachineTypedSettings() + : DorydMachineTypedSettings( + legacyEnvironment: sanitizedNewMachineEnvironment(settings.env), + displayMode: settings.displayMode + )) ) } @@ -5501,8 +6750,19 @@ final class AppStore { actionError = "Invalid machine name: use letters, digits, and _ . - (must start alphanumeric)" return "Invalid machine name" } - if settings.displayMode == .desktop { - let distro = DesktopMachineDistro.resolve(settings.env["DORY_DESKTOP_DISTRO"]) + if settings.bootMode == .efi { + guard settings.displayMode == .desktop, + let installerISOPath = settings.installerISOPath, + FileManager.default.fileExists(atPath: installerISOPath) else { + let message = "Choose a readable Linux installer ISO before creating the VM." + actionError = message + return message + } + } else if settings.displayMode == .desktop { + let distro = DesktopMachineDistro.resolve( + settings.virtualMachineSettings?.guestIdentityIntent.desktop? + .distributionIdentifier ?? settings.env["DORY_DESKTOP_DISTRO"] + ) guard AppInfo.componentAvailable(.linuxDesktop), AppInfo.componentAvailable(distro.componentID) else { let message = "Install the Linux Desktop runtime and \(distro.displayName) in Components first." @@ -5517,9 +6777,7 @@ final class AppStore { return message } guard requireDorydMachines() else { return actionError } - let resolvedEnv = await machineEnvResolver(machineEnvAllowList) - let effectiveSettings = Self.mergingEnv(settings, resolved: resolvedEnv) - return await createDorydMachine(name: trimmedName, settings: effectiveSettings, recipe: recipe) + return await createDorydMachine(name: trimmedName, settings: settings, recipe: recipe) } nonisolated static func dorydRecipeID(for recipe: DevRecipe) -> String? { @@ -5534,6 +6792,7 @@ final class AppStore { } private func createDorydMachine(name: String, settings: MachineSettings, recipe: DevRecipe?) async -> String? { + var settings = settings let address = Self.trimmedNonEmpty(settings.address) let provisioningRecipe: String? if let recipe { @@ -5555,11 +6814,45 @@ final class AppStore { activeSheet = .creatingMachine defer { busyMachines.remove(name) } + var stagedInstallerISOPath: String? + defer { + if let stagedInstallerISOPath { + try? FileManager.default.removeItem(atPath: stagedInstallerISOPath) + } + } + var createdDefinition = false do { + if settings.bootMode == .efi, let installerISOPath = settings.installerISOPath { + let sourceURL = URL(fileURLWithPath: installerISOPath) + let hasSecurityScope = sourceURL.startAccessingSecurityScopedResource() + defer { + if hasSecurityScope { sourceURL.stopAccessingSecurityScopedResource() } + } + appendMachineCreationLog("Checking the selected installer's EFI architecture…") + let staged = try DoryInstallerISOStager.stage(atPath: installerISOPath) + if staged.architecture == .unknown { + appendMachineCreationLog("The EFI architecture is non-standard; continuing as a custom image.") + } else { + appendMachineCreationLog("\(staged.architecture.rawValue) EFI architecture confirmed.") + } + appendMachineCreationLog("Media SHA-256: \(staged.sha256)") + if case .unqualified = staged.runtimeQualification { + appendMachineCreationLog("This exact installer/host combination is not yet runtime-qualified by Dory.") + } + appendMachineCreationLog("Installer media is staged for the VM service.") + stagedInstallerISOPath = staged.path + settings.installerISOPath = staged.path + } let desktopAssets: DesktopMachineAssets? - if settings.displayMode == .desktop { - let distro = DesktopMachineDistro.resolve(settings.env["DORY_DESKTOP_DISTRO"]) + if settings.bootMode == .efi { + appendMachineCreationLog("Importing the installer ISO and creating a thin-provisioned virtual disk…") + desktopAssets = nil + } else if settings.displayMode == .desktop { + let distro = DesktopMachineDistro.resolve( + settings.virtualMachineSettings?.guestIdentityIntent.desktop? + .distributionIdentifier ?? settings.env["DORY_DESKTOP_DISTRO"] + ) appendMachineCreationLog("Preparing \(distro.displayName) \(distro.version) Desktop in the selected Dory data drive…") let home = environment["HOME"] ?? NSHomeDirectory() let resourceDirectory = Bundle.main.resourcePath @@ -5583,6 +6876,12 @@ final class AppStore { } _ = try await dorydClient.machineCreate(config) createdDefinition = true + if let displayPresentation = settings.displayPresentation { + _ = try await dorydClient.machineDisplayPresentationSet( + name, + presentation: displayPresentation + ) + } appendMachineCreationLog("Definition written. Booting VM…") _ = try await dorydClient.machineStart(name) if let recipe, let provisioningRecipe { @@ -5595,7 +6894,13 @@ final class AppStore { appendMachineCreationLog("Provisioned \(result.recipeID): \(verify)") } } - appendMachineCreationLog("Machine created and started.") + if settings.bootMode == .efi { + appendMachineCreationLog( + "VM and display started. Complete Linux setup in the desktop window." + ) + } else { + appendMachineCreationLog("Machine created and started.") + } let refreshed = await refreshMachines() machineCreated = refreshed.first { $0.name == name } if machineCreated == nil { @@ -5650,22 +6955,58 @@ final class AppStore { memoryMB: current?.memoryMB.flatMap { Int(exactly: $0) }, mounts: current?.shares.map(Self.mountPair(fromDoryd:)) ?? [], env: current?.environment ?? [:], + virtualMachineSettings: current.map { + $0.typedSettings ?? DorydMachineTypedSettings( + legacyEnvironment: $0.environment, + displayMode: $0.displayMode + ) + }, + displayPresentation: current?.displayPresentation, address: current?.configuredAddress, - displayMode: current?.displayMode ?? machine.displayMode + displayMode: current?.displayMode ?? machine.displayMode, + bootMode: current?.bootMode ?? machine.bootMode ) let effectiveSettings = Self.preservingHiddenMachineSettings(settings, existing: currentSettings) + let baselineTypedSettings = currentSettings.virtualMachineSettings + ?? DorydMachineTypedSettings() + let desiredTypedSettings = effectiveSettings.virtualMachineSettings + ?? baselineTypedSettings let memory = effectiveSettings.memoryMB.flatMap { UInt64(exactly: $0) } ?? current?.memoryMB let cpus = effectiveSettings.cpus ?? current?.cpuCount let address = Self.trimmedNonEmpty(effectiveSettings.address) - _ = try await dorydClient.machineUpdate( - machine.name, - memoryMB: memory, - cpuCount: cpus, - address: address, - updatesAddress: true, - shares: Self.dorydShares(from: effectiveSettings.mounts), - environment: effectiveSettings.env - ) + let desiredPresentation = effectiveSettings.displayPresentation + ?? currentSettings.displayPresentation + ?? .windowed + let previousPresentation = currentSettings.displayPresentation ?? .windowed + let presentationChanged = desiredPresentation != previousPresentation + if presentationChanged { + _ = try await dorydClient.machineDisplayPresentationSet( + machine.name, + presentation: desiredPresentation + ) + } + do { + _ = try await dorydClient.machineUpdate( + machine.name, + memoryMB: memory, + cpuCount: cpus, + address: address, + updatesAddress: true, + shares: Self.dorydShares(from: effectiveSettings.mounts), + typedSettingsPatch: DorydMachineTypedSettingsPatch( + baseline: baselineTypedSettings, + desired: desiredTypedSettings + ) + ) + } catch { + if presentationChanged { + _ = try? await dorydClient.machineDisplayPresentationSet( + machine.name, + presentation: previousPresentation + ) + } + throw error + } appendMachineCreationLog("Settings applied to doryd VM definition.") activeSheet = nil await refreshMachines() @@ -5848,7 +7189,11 @@ final class AppStore { version: "disk", arch: snapshot.architecture, boot: "vz", - recipe: "doryd" + recipe: "doryd", + runtimeIdentity: snapshot.runtimeIdentity, + artifactEvidence: snapshot.artifactEvidence, + consistency: snapshot.consistency, + guestQuiesceReceipt: snapshot.guestQuiesceReceipt ) } @@ -6068,7 +7413,47 @@ final class AppStore { Task { defer { busyMachines.remove(Self.importBusyKey) } do { - let snapshot = try await dorydClient.machineImportSnapshot(from: url.path) + let assessment = try await dorydClient.machineAssessSnapshotImport( + from: url.path + ) + appendMachineCreationLog( + "Verified complete archive \(assessment.contentID.prefix(12))… " + + "(\(assessment.architecture), ABI " + + "\(assessment.virtualHardwareABIVersion))." + ) + appendMachineCreationLog( + "Portability: \(assessment.disposition.rawValue)." + ) + let unavailableComponents = assessment.components.filter { + $0.availability != .available + } + if !unavailableComponents.isEmpty { + appendMachineCreationLog( + "Required components: " + unavailableComponents.map { + "\($0.componentIdentifier)@\($0.buildIdentifier) " + + "(\($0.availability.rawValue))" + }.joined(separator: ", ") + ) + } + switch assessment.disposition { + case .ready, .requiresReplanning: + break + case .requiresComponents: + throw DorydClientError.daemon( + "Import requires unavailable or different Dory components: " + + unavailableComponents.map(\.componentIdentifier) + .joined(separator: ", ") + ) + case .unavailable: + throw DorydClientError.daemon( + "This archive is not portable to the current host (" + + assessment.issues.joined(separator: ", ") + ")." + ) + } + let snapshot = try await dorydClient.machineImportSnapshot( + from: url.path, + expectedContentID: assessment.contentID + ) appendMachineCreationLog("Verified snapshot \(snapshot.id).") let newName: String do { @@ -6232,7 +7617,7 @@ final class AppStore { let home = machine.username == "root" ? "/root" : "/Users/\(machine.username)" let family = MachineDistro.all.first { $0.display == machine.distro }?.family let machineShell = runtimeOwnedByDoryd - ? HostTools.userFacingDoryCommand().map { _ in MachineShellTarget(machineID: machine.name) } + ? userFacingDoryCommandResolver().map { _ in MachineShellTarget(machineID: machine.name) } : nil let sessionID = runtimeOwnedByDoryd ? "machine:\(machine.name)" : "machine:\(machine.containerID)" return TerminalSession(id: sessionID, title: machine.name, diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index 50c25759..e3cd570e 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -1,3 +1,4 @@ +import DoryOperations import SwiftUI enum AppSection: String, CaseIterable, Identifiable, Sendable { @@ -13,7 +14,7 @@ enum AppSection: String, CaseIterable, Identifiable, Sendable { case .compose: "Compose" case .builds: "Build Activity" case .kubernetes: "Kubernetes" - case .desktops: "Linux Desktops" + case .desktops: "Desktops" case .machines: "Linux Servers" case .components: "Components" case .health: "Health" @@ -40,12 +41,13 @@ enum AppSection: String, CaseIterable, Identifiable, Sendable { } enum RunState: String, Sendable { - case running, paused, stopped + case running, paused, suspended, stopped var label: String { switch self { case .running: "Running" case .paused: "Paused" + case .suspended: "Suspended" case .stopped: "Stopped" } } @@ -54,6 +56,7 @@ enum RunState: String, Sendable { switch self { case .running: p.green case .paused: p.amber + case .suspended: p.accent case .stopped: p.text3 } } @@ -62,6 +65,7 @@ enum RunState: String, Sendable { switch self { case .running: p.greenWeak case .paused: p.amberWeak + case .suspended: p.accentSoft case .stopped: p.pill } } @@ -298,13 +302,320 @@ struct Machine: Identifiable, Hashable, Sendable { var sshPort: Int? = nil var shellSocketPath: String = "" var processID: Int32? = nil + var failure: DorydMachineFailure? = nil + var activeOperation: DorydMachineOperationSummary? = nil + var flightRecorderHeadSequence: UInt64 = 0 + var flightRecorderAvailable: Bool = false var displayMode: MachineDisplayMode = .headless + var bootMode: MachineBootMode = .linuxKernel + var installerMediaAttached: Bool = false + var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility + var runtimeGraphicsSelection: DorydMachineRuntimeGraphicsSelection? = nil + var cloneReceipt: DorydMachineCloneReceipt? = nil + var agentBuild: String? = nil + var agentProtocolVersion: UInt32? = nil + var agentCapabilities: [DorydAgentCapability] = [] + var integrationHealth: DoryGuestIntegrationHealth? = nil + var fileTransferPolicy: DoryVMClipboardDirection = .bidirectional var mounts: [MountPair] = [] var id: String { name } var badgeColor: Color { Color(hex: badgeHex) } var actionLabel: String { status == .running ? "Stop" : "Start" } var isEmulated: Bool { !arch.isEmpty && arch != MachineArch.host.rawValue } + + var runtimeEvidence: [MachineRuntimeEvidence] { + var evidence: [MachineRuntimeEvidence] = [] + if let failure { + evidence.append(MachineRuntimeEvidence( + id: "failure", + label: Self.failureLabel(failure.code), + systemImage: "exclamationmark.octagon.fill", + tone: .warning, + detail: Self.failureDetail(failure) + )) + } else if let activeOperation { + evidence.append(MachineRuntimeEvidence( + id: "operation", + label: activeOperation.kind.rawValue.capitalized, + systemImage: "arrow.triangle.2.circlepath", + tone: .standard, + detail: "Operation \(activeOperation.operationID.prefix(8))…" + )) + } + if recipe == "doryd" { + if !flightRecorderAvailable { + evidence.append(MachineRuntimeEvidence( + id: "flight-recorder", + label: "Recorder unavailable", + systemImage: "waveform.path.ecg.rectangle", + tone: .warning, + detail: "Durable workspace diagnostics need repair" + )) + } else if flightRecorderHeadSequence > 0 { + evidence.append(MachineRuntimeEvidence( + id: "flight-recorder", + label: "Flight recorder", + systemImage: "waveform.path.ecg.rectangle", + tone: .standard, + detail: "Durable through event \(flightRecorderHeadSequence)" + )) + } + } + if let cloneReceipt { + evidence.append(MachineRuntimeEvidence( + id: "clone-storage", + label: "Copy-on-write clone", + systemImage: "square.on.square", + tone: .standard, + detail: "APFS-managed from \(cloneReceipt.sourceMachineID)/\(cloneReceipt.sourceSnapshotID)" + )) + } + switch runtimeIdentity.mode { + case "resolved-plan": + evidence.append(MachineRuntimeEvidence( + id: "authority", + label: runtimeIdentity.supportTier == "supported" ? "Supported" : "Preview", + systemImage: runtimeIdentity.supportTier == "supported" + ? "checkmark.seal.fill" : "exclamationmark.triangle.fill", + tone: runtimeIdentity.supportTier == "supported" ? .positive : .warning, + detail: runtimeIdentity.runtimeQualification?.qualificationIdentity + ?? "Resolved plan \(runtimeIdentity.planRevision ?? 0)" + )) + if let backend = runtimeIdentity.backend { + evidence.append(MachineRuntimeEvidence( + id: "backend", + label: Self.backendLabel(backend), + systemImage: "cpu", + tone: .standard, + detail: runtimeIdentity.backendRuntimeBuildIdentifier ?? backend + )) + } + if displayMode == .desktop { + if status == .running, let runtimeGraphicsSelection { + evidence.append(MachineRuntimeEvidence( + id: "graphics", + label: Self.graphicsLabel(runtimeGraphicsSelection.accelerationLevel), + systemImage: "display", + tone: runtimeGraphicsSelection.isQualifiedAcceleration + ? .positive : .standard, + detail: runtimeGraphicsSelection.isQualifiedAcceleration + ? "Live renderer generation \(runtimeGraphicsSelection.rendererGeneration ?? 0)" + : "Live operation-bound software selection" + )) + } else if status == .running, + runtimeIdentity.backend == "apple-virtualization-framework", + runtimeIdentity.graphics == "software" { + evidence.append(MachineRuntimeEvidence( + id: "graphics", + label: "Software graphics", + systemImage: "display", + tone: .standard, + detail: "Plan-bound Virtualization.framework display" + )) + } else if status == .running { + evidence.append(MachineRuntimeEvidence( + id: "graphics", + label: "Graphics unverified", + systemImage: "exclamationmark.triangle.fill", + tone: .warning, + detail: "The running helper did not prove its live graphics selection" + )) + } else { + evidence.append(MachineRuntimeEvidence( + id: "graphics", + label: "Planned \(Self.graphicsLabel(runtimeIdentity.graphics))", + systemImage: "display", + tone: .standard, + detail: runtimeIdentity.graphicsQualification?.manifestIdentity + ?? "No live graphics selection while stopped" + )) + } + } + if runtimeIdentity.selectionDisposition == "approved-fallback" { + evidence.append(MachineRuntimeEvidence( + id: "fallback", + label: "Approved fallback", + systemImage: "arrow.triangle.branch", + tone: .warning, + detail: runtimeIdentity.fallbackAuthorizationIdentity ?? "Approved alternative" + )) + } + case "requires-replanning": + evidence.append(MachineRuntimeEvidence( + id: "authority", + label: "Needs planning", + systemImage: "exclamationmark.triangle.fill", + tone: .warning, + detail: runtimeIdentity.invalidationReason ?? "No current launch plan" + )) + default: + evidence.append(MachineRuntimeEvidence( + id: "authority", + label: "Compatibility", + systemImage: "arrow.triangle.2.circlepath", + tone: .standard, + detail: "Legacy compatibility launch authority" + )) + } + evidence.append(toolsRuntimeEvidence) + return evidence + } + + private static func failureLabel(_ code: DorydMachineFailureCode) -> String { + switch code { + case .lifecycleOperationFailed: "Operation failed" + case .lifecycleRecoveryRequired: "Recovery required" + case .workspaceAuthorityInvalid: "Planning required" + case .backendLaunchFailed: "Backend launch failed" + case .readinessHandoffFailed: "Readiness failed" + case .readinessTimedOut: "Readiness timed out" + case .helperExited: "VM helper exited" + case .savedStateInvalid: "Saved state invalid" + case .resourceAdmissionRejected: "Resources changed" + case .desktopUpdateRecoveryRequired: "Update recovery required" + case .desktopUpdateRolledBack: "Update rolled back" + case .deletionFailed: "Deletion failed" + case .diagnosticPersistenceFailed: "Diagnostics unavailable" + case .unclassified: "Machine failure" + } + } + + private static func failureDetail(_ failure: DorydMachineFailure) -> String { + let recovery: String + switch failure.recoveryDisposition { + case .retry: recovery = "Retry the operation" + case .replan: recovery = "Replan this workspace" + case .repair: recovery = "Run repair and review diagnostics" + case .rollbackCompleted: recovery = "Rollback completed" + case .deleteWorkspace: recovery = "Delete and recreate the workspace" + case .inspectDiagnostics: recovery = "Review diagnostics" + } + if let operationID = failure.operationID { + return "\(recovery) · operation \(operationID.prefix(8))…" + } + return recovery + } + + var integrationHealthProjection: DoryGuestIntegrationHealth { + if let integrationHealth, integrationHealth.isValid { + return integrationHealth + } + let authority: DoryGuestIntegrationRuntimeAuthority + switch runtimeIdentity.mode { + case "resolved-plan": authority = .resolvedPlan + case "requires-replanning": authority = .requiresReplanning + default: authority = .legacyCompatibility + } + return DoryGuestIntegrationHealth.evaluate( + machineIsRunning: status == .running, + runtimeAuthority: authority, + desktopIntegrationsExpected: displayMode == .desktop, + clipboardTextExpected: displayMode == .desktop, + clipboardImageExpected: displayMode == .desktop, + sharedFoldersExpected: !mounts.isEmpty, + // Older daemons did not expose the plan's exact device contract. The compatibility + // projection therefore stays fail-closed instead of inferring host integrations. + qualifiedRuntimeFeatures: [], + agentBuild: agentBuild, + agentProtocolVersion: agentProtocolVersion, + agentCapabilities: agentCapabilities.map { + DoryGuestIntegrationNegotiatedCapability(id: $0.id, version: $0.version) + } + ) + } + + private var toolsRuntimeEvidence: MachineRuntimeEvidence { + let health = integrationHealthProjection + switch health.state { + case .inactive: + return MachineRuntimeEvidence( + id: "tools", + label: "Tools inactive", + systemImage: "wrench.and.screwdriver", + tone: .standard, + detail: "Integration checks resume when the workspace is running" + ) + case .missingTools: + return MachineRuntimeEvidence( + id: "tools", + label: "Tools unavailable", + systemImage: "wrench.and.screwdriver", + tone: .warning, + detail: "The guest has not reported a valid Dory Tools handshake" + ) + case .incompatible: + return MachineRuntimeEvidence( + id: "tools", + label: "Tools incompatible", + systemImage: "exclamationmark.triangle.fill", + tone: .warning, + detail: "\(health.agentBuild ?? "Dory Tools") uses unsupported protocol \(health.agentProtocolVersion ?? 0)" + ) + case .degraded: + let unavailable = health.features + .filter { $0.required && $0.state != .active } + .map(\.id.rawValue) + return MachineRuntimeEvidence( + id: "tools", + label: "Tools partially ready", + systemImage: "arrow.triangle.2.circlepath", + tone: .warning, + detail: unavailable.isEmpty + ? "The workspace needs a current resolved runtime plan" + : "Unavailable: \(unavailable.joined(separator: ", "))" + ) + case .compatibility: + return MachineRuntimeEvidence( + id: "tools", + label: "Tools compatibility", + systemImage: "wrench.and.screwdriver", + tone: .standard, + detail: "\(health.agentBuild ?? "Dory Tools") · guest capabilities negotiated; runtime integrations unqualified" + ) + case .healthy: + return MachineRuntimeEvidence( + id: "tools", + label: "Tools ready", + systemImage: "wrench.and.screwdriver.fill", + tone: .positive, + detail: "\(health.agentBuild ?? "Dory Tools") · \(health.features.filter { $0.state == .active }.count) active integrations" + ) + } + } + + private static func backendLabel(_ backend: String) -> String { + switch backend { + case "dory-hypervisor": "Raw HV" + case "apple-virtualization-framework": "Virtualization.framework" + case "qemu-hvf": "QEMU/HVF" + default: backend + } + } + + private static func graphicsLabel(_ graphics: String?) -> String { + switch graphics { + case "hardware-accelerated-3d": "Qualified 3D" + case "host-accelerated-display": "Accelerated display" + case "software": "Software graphics" + case "none": "No graphics" + default: "Graphics unknown" + } + } +} + +enum MachineRuntimeEvidenceTone: Hashable, Sendable { + case standard + case positive + case warning +} + +struct MachineRuntimeEvidence: Identifiable, Hashable, Sendable { + var id: String + var label: String + var systemImage: String + var tone: MachineRuntimeEvidenceTone + var detail: String } enum LogLevel: String, Sendable { diff --git a/Dory/Net/UsbAttachmentStore.swift b/Dory/Net/UsbAttachmentStore.swift index 67b061e2..d306efc8 100644 --- a/Dory/Net/UsbAttachmentStore.swift +++ b/Dory/Net/UsbAttachmentStore.swift @@ -1,10 +1,33 @@ import Foundation enum UsbPassthroughAvailability: Sendable { - static let attachSupported = false - static let unavailableReason = - "USB passthrough is not available in the current Dory engine. Host discovery works, but " + - "attach, detach, and automatic replay remain disabled until the guest USB/IP RPC ships." + static let automaticReplaySupported = false + + static func attachSupported(for status: DorydMachineStatus?) -> Bool { + guard let status else { return false } + return status.state == "running" + && status.runtimeIdentity.backend == "dory-hypervisor" + && status.runtimeIdentity.authorizesRemovableUSBHotplug + } + + static func unavailableReason(for status: DorydMachineStatus?) -> String { + guard let status else { + return "Select a running machine with signed removable-USB authorization." + } + guard status.state == "running" else { + return "Start this machine before attaching a host USB device." + } + guard status.runtimeIdentity.mode == "resolved-plan" else { + return "This machine has no launch-authorizing resolved plan. USB attachment fails closed." + } + guard status.runtimeIdentity.backend == "dory-hypervisor" else { + return "USB attachment requires the resolved raw-hypervisor backend." + } + guard status.runtimeIdentity.authorizesRemovableUSBHotplug else { + return "This machine's signed plan does not authorize removable USB hotplug." + } + return "USB attachment is available for this machine." + } } struct UsbAttachment: Codable, Equatable, Identifiable, Sendable { diff --git a/Dory/Resources/SandboxTemplates/coding-agent.json b/Dory/Resources/SandboxTemplates/coding-agent.json new file mode 100644 index 00000000..996272f1 --- /dev/null +++ b/Dory/Resources/SandboxTemplates/coding-agent.json @@ -0,0 +1,22 @@ +{ + "schema": "dev.dory.sandbox.template", + "version": 1, + "id": "coding-agent", + "displayName": "Coding Agent", + "summary": "A secure general-purpose coding workspace with Dory's core agent tools.", + "profile": "core", + "tools": [], + "networkPolicy": "none", + "allowNetwork": [], + "mounts": [], + "limits": { + "cpus": 4, + "memoryMB": 4096, + "diskMB": 512, + "processes": 512, + "openFiles": 4096, + "wallTimeSeconds": 600 + }, + "ttlSeconds": 0, + "sshAgent": false +} diff --git a/Dory/Resources/SandboxTemplates/polyglot-agent.json b/Dory/Resources/SandboxTemplates/polyglot-agent.json new file mode 100644 index 00000000..e0717669 --- /dev/null +++ b/Dory/Resources/SandboxTemplates/polyglot-agent.json @@ -0,0 +1,22 @@ +{ + "schema": "dev.dory.sandbox.template", + "version": 1, + "id": "polyglot-agent", + "displayName": "Polyglot Coding Agent", + "summary": "A broad coding workspace with Dory's built-in language and DevOps toolchains.", + "profile": "polyglot", + "tools": [], + "networkPolicy": "none", + "allowNetwork": [], + "mounts": [], + "limits": { + "cpus": 6, + "memoryMB": 8192, + "diskMB": 640, + "processes": 1024, + "openFiles": 8192, + "wallTimeSeconds": 600 + }, + "ttlSeconds": 0, + "sshAgent": false +} diff --git a/Dory/Runtime/ContainerRuntime.swift b/Dory/Runtime/ContainerRuntime.swift index 376165f7..05971982 100644 --- a/Dory/Runtime/ContainerRuntime.swift +++ b/Dory/Runtime/ContainerRuntime.swift @@ -242,6 +242,18 @@ enum RuntimeFeatureError: Error, Sendable, Equatable, CustomStringConvertible { } } +/// Authoritative target-filesystem usage used by migration capacity admission. +/// +/// Docker's `/system/df` describes Docker objects, but Dory owns a dedicated ext4 data disk and +/// can measure the filesystem itself through its guest agent. Keeping the capability on the target +/// runtime binds that measurement to the same runtime whose Docker authority and inventory are +/// re-read immediately before migration staging. +nonisolated struct MigrationTargetStorageUsage: Sendable, Equatable { + let totalBytes: Int64 + let usedBytes: Int64 + let availableBytes: Int64 +} + protocol ContainerRuntime: Sendable { var kind: RuntimeKind { get } /// Stable identity for resumable migration ownership. `kind` alone is not enough because @@ -255,6 +267,10 @@ protocol ContainerRuntime: Sendable { /// created outside named volumes are not silently lost. Created containers may report no size /// and are normalized to zero by Docker backends; every other omission fails closed. func migrationContainerWritableSizes() async throws -> [String: Int64] + /// Returns a target-owned filesystem measurement when the runtime can prove one. `nil` means + /// the runtime has no stronger source than Docker's `/system/df`; an error means an advertised + /// authoritative source failed and must never be silently downgraded to the Docker estimate. + func migrationTargetStorageUsage() async throws -> MigrationTargetStorageUsage? func start(containerID: String) async throws func stop(containerID: String) async throws func restart(containerID: String) async throws @@ -343,6 +359,7 @@ extension ContainerRuntime { let snapshot = try await migrationSnapshot() return Dictionary(uniqueKeysWithValues: snapshot.containers.map { ($0.id, 0) }) } + func migrationTargetStorageUsage() async throws -> MigrationTargetStorageUsage? { nil } func pull(image: String, registryAuth: String?) async throws {} func pull(image: String) async throws { try await pull(image: image, registryAuth: nil) } func kill(containerID: String, signal: String?) async throws { diff --git a/Dory/Runtime/Docker/DockerDiskUsageParser.swift b/Dory/Runtime/Docker/DockerDiskUsageParser.swift index cfa38306..7d246c4a 100644 --- a/Dory/Runtime/Docker/DockerDiskUsageParser.swift +++ b/Dory/Runtime/Docker/DockerDiskUsageParser.swift @@ -42,42 +42,48 @@ nonisolated enum DockerDiskUsageParser { guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw DockerDiskUsageParserError.invalidJSON } - let aggregateKeys = ["ImageUsage", "VolumeUsage", "ContainerUsage", "BuildCacheUsage"] - let aggregateValues = try aggregateKeys.compactMap { key -> Int64? in - guard let value = root[key] else { return nil } - guard let usage = value as? [String: Any] else { - throw DockerDiskUsageParserError.invalidTotalUsage("\(key) must be an object") - } - // Moby omits every zero-valued field, so an empty usage object is an exact zero. - if usage.isEmpty { return 0 } - guard let total = exactNonnegativeInteger(usage["TotalSize"]) else { - throw DockerDiskUsageParserError.invalidTotalUsage("\(key).TotalSize is invalid") - } - return total - } - if aggregateValues.count == aggregateKeys.count { - return try sum(aggregateValues, field: "aggregate usage") - } - if aggregateKeys.contains(where: { root[$0] != nil }) { - throw DockerDiskUsageParserError.invalidTotalUsage("incomplete aggregate usage") - } - let layers = exactNonnegativeInteger(root["LayersSize"]) - ?? (explicitlyEmptyItems(root["Images"]) ? 0 : nil) - guard let layers, - let volumes = try usageItems(root["Volumes"], field: "Volumes", size: volumeSize), - let containers = try usageItems( - root["Containers"], - field: "Containers", - size: containerSize - ), - let buildCache = try usageItems( - root["BuildCache"], - field: "BuildCache", - size: directSize - ) else { + let images = try reconciledUsage( + aggregateUsageValues( + root: root, + keys: ["ImageUsage", "ImagesUsage"], + itemSize: nil + ) + legacyImageUsageValues(root), + field: "image" + ) + let volumes = try reconciledUsage( + aggregateUsageValues( + root: root, + keys: ["VolumeUsage", "VolumesUsage"], + itemSize: volumeSize + ) + optionalValue( + try usageItems(root["Volumes"], field: "Volumes", size: volumeSize) + ), + field: "volume" + ) + let containers = try reconciledUsage( + aggregateUsageValues( + root: root, + keys: ["ContainerUsage", "ContainersUsage"], + itemSize: containerSize + ) + optionalValue( + try usageItems(root["Containers"], field: "Containers", size: containerSize) + ), + field: "container" + ) + let buildCache = try reconciledUsage( + aggregateUsageValues( + root: root, + keys: ["BuildCacheUsage"], + itemSize: directSize + ) + optionalValue( + try usageItems(root["BuildCache"], field: "BuildCache", size: directSize) + ), + field: "build-cache" + ) + guard let images, let volumes, let containers, let buildCache else { throw DockerDiskUsageParserError.missingTotalUsage } - return try sum([layers, volumes, containers, buildCache], field: "legacy usage") + return try sum([images, volumes, containers, buildCache], field: "total usage") } private static func legacyInventory(_ value: Any?) throws -> [String: Int64]? { @@ -98,6 +104,30 @@ nonisolated enum DockerDiskUsageParser { guard let items = itemsValue as? [Any] else { throw DockerDiskUsageParserError.invalidVolumeUsage("\(key).Items must be an array or null") } + let totalCount: Int64 + if let rawTotalCount = usage["TotalCount"] { + guard let decoded = exactNonnegativeInteger(rawTotalCount) else { + throw DockerDiskUsageParserError.invalidVolumeUsage( + "\(key).TotalCount is invalid" + ) + } + totalCount = decoded + } else { + totalCount = 0 + } + guard totalCount == Int64(items.count) else { + throw DockerDiskUsageParserError.invalidVolumeUsage( + "\(key).Items count does not match TotalCount" + ) + } + if let rawActiveCount = usage["ActiveCount"] { + guard let activeCount = exactNonnegativeInteger(rawActiveCount), + activeCount <= totalCount else { + throw DockerDiskUsageParserError.invalidVolumeUsage( + "\(key).ActiveCount is invalid" + ) + } + } return try parse(items: items, field: "\(key).Items") } @@ -138,10 +168,179 @@ nonisolated enum DockerDiskUsageParser { return try sum(sizes, field: field) } - private static func explicitlyEmptyItems(_ value: Any?) -> Bool { - guard let value else { return false } - if value is NSNull { return true } - return (value as? [Any])?.isEmpty == true + private static func aggregateUsageValues( + root: [String: Any], + keys: [String], + itemSize: (([String: Any]) -> Int64?)? + ) throws -> [Int64] { + let allowedKeys: Set = [ + "ActiveCount", "TotalCount", "Reclaimable", "TotalSize", "Items", + ] + var results: [Int64] = [] + for key in keys { + guard let value = root[key] else { continue } + guard let usage = value as? [String: Any] else { + throw DockerDiskUsageParserError.invalidTotalUsage("\(key) must be an object") + } + let unknownKeys = Set(usage.keys).subtracting(allowedKeys) + guard unknownKeys.isEmpty else { + throw DockerDiskUsageParserError.invalidTotalUsage( + "\(key) contains unknown fields" + ) + } + + var evidence: [Int64] = [] + // An empty aggregate is Moby's canonical encoding for an exact all-zero record. + // When the object is nonempty but TotalSize is omitted, require evidence that could + // only describe a real zero-byte category: a nonzero object count or an Items list. + // Zero-valued scalar fields are themselves omitted by Moby, so accepting a lone + // `TotalCount: 0` (or equivalent) would widen the contract to a noncanonical partial + // record and make a malformed response indistinguishable from exact capacity data. + var exactOmittedZeroEvidence = usage.isEmpty + var activeCount: Int64 = 0 + var totalCount: Int64 = 0 + var reclaimable: Int64 = 0 + for (field, destination) in [ + ("ActiveCount", 0), + ("TotalCount", 1), + ("Reclaimable", 2), + ] { + guard let raw = usage[field] else { continue } + guard let decoded = exactNonnegativeInteger(raw) else { + throw DockerDiskUsageParserError.invalidTotalUsage( + "\(key).\(field) is invalid" + ) + } + switch destination { + case 0: + activeCount = decoded + exactOmittedZeroEvidence = exactOmittedZeroEvidence || decoded > 0 + case 1: + totalCount = decoded + exactOmittedZeroEvidence = exactOmittedZeroEvidence || decoded > 0 + default: reclaimable = decoded + } + } + guard activeCount <= totalCount else { + throw DockerDiskUsageParserError.invalidTotalUsage( + "\(key) has more active objects than total objects" + ) + } + let aggregateTotal: Int64 + if let totalValue = usage["TotalSize"] { + guard let total = exactNonnegativeInteger(totalValue) else { + throw DockerDiskUsageParserError.invalidTotalUsage("\(key).TotalSize is invalid") + } + aggregateTotal = total + evidence.append(total) + } else { + // Moby declares TotalSize with `omitempty`. A real aggregate may therefore contain + // non-zero object counts while omitting TotalSize when every object consumes zero + // bytes. Infer zero only after recognizing an otherwise exact aggregate record; + // an object containing only unknown data is not storage evidence. + aggregateTotal = 0 + } + guard reclaimable <= aggregateTotal else { + throw DockerDiskUsageParserError.invalidTotalUsage( + "\(key).Reclaimable exceeds TotalSize" + ) + } + if let itemsValue = usage["Items"] { + if itemsValue is NSNull { + guard usage["TotalSize"] != nil else { + throw DockerDiskUsageParserError.invalidTotalUsage( + "\(key).Items is null without an exact total" + ) + } + } else if let items = itemsValue as? [Any] { + guard totalCount == Int64(items.count) else { + throw DockerDiskUsageParserError.invalidTotalUsage( + "\(key).Items count does not match TotalCount" + ) + } + exactOmittedZeroEvidence = true + if items.isEmpty { + evidence.append(0) + } else if let itemSize { + evidence.append(try sumUsageItems(items, field: "\(key).Items", size: itemSize)) + } else if !items.allSatisfy({ $0 is [String: Any] }) { + throw DockerDiskUsageParserError.invalidTotalUsage( + "invalid \(key).Items" + ) + } + } else { + throw DockerDiskUsageParserError.invalidTotalUsage( + "\(key).Items must be an array or null" + ) + } + } + if usage["TotalSize"] == nil, exactOmittedZeroEvidence { + evidence.append(0) + } + guard let exact = try reconciledUsage(evidence, field: key) else { + throw DockerDiskUsageParserError.invalidTotalUsage( + "\(key) does not contain an exact total" + ) + } + results.append(exact) + } + return results + } + + private static func legacyImageUsageValues(_ root: [String: Any]) throws -> [Int64] { + var results: [Int64] = [] + if let value = root["LayersSize"] { + guard let total = exactNonnegativeInteger(value) else { + throw DockerDiskUsageParserError.invalidTotalUsage("LayersSize is invalid") + } + results.append(total) + } + if let value = root["Images"] { + if value is NSNull { + results.append(0) + } else { + guard let items = value as? [Any] else { + throw DockerDiskUsageParserError.invalidTotalUsage( + "Images must be an array or null" + ) + } + // Image summary sizes overlap through shared layers, so they cannot be summed. + // An explicitly empty list is nevertheless exact evidence of zero image usage. + if items.isEmpty { + results.append(0) + } + } + } + return results + } + + private static func optionalValue(_ value: Int64?) -> [Int64] { + value.map { [$0] } ?? [] + } + + private static func reconciledUsage(_ values: [Int64], field: String) throws -> Int64? { + guard let first = values.first else { return nil } + guard values.dropFirst().allSatisfy({ $0 == first }) else { + throw DockerDiskUsageParserError.invalidTotalUsage( + "conflicting \(field) usage representations" + ) + } + return first + } + + private static func sumUsageItems( + _ items: [Any], + field: String, + size: ([String: Any]) -> Int64? + ) throws -> Int64 { + var sizes: [Int64] = [] + for (index, item) in items.enumerated() { + guard let object = item as? [String: Any], let value = size(object) else { + throw DockerDiskUsageParserError.invalidTotalUsage("invalid \(field)[\(index)]") + } + sizes.append(value) + } + return try sum(sizes, field: field) } private static func volumeSize(_ object: [String: Any]) -> Int64? { @@ -149,8 +348,20 @@ nonisolated enum DockerDiskUsageParser { } private static func containerSize(_ object: [String: Any]) -> Int64? { - if let size = exactNonnegativeInteger(object["SizeRw"]) { return size } - return ((object["State"] as? String) ?? "").lowercased() == "created" ? 0 : nil + if let rawSize = object["SizeRw"] { + return exactNonnegativeInteger(rawSize) + } + let statesWithSchemaDefinedZero: Set = [ + "created", "restarting", "running", "removing", "paused", "exited", "dead", + ] + guard let state = object["State"] as? String, + statesWithSchemaDefinedZero.contains(state.lowercased()) else { + return nil + } + // Moby declares Summary.SizeRw with `omitempty`; absence is therefore the exact wire + // representation of zero for every valid container state. Explicit null and malformed + // values took the branch above and remain invalid. + return 0 } private static func directSize(_ object: [String: Any]) -> Int64? { diff --git a/Dory/Runtime/Docker/DockerEngineRuntime.swift b/Dory/Runtime/Docker/DockerEngineRuntime.swift index c3840b2f..b05bade7 100644 --- a/Dory/Runtime/Docker/DockerEngineRuntime.swift +++ b/Dory/Runtime/Docker/DockerEngineRuntime.swift @@ -424,12 +424,16 @@ struct DockerEngineRuntime: ContainerRuntime { let socketPath: String let displayName: String let operationIdleTimeout: TimeInterval? + private let migrationTargetStorageUsageProbe: + (@Sendable () async throws -> MigrationTargetStorageUsage)? nonisolated init( socketPath: String, kind: RuntimeKind = .docker, displayName: String? = nil, - operationIdleTimeout: TimeInterval? = nil + operationIdleTimeout: TimeInterval? = nil, + migrationTargetStorageUsageProbe: + (@Sendable () async throws -> MigrationTargetStorageUsage)? = nil ) { self.socketPath = socketPath self.kind = kind @@ -437,6 +441,7 @@ struct DockerEngineRuntime: ContainerRuntime { ? DockerEngineSocketDiscovery.engineLabel(for: socketPath, home: NSHomeDirectory()) : kind.displayName) self.operationIdleTimeout = operationIdleTimeout + self.migrationTargetStorageUsageProbe = migrationTargetStorageUsageProbe } var migrationSourceIdentifier: String { @@ -457,10 +462,16 @@ struct DockerEngineRuntime: ContainerRuntime { socketPath: socketPath, kind: kind, displayName: displayName, - operationIdleTimeout: timeout + operationIdleTimeout: timeout, + migrationTargetStorageUsageProbe: migrationTargetStorageUsageProbe ) } + func migrationTargetStorageUsage() async throws -> MigrationTargetStorageUsage? { + guard let migrationTargetStorageUsageProbe else { return nil } + return try await migrationTargetStorageUsageProbe() + } + private var http: UnixSocketHTTP { UnixSocketHTTP( path: socketPath, @@ -566,6 +577,34 @@ struct DockerEngineRuntime: ContainerRuntime { ) } + /// Decodes the non-waking dashboard payload produced by doryd's private observation path. + /// Runtime statistics are intentionally omitted: collecting them is an active two-sample + /// operation and belongs to an explicit user refresh, not the background idle observer. + func dashboardSnapshot(from payload: [String: Data]) throws -> RuntimeSnapshot { + func required(_ key: String, as type: T.Type) throws -> T { + guard let data = payload[key] else { + throw RuntimeFeatureError.unsupported("doryd dashboard snapshot omitted \(key)") + } + return try decoder.decode(T.self, from: data) + } + + let summaries = try required("containers", as: [DockerContainerSummary].self) + let imageSummaries = try required("images", as: [DockerImageSummary].self) + let volumeList = try required("volumes", as: DockerVolumeList.self) + let networkSummaries = try required("networks", as: [DockerNetwork].self) + let version = try required("version", as: DockerVersion.self) + return RuntimeSnapshot( + containers: summaries.map { map($0, stats: nil) }, + images: imageSummaries.compactMap(mapImage), + volumes: volumeList.volumes?.map(mapVolume) ?? [], + networks: networkSummaries.map(mapNetwork), + pods: [], + machines: [], + engineRunning: true, + engineVersion: version.version ?? "docker" + ) + } + /// Fail closed for migration inventory. The normal dashboard deliberately tolerates optional /// table failures, but silently translating a timed-out `/volumes` or `/networks` request into /// an empty array would produce an image-only partial import and misleading success report. diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index e01b8886..3a5ef76d 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -1,11 +1,13 @@ @preconcurrency import Foundation @preconcurrency import Security +import DoryOperations @objc(DorydHealthControl) nonisolated protocol DorydControlXPC { func protocolVersion(reply: @escaping (UInt32) -> Void) func dorySocketPath(reply: @escaping (String) -> Void) func engineStatus(reply: @escaping (String, String) -> Void) + func engineDashboardSnapshot(reply: @escaping (NSDictionary, String) -> Void) func engineStart(reply: @escaping (Bool, String) -> Void) func engineStop(reply: @escaping (Bool, String) -> Void) func engineSleep(reply: @escaping (Bool, String) -> Void) @@ -13,22 +15,54 @@ nonisolated protocol DorydControlXPC { func dockerAgentInfo(reply: @escaping (NSDictionary, String) -> Void) func dockerAgentPorts(reply: @escaping (NSDictionary, String) -> Void) func dockerAgentTelemetry(reply: @escaping (NSDictionary, String) -> Void) + func dockerGuestDataDiskUsage(reply: @escaping (NSDictionary, String) -> Void) func machineCreate(_ config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineStart(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineStop(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machinePause(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineSuspend(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineResume(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineRestart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineRefreshManagedDesktopKernel(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineDisplayPresentationSet(_ machineID: String, presentation: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) func machineList(reply: @escaping (NSArray, String) -> Void) + func machineEvents(_ afterSequence: UInt64, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineFlightRecorder(_ machineID: String, afterSequence: UInt64, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineSerialConsoleRead(_ machineID: String, cursor: NSDictionary, limit: UInt32, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineSerialConsoleWrite(_ machineID: String, data: NSData, reply: @escaping (Bool, String) -> Void) func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineDeviceTelemetry(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func hostUSBDevices(reply: @escaping (Bool, NSArray, String) -> Void) + func machineUSBAttach(_ machineID: String, busID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineUSBDetach(_ machineID: String, busID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineExec(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineTransfer(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineTransferStart(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineTransferCurrent(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineTransferStatus(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineTransferCancel(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineGuestExportStart(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineGuestExportCurrent(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineGuestExportStatus(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineGuestExportCancel(_ machineID: String, operationID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineGuestExportDiscard(_ machineID: String, operationID: String, reply: @escaping (Bool, String) -> Void) func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineDesktopUpdate(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineSnapshots(_ machineID: String, reply: @escaping (NSArray, String) -> Void) func machineCloneSnapshot(_ machineID: String, snapshotID: String, newID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineRestoreSnapshot(_ machineID: String, snapshotID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineDeleteSnapshot(_ machineID: String, snapshotID: String, reply: @escaping (Bool, String) -> Void) func machineExportSnapshot(_ machineID: String, snapshotID: String, path: String, reply: @escaping (Bool, String) -> Void) + func machineAssessSnapshotImport(_ path: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineImportSnapshot(_ path: String, reply: @escaping (Bool, NSDictionary, String) -> Void) + func machineImportSnapshot(_ path: String, expectedContentID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineBackupSchedules(reply: @escaping (NSArray, String) -> Void) func machineBackupSet(_ schedule: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) func machineBackupRemove(_ machineID: String, reply: @escaping (Bool, String) -> Void) @@ -71,14 +105,387 @@ nonisolated struct DorydMachineShareConfiguration: Sendable, Equatable { var hostPath: String var guestPath: String var readOnly: Bool + var authorizationBookmark: Data? = nil var xpcDictionary: NSDictionary { - [ + let dictionary = NSMutableDictionary(dictionary: [ "tag": tag, "hostPath": hostPath, "guestPath": guestPath, "readOnly": readOnly, - ] + ]) + if let authorizationBookmark { + dictionary["authorizationBookmark"] = authorizationBookmark as NSData + } + return dictionary + } +} + +nonisolated struct DorydMachineTypedSettings: Sendable, Equatable, Hashable { + var guestIdentityIntent: DoryVMGuestIdentityIntent = .unspecified + var clipboardPolicy: DoryVMClipboardPolicy? = nil + var runtimePreference: DoryDesktopVMMPreference? = nil + var graphicsPreference: DoryDesktopGraphicsPreference? = nil + var networkMode: DoryVMNetworkMode? = nil + var portForwards: [DoryVMPortForward] = [] + var audioConfiguration: DoryVMAudioConfiguration? = nil + var cameraConfiguration: DoryVMCameraConfiguration? = nil + var intelApplicationTranslationEnabled: Bool? = nil + + init( + guestIdentityIntent: DoryVMGuestIdentityIntent = .unspecified, + clipboardPolicy: DoryVMClipboardPolicy? = nil, + runtimePreference: DoryDesktopVMMPreference? = nil, + graphicsPreference: DoryDesktopGraphicsPreference? = nil, + networkMode: DoryVMNetworkMode? = nil, + portForwards: [DoryVMPortForward] = [], + audioConfiguration: DoryVMAudioConfiguration? = nil, + cameraConfiguration: DoryVMCameraConfiguration? = nil, + intelApplicationTranslationEnabled: Bool? = nil + ) { + self.guestIdentityIntent = guestIdentityIntent + self.clipboardPolicy = clipboardPolicy + self.runtimePreference = runtimePreference + self.graphicsPreference = graphicsPreference + self.networkMode = networkMode + self.portForwards = portForwards + self.audioConfiguration = audioConfiguration + self.cameraConfiguration = cameraConfiguration + self.intelApplicationTranslationEnabled = intelApplicationTranslationEnabled + } + + init(legacyEnvironment: [String: String], displayMode: MachineDisplayMode) { + let username = legacyEnvironment[DoryVMGuestAccountIntent.legacyUsernameEnvironmentKey] + .flatMap { DoryVMGuestAccountIntent.isValidUsername($0) ? $0 : nil } + let numericUserID = legacyEnvironment[ + DoryVMGuestAccountIntent.legacyNumericUserIDEnvironmentKey + ].flatMap(UInt32.init).flatMap { + DoryVMGuestAccountIntent.isValidNumericUserID($0) ? $0 : nil + } + let account = DoryVMGuestAccountIntent(username: username, numericUserID: numericUserID) + let desktop: DoryVMDesktopIdentityIntent? + if displayMode == .desktop { + func safeLabel(_ key: String) -> String? { + legacyEnvironment[key].flatMap { + DoryVMDesktopIdentityIntent.isValidLabel($0) ? $0 : nil + } + } + let distribution = legacyEnvironment[ + DoryVMDesktopIdentityIntent.legacyDistributionEnvironmentKey + ].flatMap { + DoryVMDesktopIdentityIntent.isValidDistributionIdentifier($0) ? $0 : nil + } + let candidate = DoryVMDesktopIdentityIntent( + distributionIdentifier: distribution, + displayName: safeLabel(DoryVMDesktopIdentityIntent.legacyDisplayNameEnvironmentKey), + version: safeLabel(DoryVMDesktopIdentityIntent.legacyVersionEnvironmentKey), + desktopEnvironment: safeLabel( + DoryVMDesktopIdentityIntent.legacyDesktopEnvironmentKey + ) + ) + desktop = candidate.isValidForPersistence ? candidate : nil + } else { + desktop = nil + } + guestIdentityIntent = DoryVMGuestIdentityIntent( + account: account.isValidForPersistence ? account : nil, + desktop: desktop + ) + networkMode = .sharedNAT + portForwards = [] + intelApplicationTranslationEnabled = nil + if displayMode == .desktop { + let effectiveClipboard = DoryDesktopClipboardPolicy( + environment: legacyEnvironment + ) + clipboardPolicy = DoryVMClipboardDirection( + rawValue: effectiveClipboard.rawValue + ).map(DoryVMClipboardPolicy.legacyDesktop) + runtimePreference = (try? DoryDesktopVMMPreference( + environment: legacyEnvironment + )) ?? .automatic + graphicsPreference = (try? DoryDesktopGraphicsPreference( + environment: legacyEnvironment + )) ?? .automatic + audioConfiguration = DoryVMAudioConfiguration( + inputEnabled: true, + outputEnabled: true + ) + cameraConfiguration = DoryVMCameraConfiguration( + enabled: legacyEnvironment[ + DoryVMCameraConfiguration.legacyEnabledEnvironmentKey + ] == "1" + ) + } else { + clipboardPolicy = nil + runtimePreference = nil + graphicsPreference = nil + audioConfiguration = nil + cameraConfiguration = nil + } + } + + var isEmpty: Bool { + guestIdentityIntent.isEmpty + && clipboardPolicy == nil + && runtimePreference == nil + && graphicsPreference == nil + && networkMode == nil + && portForwards.isEmpty + && audioConfiguration == nil + && cameraConfiguration == nil + && intelApplicationTranslationEnabled == nil + } + + var xpcDictionary: NSDictionary { + var result: [String: Any] = [:] + var identity: [String: Any] = [:] + if let account = guestIdentityIntent.account, !account.isEmpty { + var value: [String: Any] = [:] + if let username = account.username { value["username"] = username } + if let numericUserID = account.numericUserID { + value["numericUserID"] = numericUserID + } + identity["account"] = value as NSDictionary + } + if let desktop = guestIdentityIntent.desktop, !desktop.isEmpty { + var value: [String: Any] = [:] + if let distributionIdentifier = desktop.distributionIdentifier { + value["distributionIdentifier"] = distributionIdentifier + } + if let displayName = desktop.displayName { value["displayName"] = displayName } + if let version = desktop.version { value["version"] = version } + if let desktopEnvironment = desktop.desktopEnvironment { + value["desktopEnvironment"] = desktopEnvironment + } + identity["desktop"] = value as NSDictionary + } + if !identity.isEmpty { result["guestIdentityIntent"] = identity as NSDictionary } + if let clipboardPolicy { + result["clipboardPolicy"] = [ + "text": clipboardPolicy.text.rawValue, + "image": clipboardPolicy.image.rawValue, + "files": clipboardPolicy.files.rawValue, + ] as NSDictionary + } + if let runtimePreference { + result["desktopRuntimePreference"] = runtimePreference.rawValue + } + if let graphicsPreference { + result["desktopGraphicsPreference"] = graphicsPreference.rawValue + } + if let networkMode { + result["networkMode"] = networkMode.rawValue + } + if !portForwards.isEmpty { + result["portForwards"] = Self.xpcPortForwards(portForwards) + } + if let audioConfiguration { + result["audio"] = [ + "inputEnabled": audioConfiguration.inputEnabled, + "outputEnabled": audioConfiguration.outputEnabled, + ] as NSDictionary + } + if let cameraConfiguration { + result["cameraEnabled"] = cameraConfiguration.enabled + } + if let intelApplicationTranslationEnabled { + result["intelApplicationTranslationEnabled"] = intelApplicationTranslationEnabled + } + return result as NSDictionary + } + + func hash(into hasher: inout Hasher) { + hasher.combine(guestIdentityIntent.account?.username) + hasher.combine(guestIdentityIntent.account?.numericUserID) + hasher.combine(guestIdentityIntent.desktop?.distributionIdentifier) + hasher.combine(guestIdentityIntent.desktop?.displayName) + hasher.combine(guestIdentityIntent.desktop?.version) + hasher.combine(guestIdentityIntent.desktop?.desktopEnvironment) + hasher.combine(clipboardPolicy?.text.rawValue) + hasher.combine(clipboardPolicy?.image.rawValue) + hasher.combine(clipboardPolicy?.files.rawValue) + hasher.combine(runtimePreference?.rawValue) + hasher.combine(graphicsPreference?.rawValue) + hasher.combine(networkMode?.rawValue) + hasher.combine(portForwards) + hasher.combine(audioConfiguration?.inputEnabled) + hasher.combine(audioConfiguration?.outputEnabled) + hasher.combine(cameraConfiguration?.enabled) + hasher.combine(intelApplicationTranslationEnabled) + } + + fileprivate static func xpcPortForwards(_ forwards: [DoryVMPortForward]) -> NSArray { + forwards.map { forward in + [ + "id": forward.id, + "transport": forward.transport.rawValue, + "hostPort": NSNumber(value: forward.hostPort), + "guestPort": NSNumber(value: forward.guestPort), + "exposure": forward.exposure.rawValue, + ] as NSDictionary + } as NSArray + } +} + +/// Leaf-level update authority. Comparing a safely migrated baseline to the desired typed state +/// means an unrelated edit never clears an opaque or invalid legacy value that was intentionally +/// omitted during migration. +nonisolated struct DorydMachineTypedSettingsPatch: Sendable, Equatable { + var baseline: DorydMachineTypedSettings + var desired: DorydMachineTypedSettings + + var isEmpty: Bool { xpcDictionary.count == 0 } + + var xpcDictionary: NSDictionary { + var result: [String: Any] = [:] + var account: [String: Any] = [:] + Self.encode( + baseline.guestIdentityIntent.account?.username, + desired.guestIdentityIntent.account?.username, + key: "username", + into: &account + ) + Self.encode( + baseline.guestIdentityIntent.account?.numericUserID, + desired.guestIdentityIntent.account?.numericUserID, + key: "numericUserID", + into: &account + ) + var desktop: [String: Any] = [:] + Self.encode( + baseline.guestIdentityIntent.desktop?.distributionIdentifier, + desired.guestIdentityIntent.desktop?.distributionIdentifier, + key: "distributionIdentifier", + into: &desktop + ) + Self.encode( + baseline.guestIdentityIntent.desktop?.displayName, + desired.guestIdentityIntent.desktop?.displayName, + key: "displayName", + into: &desktop + ) + Self.encode( + baseline.guestIdentityIntent.desktop?.version, + desired.guestIdentityIntent.desktop?.version, + key: "version", + into: &desktop + ) + Self.encode( + baseline.guestIdentityIntent.desktop?.desktopEnvironment, + desired.guestIdentityIntent.desktop?.desktopEnvironment, + key: "desktopEnvironment", + into: &desktop + ) + if !account.isEmpty || !desktop.isEmpty { + var identity: [String: Any] = [:] + if !account.isEmpty { identity["account"] = account as NSDictionary } + if !desktop.isEmpty { identity["desktop"] = desktop as NSDictionary } + result["guestIdentityIntent"] = identity as NSDictionary + } + Self.encodePolicy( + baseline.clipboardPolicy, + desired.clipboardPolicy, + into: &result + ) + Self.encodeEnum( + baseline.runtimePreference, + desired.runtimePreference, + key: "desktopRuntimePreference", + into: &result + ) + Self.encodeEnum( + baseline.graphicsPreference, + desired.graphicsPreference, + key: "desktopGraphicsPreference", + into: &result + ) + Self.encodeEnum( + baseline.networkMode, + desired.networkMode, + key: "networkMode", + into: &result + ) + if baseline.portForwards != desired.portForwards { + result["portForwards"] = DorydMachineTypedSettings.xpcPortForwards( + desired.portForwards + ) + } + Self.encodeAudio( + baseline.audioConfiguration, + desired.audioConfiguration, + into: &result + ) + Self.encode( + baseline.cameraConfiguration?.enabled, + desired.cameraConfiguration?.enabled, + key: "cameraEnabled", + into: &result + ) + Self.encode( + baseline.intelApplicationTranslationEnabled, + desired.intelApplicationTranslationEnabled, + key: "intelApplicationTranslationEnabled", + into: &result + ) + return result as NSDictionary + } + + private static func encode( + _ baseline: Value?, + _ desired: Value?, + key: String, + into dictionary: inout [String: Any] + ) { + guard baseline != desired else { return } + dictionary[key] = desired ?? NSNull() + } + + private static func encodePolicy( + _ baseline: DoryVMClipboardPolicy?, + _ desired: DoryVMClipboardPolicy?, + into dictionary: inout [String: Any] + ) { + guard baseline != desired else { return } + guard let desired else { + dictionary["clipboardPolicy"] = NSNull() + return + } + dictionary["clipboardPolicy"] = [ + "text": desired.text.rawValue, + "image": desired.image.rawValue, + "files": desired.files.rawValue, + ] as NSDictionary + } + + private static func encodeEnum( + _ baseline: Value?, + _ desired: Value?, + key: String, + into dictionary: inout [String: Any] + ) where Value.RawValue == String { + guard baseline != desired else { return } + dictionary[key] = desired?.rawValue ?? NSNull() + } + + private static func encodeAudio( + _ baseline: DoryVMAudioConfiguration?, + _ desired: DoryVMAudioConfiguration?, + into dictionary: inout [String: Any] + ) { + guard baseline != desired else { return } + guard let desired else { + dictionary["audio"] = NSNull() + return + } + var audio: [String: Any] = [:] + if baseline?.inputEnabled != desired.inputEnabled { + audio["inputEnabled"] = desired.inputEnabled + } + if baseline?.outputEnabled != desired.outputEnabled { + audio["outputEnabled"] = desired.outputEnabled + } + dictionary["audio"] = audio as NSDictionary } } @@ -86,18 +493,22 @@ nonisolated struct DorydMachineConfiguration: Sendable, Equatable { var id: String var kernelPath: String var rootfsPath: String + var bootMode: MachineBootMode = .linuxKernel + var installerISOPath: String? = nil + var diskSizeBytes: UInt64? = nil var memoryMB: UInt64 var cpuCount: Int var address: String? = nil var displayMode: MachineDisplayMode = .headless var shares: [DorydMachineShareConfiguration] = [] - var environment: [String: String] = [:] + var typedSettings: DorydMachineTypedSettings = DorydMachineTypedSettings() var xpcDictionary: NSDictionary { var dictionary: [String: Any] = [ "id": id, "kernelPath": kernelPath, "rootfsPath": rootfsPath, + "bootMode": bootMode.rawValue, "memoryMB": memoryMB, "cpuCount": cpuCount, "displayMode": displayMode.rawValue, @@ -105,28 +516,643 @@ nonisolated struct DorydMachineConfiguration: Sendable, Equatable { if let address { dictionary["address"] = address } + if let installerISOPath { + dictionary["installerISOPath"] = installerISOPath + } + if let diskSizeBytes { + dictionary["diskSizeBytes"] = diskSizeBytes + } if !shares.isEmpty { dictionary["shares"] = shares.map(\.xpcDictionary) } - if !environment.isEmpty { - dictionary["env"] = environment.sorted(by: { $0.key < $1.key }).map { key, value in - [ - "key": key, - "value": value, - ] as NSDictionary - } + for (rawKey, value) in typedSettings.xpcDictionary { + if let key = rawKey as? String { dictionary[key] = value } } return dictionary as NSDictionary } } +nonisolated struct DorydMachineRuntimeComponentIdentity: Codable, Sendable, Equatable, Hashable { + var componentIdentifier: String + var buildIdentifier: String + var artifactSHA256: String +} + +nonisolated struct DorydMachineRuntimeBootMediaIdentity: Codable, Sendable, Equatable, Hashable { + var kind: String + var source: String + var artifactSHA256: String? = nil + var resolverNamespace: String? = nil + var resolverIdentifier: String? = nil + var inspectionIdentity: String? = nil + var inspectionReportSHA256: String? = nil + var provenanceReceiptIdentity: String? = nil + var provenanceReceiptSHA256: String? = nil + var provenanceRevision: UInt64? = nil +} + +nonisolated struct DorydMachineRuntimeQualificationReference: Codable, Sendable, Equatable, Hashable { + var manifestIdentity: String? = nil + var artifactSHA256: String? = nil + var manifestSHA256: String? = nil + var qualificationIdentity: String? = nil + var qualificationReportSHA256: String? = nil + var signingKeyID: String? = nil + var qualifierIdentifier: String? = nil + + var isValidGraphicsReference: Bool { + manifestIdentity?.isSafeEvidenceIdentifier == true + && artifactSHA256?.isLowercaseSHA256 == true + && manifestSHA256?.isLowercaseSHA256 == true + && signingKeyID?.isSafeEvidenceIdentifier == true + && qualificationIdentity == nil + && qualificationReportSHA256 == nil + && qualifierIdentifier == nil + } + + var isValidRuntimeReference: Bool { + qualificationIdentity?.isSafeEvidenceIdentifier == true + && qualificationReportSHA256?.isLowercaseSHA256 == true + && signingKeyID?.isSafeEvidenceIdentifier == true + && manifestIdentity == nil + && artifactSHA256 == nil + && manifestSHA256 == nil + && qualifierIdentifier == nil + } + + var isValidHostReference: Bool { + qualificationIdentity?.isSafeEvidenceIdentifier == true + && qualificationReportSHA256?.isLowercaseSHA256 == true + && qualifierIdentifier?.isSafeEvidenceIdentifier == true + && manifestIdentity == nil + && artifactSHA256 == nil + && manifestSHA256 == nil + && signingKeyID == nil + } +} + +nonisolated struct DorydMachineRuntimeIdentity: Codable, Sendable, Equatable, Hashable { + static let currentSchemaVersion: UInt16 = 1 + var schemaVersion: UInt16 + var mode: String + var virtualHardwareABIVersion: UInt16 + var invalidationReason: String? = nil + var definitionRevision: UInt64? = nil + var definitionSHA256: String? = nil + var planRevision: UInt64? = nil + var planSHA256: String? = nil + var backend: String? = nil + var backendImplementationIdentifier: String? = nil + var backendRuntimeBuildIdentifier: String? = nil + var supportTier: String? = nil + var graphics: String? = nil + var removableUSBHotplug: Bool? = nil + var selectionDisposition: String? = nil + var fallbackAuthorizationIdentity: String? = nil + var experimentalAuthorizationIdentity: String? = nil + var graphicsQualification: DorydMachineRuntimeQualificationReference? = nil + var runtimeQualification: DorydMachineRuntimeQualificationReference? = nil + var hostQualification: DorydMachineRuntimeQualificationReference? = nil + var components: [DorydMachineRuntimeComponentIdentity]? = nil + var bootMedia: DorydMachineRuntimeBootMediaIdentity? = nil + + static let legacyCompatibility = Self( + schemaVersion: currentSchemaVersion, + mode: "legacy-compatibility", + virtualHardwareABIVersion: 1 + ) + + var isValid: Bool { + guard schemaVersion == Self.currentSchemaVersion, + virtualHardwareABIVersion > 0 else { + return false + } + switch mode { + case "legacy-compatibility": + return invalidationReason == nil && hasNoResolvedEvidence + case "requires-replanning": + return invalidationReason?.isSafeEvidenceIdentifier == true + && hasNoResolvedEvidence + case "resolved-plan": + return isValidResolvedEvidence + default: + return false + } + } + + private var hasNoResolvedEvidence: Bool { + definitionRevision == nil + && definitionSHA256 == nil + && planRevision == nil + && planSHA256 == nil + && backend == nil + && backendImplementationIdentifier == nil + && backendRuntimeBuildIdentifier == nil + && supportTier == nil + && graphics == nil + && removableUSBHotplug == nil + && selectionDisposition == nil + && fallbackAuthorizationIdentity == nil + && experimentalAuthorizationIdentity == nil + && graphicsQualification == nil + && runtimeQualification == nil + && hostQualification == nil + && components == nil + && bootMedia == nil + } + + private var isValidResolvedEvidence: Bool { + guard invalidationReason == nil, + let definitionRevision, definitionRevision > 0, + definitionSHA256?.isLowercaseSHA256 == true, + let planRevision, planRevision > 0, + planSHA256?.isLowercaseSHA256 == true, + backend?.isSafeEvidenceIdentifier == true, + backendImplementationIdentifier?.isSafeEvidenceIdentifier == true, + backendRuntimeBuildIdentifier?.isSafeEvidenceIdentifier == true, + let supportTier, + let graphics, + ["none", "software", "host-accelerated-display", "hardware-accelerated-3d"] + .contains(graphics), + let selectionDisposition, + ["primary", "explicit-alternative", "approved-fallback"] + .contains(selectionDisposition), + let components, !components.isEmpty, + Set(components.map(\.componentIdentifier)).count == components.count, + components.allSatisfy({ component in + component.componentIdentifier.isSafeEvidenceIdentifier + && component.buildIdentifier.isSafeEvidenceIdentifier + && component.artifactSHA256.isLowercaseSHA256 + }), + let bootMedia, bootMedia.isValid else { + return false + } + if let graphicsQualification, !graphicsQualification.isValidGraphicsReference { + return false + } + switch supportTier { + case "supported": + if isPortableVZSoftwareBaseline { + guard graphicsQualification == nil, + runtimeQualification == nil, + hostQualification == nil, + experimentalAuthorizationIdentity == nil else { + return false + } + } else { + guard hostQualification?.isValidHostReference == true, + runtimeQualification?.isValidRuntimeReference == true, + experimentalAuthorizationIdentity == nil else { + return false + } + } + case "experimental": + guard hostQualification?.isValidHostReference == true, + experimentalAuthorizationIdentity?.isSafeEvidenceIdentifier == true, + runtimeQualification.map(\.isValidRuntimeReference) ?? true else { + return false + } + default: + return false + } + switch selectionDisposition { + case "approved-fallback": + return fallbackAuthorizationIdentity?.isSafeEvidenceIdentifier == true + case "primary", "explicit-alternative": + return fallbackAuthorizationIdentity == nil + default: + return false + } + } + + /// Portable user-installed Linux intentionally has no exact-media runtime or host + /// qualification. Admit only the narrow non-accelerated VZ contract that the daemon can prove + /// structurally; every managed or accelerated plan still requires signed qualification. + private var isPortableVZSoftwareBaseline: Bool { + guard backend == "apple-virtualization-framework", + backendImplementationIdentifier == "dory.vz-linux.compatibility.v1", + graphics == "software", + selectionDisposition == "primary", + bootMedia?.source == "user-provided", + let kind = bootMedia?.kind, + kind == "installer-iso" || kind == "virtual-disk", + components?.contains(where: { $0.componentIdentifier == "dory-vmm" }) == true else { + return false + } + return true + } + + var authorizesRemovableUSBHotplug: Bool { + mode == "resolved-plan" && removableUSBHotplug == true && isValid + } +} + +nonisolated struct DorydMachineRuntimeGraphicsSelection: Sendable, Equatable, Hashable { + static let currentSchemaVersion: UInt16 = 1 + var schemaVersion: UInt16 + var operationID: String + var resolvedPlanSHA256: String + var planRevision: UInt64 + var accelerationLevel: String + var backend: String + var rendererGeneration: UInt64? + var rendererWorkerReceiptSHA256: String? + var guestProducerFenceProofSHA256: String? + + var isValid: Bool { + guard schemaVersion == Self.currentSchemaVersion, + operationID.isCanonicalLowercaseUUID, + resolvedPlanSHA256.isLowercaseSHA256, + planRevision > 0 else { return false } + switch (accelerationLevel, backend) { + case ("software", "software"): + return rendererGeneration == nil + && rendererWorkerReceiptSHA256 == nil + && guestProducerFenceProofSHA256 == nil + case ("host-accelerated-display", "virgl"), + ("hardware-accelerated-3d", "virgl-venus"): + return rendererGeneration.map { $0 > 0 } == true + && rendererWorkerReceiptSHA256?.isLowercaseSHA256 == true + && guestProducerFenceProofSHA256?.isLowercaseSHA256 == true + default: + return false + } + } + + var isQualifiedAcceleration: Bool { + isValid && accelerationLevel != "software" + } +} + +nonisolated struct DorydMachineUSBAttachment: Sendable, Equatable { + var machineID: String + var busID: String + var port: Int + var vsockPort: UInt32 + var deviceID: UInt32 + var speed: UInt32 +} + +nonisolated struct DorydHostUSBDevice: Sendable, Equatable, Identifiable { + var busID: String + var vendorID: UInt16 + var productID: UInt16 + var vendorName: String + var productName: String + var deviceClass: UInt8 + var speed: UInt32 + + var id: String { busID } + + var displayName: String { + if !productName.isEmpty { return productName } + if !vendorName.isEmpty { return vendorName } + return String(format: "%04x:%04x", vendorID, productID) + } +} + +nonisolated private extension String { + var isLowercaseSHA256: Bool { + utf8.count == 64 && utf8.allSatisfy { byte in + (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) + } + } + + var isCanonicalLowercaseUUID: Bool { + utf8.count == 36 + && self == lowercased() + && UUID(uuidString: self).map { $0.uuidString.lowercased() == self } == true + } + + var isSafeEvidenceIdentifier: Bool { + let bytes = Array(utf8) + guard (1...256).contains(bytes.count) else { return false } + return bytes.allSatisfy { byte in + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + || byte == 45 || byte == 46 || byte == 47 + || byte == 58 || byte == 64 || byte == 95 + } + } + + var isSafeMachineIdentifier: Bool { + let bytes = Array(utf8) + guard (1...63).contains(bytes.count) else { return false } + return bytes.allSatisfy { byte in + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + || byte == 45 || byte == 46 || byte == 95 + } + } + + var isSafeComponentInstallationName: Bool { + let bytes = Array(utf8) + guard (1...255).contains(bytes.count), + let first = bytes.first, + (first >= 48 && first <= 57) + || (first >= 65 && first <= 90) + || (first >= 97 && first <= 122) else { + return false + } + return bytes.dropFirst().allSatisfy { byte in + (byte >= 48 && byte <= 57) + || (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + || byte == 43 || byte == 45 || byte == 46 || byte == 95 + } + } +} + +nonisolated struct DorydInstalledDesktopPayloadReceipt: Codable, Sendable, Equatable, Hashable { + var schemaVersion: UInt16 + var provenance: String + var distributionIdentifier: String + var releaseVersion: String + var inputSHA256: String + var bundleSHA256: String? + var distributionComponentIdentifier: String? + var distributionInstallationName: String? + var distributionCatalogSHA256: String? + var bundleAssetIdentifier: String? + var runtimeComponentIdentifier: String? + var runtimeInstallationName: String? + var runtimeCatalogSHA256: String? + var kernelAssetIdentifier: String? + var kernelSHA256: String? + + var isValid: Bool { + guard schemaVersion == 1, + ["debian", "kali", "ubuntu"].contains(distributionIdentifier), + releaseVersion.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9._+-]{0,127}/) != nil, + inputSHA256.isLowercaseSHA256 else { + return false + } + switch provenance { + case "legacy-environment": + return verifiedAuthorityFieldsAreNil + case "legacy-snapshot-migration": + return verifiedAuthorityFieldsAreNil + case "verified-update-bundle": + return bundleSHA256?.isLowercaseSHA256 == true + && kernelSHA256?.isLowercaseSHA256 == true + && distributionComponentIdentifier == "desktop-" + distributionIdentifier + && distributionInstallationName?.isSafeComponentInstallationName == true + && distributionCatalogSHA256?.isLowercaseSHA256 == true + && bundleAssetIdentifier + == "dory-desktop-" + distributionIdentifier + "-update-arm64.tar" + && runtimeComponentIdentifier == "linux-desktop" + && runtimeInstallationName?.isSafeComponentInstallationName == true + && runtimeCatalogSHA256?.isLowercaseSHA256 == true + && kernelAssetIdentifier == "dory-desktop-kernel-arm64.lzfse" + default: + return false + } + } + + private var verifiedAuthorityFieldsAreNil: Bool { + bundleSHA256 == nil + && distributionComponentIdentifier == nil + && distributionInstallationName == nil + && distributionCatalogSHA256 == nil + && bundleAssetIdentifier == nil + && runtimeComponentIdentifier == nil + && runtimeInstallationName == nil + && runtimeCatalogSHA256 == nil + && kernelAssetIdentifier == nil + && kernelSHA256 == nil + } + + static func legacyEnvironment(_ environment: [String: String]) -> Self? { + guard let distributionIdentifier = environment["DORY_DESKTOP_DISTRO"], + let releaseVersion = environment["DORY_DESKTOP_RELEASE_VERSION"], + let inputSHA256 = environment["DORY_DESKTOP_INPUT_SHA256"] else { + return nil + } + let receipt = Self( + schemaVersion: 1, + provenance: "legacy-environment", + distributionIdentifier: distributionIdentifier, + releaseVersion: releaseVersion, + inputSHA256: inputSHA256, + bundleSHA256: nil, + distributionComponentIdentifier: nil, + distributionInstallationName: nil, + distributionCatalogSHA256: nil, + bundleAssetIdentifier: nil, + runtimeComponentIdentifier: nil, + runtimeInstallationName: nil, + runtimeCatalogSHA256: nil, + kernelAssetIdentifier: nil, + kernelSHA256: nil + ) + return receipt.isValid ? receipt : nil + } +} + +nonisolated private extension DorydMachineRuntimeBootMediaIdentity { + var isValid: Bool { + let immutableKinds = [ + "installer-iso", + "installed-linux-boot-bundle", + "macos-restore-image", + ] + guard immutableKinds.contains(kind) || kind == "virtual-disk", + ["dory-bundled", "vendor-download", "user-provided"].contains(source) else { + return false + } + let immutable = artifactSHA256?.isLowercaseSHA256 == true + let mutable = provenanceReceiptIdentity?.isSafeEvidenceIdentifier == true + && provenanceReceiptSHA256?.isLowercaseSHA256 == true + && (provenanceRevision ?? 0) > 0 + let hasAnyMutableField = provenanceReceiptIdentity != nil + || provenanceReceiptSHA256 != nil + || provenanceRevision != nil + guard immutable != mutable, + hasAnyMutableField == mutable, + (kind == "virtual-disk") == mutable else { + return false + } + guard (resolverNamespace == nil) == (resolverIdentifier == nil), + resolverNamespace.map(\.isSafeEvidenceIdentifier) ?? true, + resolverIdentifier.map(\.isSafeEvidenceIdentifier) ?? true, + (inspectionIdentity == nil) == (inspectionReportSHA256 == nil), + inspectionIdentity.map(\.isSafeEvidenceIdentifier) ?? true, + inspectionReportSHA256.map(\.isLowercaseSHA256) ?? true else { + return false + } + if kind == "installer-iso" || kind == "macos-restore-image" { + guard inspectionIdentity != nil else { return false } + } + return true + } +} + +nonisolated struct DorydMachineSnapshotArtifact: Codable, Sendable, Equatable, Hashable { + var byteCount: UInt64 + var sha256: String + + var isValid: Bool { byteCount > 0 && sha256.isLowercaseSHA256 } +} + +nonisolated struct DorydMachineSnapshotArtifactEvidence: Codable, Sendable, Equatable, Hashable { + var schemaVersion: UInt16 + var rootfs: DorydMachineSnapshotArtifact + var kernel: DorydMachineSnapshotArtifact + var machineIdentifier: DorydMachineSnapshotArtifact? + var nvram: DorydMachineSnapshotArtifact? + + var isValid: Bool { + schemaVersion == 1 + && rootfs.isValid + && kernel.isValid + && (machineIdentifier?.isValid ?? true) + && (nvram?.isValid ?? true) + && ((machineIdentifier == nil) == (nvram == nil)) + } +} + +nonisolated enum DorydMachineSnapshotConsistency: String, Sendable, Equatable, Hashable { + case coldStopped = "cold-stopped" + case guestQuiesced = "guest-quiesced" +} + +nonisolated struct DorydMachineSnapshotQuiesceReceipt: Sendable, Equatable, Hashable { + var schemaVersion: UInt16 + var receiptID: String + var agentBuild: String + var agentProtocolVersion: UInt32 + var capabilityVersion: UInt32 + + var isValid: Bool { + schemaVersion == 1 + && receiptID.utf8.count == 32 + && receiptID.utf8.allSatisfy { + ($0 >= 0x30 && $0 <= 0x39) || ($0 >= 0x61 && $0 <= 0x66) + } + && !agentBuild.isEmpty + && agentBuild.utf8.count <= 128 + && agentBuild.utf8.allSatisfy { $0 >= 0x20 && $0 <= 0x7e } + && agentProtocolVersion == 1 + && capabilityVersion >= 2 + } +} + +nonisolated struct DorydAgentCapability: Sendable, Equatable, Hashable { + var id: String + var version: UInt32 + + var isValid: Bool { + version > 0 && id.utf8.count <= 63 + && id.wholeMatch(of: /[a-z][a-z0-9]*(?:-[a-z0-9]+)*/) != nil + } +} + +nonisolated enum DorydMachineFailureCode: String, Sendable, Equatable, Hashable { + case lifecycleOperationFailed = "lifecycle-operation-failed" + case lifecycleRecoveryRequired = "lifecycle-recovery-required" + case workspaceAuthorityInvalid = "workspace-authority-invalid" + case backendLaunchFailed = "backend-launch-failed" + case readinessHandoffFailed = "readiness-handoff-failed" + case readinessTimedOut = "readiness-timed-out" + case helperExited = "helper-exited" + case savedStateInvalid = "saved-state-invalid" + case resourceAdmissionRejected = "resource-admission-rejected" + case desktopUpdateRecoveryRequired = "desktop-update-recovery-required" + case desktopUpdateRolledBack = "desktop-update-rolled-back" + case deletionFailed = "deletion-failed" + case diagnosticPersistenceFailed = "diagnostic-persistence-failed" + case unclassified +} + +nonisolated enum DorydMachineFailureCauseCode: String, Sendable, Equatable, Hashable { + case configurationAuthority = "configuration-authority" + case runtimeAuthority = "runtime-authority" + case artifactAuthority = "artifact-authority" + case componentAuthority = "component-authority" + case hostQualification = "host-qualification" + case resourceAdmission = "resource-admission" + case processExit = "process-exit" + case readinessGate = "readiness-gate" + case journal + case filesystem + case guestAgent = "guest-agent" + case unknown +} + +nonisolated enum DorydMachineRecoveryDisposition: String, Sendable, Equatable, Hashable { + case retry + case replan + case repair + case rollbackCompleted = "rollback-completed" + case deleteWorkspace = "delete-workspace" + case inspectDiagnostics = "inspect-diagnostics" +} + +nonisolated enum DorydMachineFailureEvidenceKind: String, Sendable, Equatable, Hashable { + case operation + case plan + case backend + case component + case media + case snapshot + case savedState = "saved-state" + case journal + case hostQualification = "host-qualification" +} + +nonisolated struct DorydMachineFailureEvidenceReference: Sendable, Equatable, Hashable { + var kind: DorydMachineFailureEvidenceKind + var identifier: String +} + +nonisolated struct DorydMachineFailure: Sendable, Equatable, Hashable { + var schemaVersion: UInt16 + var code: DorydMachineFailureCode + var occurredAtUnixMilliseconds: Int64 + var operationID: String? + var causalChain: [DorydMachineFailureCauseCode] + var recoveryDisposition: DorydMachineRecoveryDisposition + var evidenceReferences: [DorydMachineFailureEvidenceReference] +} + +nonisolated enum DorydMachineOperationKind: String, Sendable, Equatable, Hashable { + case importing + case provisioning + case resolving + case starting + case stopping + case pausing + case resuming + case suspending + case restoring + case snapshotting + case cloning + case updating + case repairing + case deleting +} + +nonisolated struct DorydMachineOperationSummary: Sendable, Equatable, Hashable { + var operationID: String + var kind: DorydMachineOperationKind +} + nonisolated struct DorydMachineStatus: Sendable, Equatable { var id: String var state: String var pid: Int32? var lastError: String? + var failure: DorydMachineFailure? = nil + var activeOperation: DorydMachineOperationSummary? = nil + var flightRecorderHeadSequence: UInt64 = 0 + var flightRecorderAvailable: Bool = false var handoffSocketPath: String? var agentBuild: String? + var agentProtocolVersion: UInt32? = nil + var agentCapabilities: [DorydAgentCapability] = [] + var integrationHealth: DoryGuestIntegrationHealth? = nil var agentSocketPath: String? var dockerdSocketPath: String? var shellSocketPath: String? @@ -139,8 +1165,34 @@ nonisolated struct DorydMachineStatus: Sendable, Equatable { var currentBalloonTargetMB: UInt64? = nil var cpuCount: Int? var displayMode: MachineDisplayMode = .headless + var bootMode: MachineBootMode = .linuxKernel + var installerMediaAttached: Bool = false var shares: [DorydMachineShareConfiguration] = [] var environment: [String: String] = [:] + var typedSettings: DorydMachineTypedSettings? = nil + var displayPresentation: DoryMachineDisplayPresentation = .windowed + var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility + var runtimeGraphicsSelection: DorydMachineRuntimeGraphicsSelection? = nil + var installedDesktopPayloadReceipt: DorydInstalledDesktopPayloadReceipt? = nil + var cloneReceipt: DorydMachineCloneReceipt? = nil + var savedState: DorydMachineSavedStateSummary? = nil +} + +nonisolated struct DorydMachineCloneReceipt: Sendable, Equatable, Hashable { + var sourceMachineID: String + var sourceSnapshotID: String + var sourceRootfsSHA256: String + var sourceRootfsByteCount: UInt64 + var storageMode: String + var createdAtUnixMilliseconds: Int64 +} + +nonisolated struct DorydMachineSavedStateSummary: Sendable, Equatable { + var stateFileSHA256: String + var stateFileByteCount: UInt64 + var hostHardwareModel: String + var hostOperatingSystemBuild: String + var createdAtUnixMilliseconds: Int64 } nonisolated struct DorydMachineExecResult: Sendable, Equatable { @@ -164,29 +1216,250 @@ nonisolated struct DorydMachineStats: Sendable, Equatable { var uptimeSeconds: Double } +nonisolated enum DorydDeviceTelemetryKind: String, Sendable, Equatable, CaseIterable { + case platform, storage, network, graphics, display, audio, balloon, entropy, input, socket + case sharedDirectory = "shared-directory" +} + +nonisolated enum DorydDeviceTelemetryHealth: String, Sendable, Equatable { + case healthy, degraded, failed, unavailable +} + +nonisolated enum DorydDeviceTelemetryMetricAvailability: String, Sendable, Equatable { + case measured, unavailable +} + +nonisolated struct DorydDeviceTelemetryMetric: Sendable, Equatable { + var kind: String + var unit: String + var availability: DorydDeviceTelemetryMetricAvailability + var value: UInt64? + var unavailableReason: String? +} + +nonisolated struct DorydDeviceTelemetryDevice: Sendable, Equatable { + var id: String + var kind: DorydDeviceTelemetryKind + var health: DorydDeviceTelemetryHealth + var metrics: [DorydDeviceTelemetryMetric] +} + +nonisolated struct DorydDeviceTelemetryEvent: Sendable, Equatable { + var sequence: UInt64 + var monotonicNanoseconds: UInt64 + var deviceID: String + var kind: String + var occurrences: UInt64 +} + +nonisolated struct DorydDeviceTelemetrySnapshot: Sendable, Equatable { + var machineID: String + var operationID: String + var backend: DoryVirtualizationBackendIdentity + var sampleSequence: UInt64 + var sampledAtUnixMilliseconds: UInt64 + var monotonicNanoseconds: UInt64 + var devices: [DorydDeviceTelemetryDevice] + var events: [DorydDeviceTelemetryEvent] +} + nonisolated struct DorydMachineProvisionResult: Sendable, Equatable { var recipeID: String var install: DorydMachineExecResult var verify: DorydMachineExecResult } -nonisolated struct DorydMachineSnapshot: Sendable, Equatable { - var id: String +nonisolated struct DorydDesktopUpdateResult: Sendable, Equatable { + var operationID: String? var machineID: String - var note: String - var createdISO: String - var rootfsPath: String - var sizeBytes: Int64 - var kernelPath: String - var architecture: String - var memoryMB: UInt64 - var cpuCount: Int + var distro: String + var version: String + var inputSHA256: String + var bundleSHA256: String + var snapshotID: String + var status: DorydMachineStatus + var restoredRunningState: Bool } -nonisolated enum DorydMachineBackupFrequency: String, Sendable, Equatable, CaseIterable { - case hourly - case daily - case weekly +nonisolated enum DorydMachineEventKind: String, Sendable, Equatable { + case updated + case removed +} + +nonisolated struct DorydMachineEventStatus: Sendable, Equatable { + var machineID: String + var configurationRevision: String + var observedRevision: String + var state: String + var hasFailure: Bool + var failureCode: DorydMachineFailureCode? + var recoveryDisposition: DorydMachineRecoveryDisposition? + var operationID: String? + var operationKind: DorydMachineOperationKind? + var memoryMB: UInt64 + var cpuCount: Int + var displayMode: String + var bootMode: String + var installerMediaAttached: Bool + var shareCount: Int + var integrationHealth: String + var runtimeMode: String + var virtualHardwareABIVersion: UInt16 + var planRevision: UInt64? + var planSHA256: String? + var backend: DoryVirtualizationBackendIdentity? + var savedStateSHA256: String? +} + +nonisolated struct DorydMachineEvent: Sendable, Equatable { + var sequence: UInt64 + var observedAtUnixMilliseconds: Int64 + var machineID: String + var kind: DorydMachineEventKind + var status: DorydMachineEventStatus? +} + +nonisolated struct DorydMachineEventBatch: Sendable, Equatable { + var headSequence: UInt64 + var snapshotRequired: Bool + var events: [DorydMachineEvent] +} + +nonisolated enum DorydMachineFlightEventKind: String, Sendable, Equatable { + case workspaceCreated = "workspace-created" + case operationStarted = "operation-started" + case operationPhase = "operation-phase" + case backendSpawned = "backend-spawned" + case readinessAccepted = "readiness-accepted" + case readinessRejected = "readiness-rejected" + case resourceTransition = "resource-transition" + case deviceHealthEvent = "device-health-event" + case processExited = "process-exited" + case failureRecorded = "failure-recorded" + case operationCompleted = "operation-completed" + case operationFailed = "operation-failed" + case recoveryRequired = "recovery-required" + case workspaceDeleted = "workspace-deleted" +} + +nonisolated struct DorydMachineFlightEvent: Sendable, Equatable { + var sequence: UInt64 + var occurredAtUnixMilliseconds: Int64 + var machineID: String + var operationID: String? + var operationKind: DorydMachineOperationKind? + var kind: DorydMachineFlightEventKind + var phase: String? + var machineState: String? + var failureCode: DorydMachineFailureCode? + var recoveryDisposition: DorydMachineRecoveryDisposition? + var backend: DoryVirtualizationBackendIdentity? + var virtualHardwareABIVersion: UInt16? + var planSHA256: String? + var durationMilliseconds: UInt64? + var deadlineUnixMilliseconds: Int64? + var deviceID: String? + var deviceEventKind: String? + var deviceEventSequence: UInt64? + var deviceEventOccurrences: UInt64? + var evidenceReferences: [DorydMachineFailureEvidenceReference] +} + +nonisolated struct DorydMachineFlightRecorderBatch: Sendable, Equatable { + var machineID: String + var headSequence: UInt64 + var snapshotRequired: Bool + var events: [DorydMachineFlightEvent] +} + +nonisolated struct DorydMachineSerialConsoleCursor: Sendable, Equatable, Hashable { + var generation: String? = nil + var offset: UInt64 = 0 + + var xpcDictionary: NSDictionary { + var dictionary: [String: Any] = [ + "schemaVersion": UInt16(1), + "offset": offset, + ] + if let generation { dictionary["generation"] = generation } + return dictionary as NSDictionary + } +} + +nonisolated struct DorydMachineSerialConsoleBatch: Sendable, Equatable { + var machineID: String + var generation: String? + var startOffset: UInt64 + var nextOffset: UInt64 + var totalBytes: UInt64 + var snapshotRequired: Bool + var inputAvailable: Bool + var bytes: Data + + var cursor: DorydMachineSerialConsoleCursor { + DorydMachineSerialConsoleCursor(generation: generation, offset: nextOffset) + } +} + +nonisolated enum DorydMachineImportDisposition: String, Sendable, Equatable { + case ready + case requiresComponents = "requires-components" + case requiresReplanning = "requires-replanning" + case unavailable +} + +nonisolated enum DorydMachineImportComponentAvailability: String, Sendable, Equatable { + case available + case mismatched + case missing +} + +nonisolated struct DorydMachineImportComponentAssessment: Sendable, Equatable { + var componentIdentifier: String + var buildIdentifier: String + var artifactSHA256: String + var availability: DorydMachineImportComponentAvailability +} + +nonisolated struct DorydMachineImportAssessment: Sendable, Equatable { + var schemaVersion: UInt16 + var contentID: String + var sourceMachineID: String + var sourceSnapshotID: String + var architecture: String + var bootMode: String + var diskSizeBytes: UInt64 + var virtualHardwareABIVersion: UInt16 + var sourceRuntimeMode: String + var sourceBackend: DoryVirtualizationBackendIdentity? + var portable: Bool + var disposition: DorydMachineImportDisposition + var issues: [String] + var components: [DorydMachineImportComponentAssessment] +} + +nonisolated struct DorydMachineSnapshot: Sendable, Equatable { + var id: String + var machineID: String + var note: String + var createdISO: String + var rootfsPath: String + var sizeBytes: Int64 + var kernelPath: String + var architecture: String + var memoryMB: UInt64 + var cpuCount: Int + var runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility + var artifactEvidence: DorydMachineSnapshotArtifactEvidence? = nil + var installedDesktopPayloadReceipt: DorydInstalledDesktopPayloadReceipt? = nil + var consistency: DorydMachineSnapshotConsistency = .coldStopped + var guestQuiesceReceipt: DorydMachineSnapshotQuiesceReceipt? = nil +} + +nonisolated enum DorydMachineBackupFrequency: String, Sendable, Equatable, CaseIterable { + case hourly + case daily + case weekly } nonisolated struct DorydMachineBackupSchedule: Sendable, Equatable { @@ -270,6 +1543,7 @@ nonisolated struct DorydAgentInfo: Sendable, Equatable { var kernel: String var agentBuild: String var uptimeSeconds: UInt64 + var capabilities: [DorydAgentCapability] = [] } nonisolated struct DorydTelemetry: Sendable, Equatable { @@ -279,6 +1553,14 @@ nonisolated struct DorydTelemetry: Sendable, Equatable { var psiFullAvg10: Double } +nonisolated struct DorydDockerGuestDataDiskUsage: Sendable, Equatable { + var engineSocketPath: String + var dataDriveID: UUID + var totalBytes: UInt64 + var usedBytes: UInt64 + var availableBytes: UInt64 +} + nonisolated struct DorydListenPort: Sendable, Equatable, Hashable { var `protocol`: String var port: UInt32 @@ -296,6 +1578,117 @@ nonisolated struct DorydPushStats: Sendable, Equatable { var filesDeleted: UInt64 } +nonisolated struct DorydMachineFileTransferResult: Sendable, Equatable { + var transferID: String + var guestDestination: String + var filesSent: UInt64 + var bytesSent: UInt64 +} + +nonisolated enum DorydMachineFileTransferPhase: String, Sendable, Equatable { + case preparing + case transferring + case finalizing + case cancelling + case completed + case cancelled + case failed + + var isTerminal: Bool { + switch self { + case .completed, .cancelled, .failed: + true + case .preparing, .transferring, .finalizing, .cancelling: + false + } + } +} + +nonisolated enum DorydMachineFileTransferFailureCode: String, Sendable, Equatable { + case guestUnavailable = "guest-unavailable" + case directionNotAuthorized = "direction-not-authorized" + case guestPreparationFailed = "guest-preparation-failed" + case transferFailed = "transfer-failed" + case guestFinalizationFailed = "guest-finalization-failed" +} + +nonisolated struct DorydMachineFileTransferFailure: Sendable, Equatable { + var code: DorydMachineFileTransferFailureCode + var message: String +} + +nonisolated struct DorydMachineFileTransferOperation: Sendable, Equatable { + var operationID: String + var machineID: String + var phase: DorydMachineFileTransferPhase + var filesTotal: UInt64 + var filesCompleted: UInt64 + var bytesTotal: UInt64 + var bytesCompleted: UInt64 + var currentPath: String? + var guestDestination: String? + var result: DorydMachineFileTransferResult? + var failure: DorydMachineFileTransferFailure? + + var fractionCompleted: Double { + if phase == .completed { + return 1 + } + if bytesTotal > 0 { + return min(1, Double(bytesCompleted) / Double(bytesTotal)) + } + if filesTotal > 0 { + return min(1, Double(filesCompleted) / Double(filesTotal)) + } + return 0 + } +} + +nonisolated private struct DorydMachineFileTransferCurrent: Sendable { + var operation: DorydMachineFileTransferOperation? +} + +/// A daemon-owned, short-lived handoff containing bytes verified by the guest pull protocol. +/// The private root is available only on an exact completed operation and must be discarded after +/// the app materializes it into a user-selected destination. +nonisolated struct DorydMachineGuestFileExportResult: Sendable, Equatable { + var exportID: String + var privateStagingRoot: String + var filesReceived: UInt64 + var directoriesReceived: UInt64 + var bytesReceived: UInt64 +} + +nonisolated struct DorydMachineGuestFileExportOperation: Sendable, Equatable { + var operationID: String + var machineID: String + var phase: DorydMachineFileTransferPhase + var filesTotal: UInt64 + var filesCompleted: UInt64 + var bytesTotal: UInt64 + var bytesCompleted: UInt64 + var currentPath: String? + var result: DorydMachineGuestFileExportResult? + var failure: DorydMachineFileTransferFailure? + + var fractionCompleted: Double { + if phase == .completed { + return 1 + } + if bytesTotal > 0 { + return min(1, Double(bytesCompleted) / Double(bytesTotal)) + } + if filesTotal > 0 { + return min(1, Double(filesCompleted) / Double(filesTotal)) + } + return 0 + } +} + +nonisolated private struct DorydMachineGuestFileExportCurrent: Sendable { + var operation: DorydMachineGuestFileExportOperation? +} + nonisolated struct DorydRemoteMachineStatus: Sendable, Equatable { var id: String var state: String @@ -462,9 +1855,11 @@ nonisolated final class DorydClient: @unchecked Sendable { // The daemon owns a 240-second promotion deadline. Leave enough client-side margin for the // daemon to return its exact outcome instead of replacing it with a simultaneous UI timeout. private static let engineColdStartTimeout: TimeInterval = 250 - // doryd gives dockerd and dory-hv up to 30 seconds to quiesce before its final fallback. - // Keep the UI connection alive past that bound so a safe stop is not reported as a timeout. + // Engine shutdown has no per-machine guest/helper acknowledgement path. private static let engineShutdownTimeout: TimeInterval = 45 + // doryd allows the bounded in-guest resource probe three seconds. Keep transport overhead and + // scheduling pressure from replacing a valid daemon result with the default control timeout. + private static let dockerGuestResourceProbeTimeout: TimeInterval = 10 private enum Target { case machService(String) @@ -555,6 +1950,26 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func engineDashboardSnapshot() async throws -> [String: Data] { + try await call { proxy, finish in + proxy.engineDashboardSnapshot { dictionary, error in + guard error.isEmpty else { + finish(.failure(DorydClientError.daemon(error))) + return + } + var result: [String: Data] = [:] + for (key, value) in dictionary { + guard let key = key as? String, let data = value as? Data else { + finish(.failure(DorydClientError.daemon("invalid dashboard snapshot payload"))) + return + } + result[key] = data + } + finish(.success(result)) + } + } + } + func engineStart() async throws -> DorydCommandResult { try await withTimeout(atLeast: Self.engineColdStartTimeout).command { proxy, reply in proxy.engineStart(reply: reply) @@ -603,25 +2018,133 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + /// Capacity and usage of the Docker engine guest filesystem that contains `/var/lib/docker`. + /// + /// This is deliberately sourced from the guest filesystem rather than Docker's object-level + /// `/system/df` inventory, which can be unavailable even while the engine is healthy. + func dockerGuestDataDiskUsage() async throws -> DorydDockerGuestDataDiskUsage { + try await withTimeout(atLeast: Self.dockerGuestResourceProbeTimeout).dictionaryCall { proxy, reply in + proxy.dockerGuestDataDiskUsage(reply: reply) + } decode: { dictionary in + let expectedKeys: Set = [ + "schema", + "engineSocketPath", + "dataDriveID", + "totalBytes", + "usedBytes", + "availableBytes", + ] + let keys = Set(dictionary.allKeys.compactMap { $0 as? String }) + guard keys.count == dictionary.count, + keys == expectedKeys, + Self.strictUInt64(dictionary["schema"]) == 1, + let engineSocketPath = dictionary["engineSocketPath"] as? String, + engineSocketPath.hasPrefix("/"), + !engineSocketPath.contains("\0"), + let encodedDataDriveID = dictionary["dataDriveID"] as? String, + let dataDriveID = UUID(uuidString: encodedDataDriveID), + encodedDataDriveID == dataDriveID.uuidString.lowercased(), + let totalBytes = Self.strictUInt64(dictionary["totalBytes"]), + totalBytes > 0, + let usedBytes = Self.strictUInt64(dictionary["usedBytes"]), + let availableBytes = Self.strictUInt64(dictionary["availableBytes"]), + usedBytes <= totalBytes, + availableBytes <= totalBytes else { + return nil + } + let accountedBytes = usedBytes.addingReportingOverflow(availableBytes) + guard !accountedBytes.overflow, accountedBytes.partialValue <= totalBytes else { + return nil + } + return DorydDockerGuestDataDiskUsage( + engineSocketPath: engineSocketPath, + dataDriveID: dataDriveID, + totalBytes: totalBytes, + usedBytes: usedBytes, + availableBytes: availableBytes + ) + } + } + func machineCreate(_ config: DorydMachineConfiguration) async throws -> DorydMachineStatus { - try await withTimeout(atLeast: 60).statusCommand { proxy, reply in + try await withTimeout(atLeast: DoryMachineControlTiming.fileMutationSeconds).statusCommand { proxy, reply in proxy.machineCreate(config.xpcDictionary, reply: reply) } decode: { Self.machineStatus(from: $0) } } - func machineStart(_ machineID: String) async throws -> DorydMachineStatus { - try await withTimeout(atLeast: 120).statusCommand { proxy, reply in - proxy.machineStart(machineID, reply: reply) + func machineStart( + _ machineID: String, + operationID: UUID = UUID() + ) async throws -> DorydMachineStatus { + try await withTimeout(atLeast: DoryMachineControlTiming.startSeconds).statusCommand { proxy, reply in + proxy.machineStart( + machineID, + operationID: operationID.uuidString.lowercased(), + reply: reply + ) + } decode: { + Self.machineStatus(from: $0) + } + } + + func machineStop( + _ machineID: String, + operationID: UUID = UUID() + ) async throws -> DorydMachineStatus { + try await withTimeout(atLeast: DoryMachineControlTiming.stopSeconds).statusCommand { proxy, reply in + proxy.machineStop( + machineID, + operationID: operationID.uuidString.lowercased(), + reply: reply + ) + } decode: { + Self.machineStatus(from: $0) + } + } + + func machinePause( + _ machineID: String, + operationID: UUID = UUID() + ) async throws -> DorydMachineStatus { + try await withTimeout(atLeast: 30).statusCommand { proxy, reply in + proxy.machinePause( + machineID, + operationID: operationID.uuidString.lowercased(), + reply: reply + ) + } decode: { + Self.machineStatus(from: $0) + } + } + + func machineSuspend(_ machineID: String) async throws -> DorydMachineStatus { + try await withTimeout(atLeast: 15 * 60).statusCommand { proxy, reply in + proxy.machineSuspend(machineID, reply: reply) } decode: { Self.machineStatus(from: $0) } } - func machineStop(_ machineID: String) async throws -> DorydMachineStatus { + func machineResume( + _ machineID: String, + operationID: UUID = UUID() + ) async throws -> DorydMachineStatus { try await withTimeout(atLeast: 30).statusCommand { proxy, reply in - proxy.machineStop(machineID, reply: reply) + proxy.machineResume( + machineID, + operationID: operationID.uuidString.lowercased(), + reply: reply + ) + } decode: { + Self.machineStatus(from: $0) + } + } + + func machineRestart(_ machineID: String) async throws -> DorydMachineStatus { + try await withTimeout(atLeast: DoryMachineControlTiming.restartSeconds).statusCommand { proxy, reply in + proxy.machineRestart(machineID, reply: reply) } decode: { Self.machineStatus(from: $0) } @@ -634,7 +2157,9 @@ nonisolated final class DorydClient: @unchecked Sendable { address: String? = nil, updatesAddress: Bool = false, shares: [DorydMachineShareConfiguration]? = nil, - environment: [String: String]? = nil + typedSettings: DorydMachineTypedSettings? = nil, + typedSettingsPatch: DorydMachineTypedSettingsPatch? = nil, + installerMediaAttached: Bool? = nil ) async throws -> DorydMachineStatus { var config: [String: Any] = [:] if let memoryMB { @@ -651,14 +2176,20 @@ nonisolated final class DorydClient: @unchecked Sendable { if let shares { config["shares"] = shares.map(\.xpcDictionary) } - if let environment { - config["env"] = environment.sorted(by: { $0.key < $1.key }).map { key, value in - [ - "key": key, - "value": value, - ] as NSDictionary + if let typedSettings { + for (rawKey, value) in typedSettings.xpcDictionary { + if let key = rawKey as? String { config[key] = value } + } + } + if let typedSettingsPatch { + precondition(typedSettings == nil, "use either full typed settings or a typed patch") + for (rawKey, value) in typedSettingsPatch.xpcDictionary { + if let key = rawKey as? String { config[key] = value } } } + if let installerMediaAttached { + config["installerMediaAttached"] = installerMediaAttached + } return try await withTimeout(atLeast: 120).statusCommand { proxy, reply in proxy.machineUpdate(machineID, config: config as NSDictionary, reply: reply) } decode: { @@ -666,8 +2197,48 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + /// Reconciles only Dory's managed direct-boot kernel. The daemon reopens, hashes, and copies + /// the private prepared asset atomically; the guest disk and all user data remain untouched. + func machineRefreshManagedDesktopKernel( + _ machineID: String, + sourcePath: String, + sourceSHA256: String + ) async throws -> DorydMachineStatus { + let request: NSDictionary = [ + "sourcePath": sourcePath, + "sourceSHA256": sourceSHA256, + ] + return try await withTimeout(atLeast: 120).statusCommand { proxy, reply in + proxy.machineRefreshManagedDesktopKernel( + machineID, + request: request, + reply: reply + ) + } decode: { + Self.machineStatus(from: $0) + } + } + + func machineDisplayPresentationSet( + _ machineID: String, + presentation: DoryMachineDisplayPresentation + ) async throws -> DorydMachineStatus { + guard presentation.isValid else { + throw DorydClientError.daemon("invalid display presentation preference") + } + return try await withTimeout(atLeast: 10).statusCommand { proxy, reply in + proxy.machineDisplayPresentationSet( + machineID, + presentation: Self.displayPresentationDictionary(presentation), + reply: reply + ) + } decode: { + Self.machineStatus(from: $0) + } + } + func machineDelete(_ machineID: String) async throws -> DorydCommandResult { - try await command { proxy, reply in + try await withTimeout(atLeast: DoryMachineControlTiming.fileMutationSeconds).command { proxy, reply in proxy.machineDelete(machineID, reply: reply) } } @@ -694,94 +2265,373 @@ nonisolated final class DorydClient: @unchecked Sendable { } } - func machineStats(_ machineID: String) async throws -> DorydMachineStats { - try await withTimeout(atLeast: 10).statusCommand { proxy, reply in - proxy.machineStats(machineID, reply: reply) - } decode: { - Self.machineStats(from: $0) + func machineTransfer( + _ machineID: String, + staged: DoryStagedMachineFileTransfer + ) async throws -> DorydMachineFileTransferResult { + let request: NSDictionary = [ + "schema": UInt16(1), + "privateStagingRoot": staged.rootPath, + ] + return try await withTimeout( + atLeast: Self.machineTransferControlTimeout(byteCount: staged.byteCount) + ).statusCommand { proxy, reply in + proxy.machineTransfer(machineID, request: request, reply: reply) + } decode: { dictionary in + guard let result = Self.machineFileTransferResult(from: dictionary), + result.filesSent == staged.fileCount, + result.bytesSent == staged.byteCount else { + return nil + } + return result } } - func machineProvision(_ machineID: String, recipe: String) async throws -> DorydMachineProvisionResult { - try await withTimeout(atLeast: Self.machineProvisionControlTimeout).statusCommand { proxy, reply in - proxy.machineProvision(machineID, request: ["recipe": recipe] as NSDictionary, reply: reply) - } decode: { - Self.machineProvisionResult(from: $0) + func machineTransferStart( + _ machineID: String, + staged: DoryStagedMachineFileTransfer + ) async throws -> DorydMachineFileTransferOperation { + let request: NSDictionary = [ + "schema": UInt16(2), + "privateStagingRoot": staged.rootPath, + ] + return try await withTimeout(atLeast: 10).statusCommand { proxy, reply in + proxy.machineTransferStart(machineID, request: request, reply: reply) + } decode: { dictionary in + guard let operation = Self.machineFileTransferOperation(from: dictionary), + operation.machineID == machineID else { + return nil + } + return operation } } - func machineSnapshot( + func machineTransferStatus( _ machineID: String, - note: String = "", - createdISO: String, - snapshotID: String? = nil - ) async throws -> DorydMachineSnapshot { - var request: [String: Any] = [ - "note": note, - "createdISO": createdISO, - ] - if let snapshotID { - request["snapshotID"] = snapshotID + operationID: String + ) async throws -> DorydMachineFileTransferOperation { + try await withTimeout(atLeast: 10).statusCommand { proxy, reply in + proxy.machineTransferStatus(machineID, operationID: operationID, reply: reply) + } decode: { dictionary in + guard let operation = Self.machineFileTransferOperation(from: dictionary), + operation.machineID == machineID, + operation.operationID == operationID else { + return nil + } + return operation } - return try await withTimeout(atLeast: 60).statusCommand { proxy, reply in - proxy.machineSnapshot(machineID, request: request as NSDictionary, reply: reply) - } decode: { - Self.machineSnapshot(from: $0) + } + + func machineTransferCurrent( + _ machineID: String + ) async throws -> DorydMachineFileTransferOperation? { + let current: DorydMachineFileTransferCurrent = try await withTimeout( + atLeast: 10 + ).statusCommand { proxy, reply in + proxy.machineTransferCurrent(machineID, reply: reply) + } decode: { dictionary in + Self.machineFileTransferCurrent(from: dictionary, machineID: machineID) } + return current.operation } - func machineSnapshots(machineID: String? = nil) async throws -> [DorydMachineSnapshot] { - try await call { proxy, finish in - proxy.machineSnapshots(machineID ?? "") { rows, error in - if !error.isEmpty { - finish(.failure(DorydClientError.daemon(error))) - return - } - guard let snapshots = Self.machineSnapshots(from: rows) else { - finish(.failure(DorydClientError.daemon("invalid machine snapshot list"))) - return - } - finish(.success(snapshots)) + func machineTransferCancel( + _ machineID: String, + operationID: String + ) async throws -> DorydMachineFileTransferOperation { + try await withTimeout(atLeast: 10).statusCommand { proxy, reply in + proxy.machineTransferCancel(machineID, operationID: operationID, reply: reply) + } decode: { dictionary in + guard let operation = Self.machineFileTransferOperation(from: dictionary), + operation.machineID == machineID, + operation.operationID == operationID else { + return nil } + return operation } } - func machineCloneSnapshot(machineID: String, snapshotID: String, newID: String) async throws -> DorydMachineStatus { - try await withTimeout(atLeast: 120).statusCommand { proxy, reply in - proxy.machineCloneSnapshot(machineID, snapshotID: snapshotID, newID: newID, reply: reply) - } decode: { - Self.machineStatus(from: $0) + func machineGuestExportStart( + _ machineID: String, + guestSource: String + ) async throws -> DorydMachineGuestFileExportOperation { + let request: NSDictionary = [ + "schema": UInt16(1), + "guestSource": guestSource, + ] + let accepted: DorydMachineGuestFileExportOperation = try await withTimeout( + atLeast: 10 + ).statusCommand { proxy, reply in + proxy.machineGuestExportStart(machineID, request: request, reply: reply) + } decode: { dictionary in + guard let operation = Self.machineGuestFileExportOperation( + from: dictionary, + allowsOmittedCompletedResult: true + ), operation.machineID == machineID else { + return nil + } + return operation } + if accepted.phase == .completed, accepted.result == nil { + return try await machineGuestExportStatus( + machineID, + operationID: accepted.operationID + ) + } + return accepted } - func machineRestoreSnapshot(machineID: String, snapshotID: String) async throws -> DorydMachineStatus { - try await withTimeout(atLeast: 120).statusCommand { proxy, reply in - proxy.machineRestoreSnapshot(machineID, snapshotID: snapshotID, reply: reply) - } decode: { - Self.machineStatus(from: $0) + func machineGuestExportStatus( + _ machineID: String, + operationID: String + ) async throws -> DorydMachineGuestFileExportOperation { + try await withTimeout(atLeast: 10).statusCommand { proxy, reply in + proxy.machineGuestExportStatus(machineID, operationID: operationID, reply: reply) + } decode: { dictionary in + guard let operation = Self.machineGuestFileExportOperation(from: dictionary), + operation.machineID == machineID, + operation.operationID == operationID else { + return nil + } + return operation } } - func machineDeleteSnapshot(machineID: String, snapshotID: String) async throws -> DorydCommandResult { - try await command { proxy, reply in - proxy.machineDeleteSnapshot(machineID, snapshotID: snapshotID, reply: reply) + func machineGuestExportCurrent( + _ machineID: String + ) async throws -> DorydMachineGuestFileExportOperation? { + let current: DorydMachineGuestFileExportCurrent = try await withTimeout( + atLeast: 10 + ).statusCommand { proxy, reply in + proxy.machineGuestExportCurrent(machineID, reply: reply) + } decode: { dictionary in + Self.machineGuestFileExportCurrent(from: dictionary, machineID: machineID) } + return current.operation } - func machineExportSnapshot(machineID: String, snapshotID: String, to path: String) async throws -> DorydCommandResult { - try await withTimeout(atLeast: 120).command { proxy, reply in + func machineGuestExportCancel( + _ machineID: String, + operationID: String + ) async throws -> DorydMachineGuestFileExportOperation { + try await withTimeout(atLeast: 10).statusCommand { proxy, reply in + proxy.machineGuestExportCancel(machineID, operationID: operationID, reply: reply) + } decode: { dictionary in + guard let operation = Self.machineGuestFileExportOperation(from: dictionary), + operation.machineID == machineID, + operation.operationID == operationID else { + return nil + } + return operation + } + } + + func machineGuestExportDiscard( + _ machineID: String, + operationID: String + ) async throws -> DorydCommandResult { + try await command { proxy, reply in + proxy.machineGuestExportDiscard( + machineID, + operationID: operationID, + reply: reply + ) + } + } + + func machineStats(_ machineID: String) async throws -> DorydMachineStats { + try await withTimeout(atLeast: 10).statusCommand { proxy, reply in + proxy.machineStats(machineID, reply: reply) + } decode: { + Self.machineStats(from: $0) + } + } + + func machineDeviceTelemetry(_ machineID: String) async throws + -> DorydDeviceTelemetrySnapshot { + try await withTimeout(atLeast: 10).statusCommand { proxy, reply in + proxy.machineDeviceTelemetry(machineID, reply: reply) + } decode: { + Self.machineDeviceTelemetry(from: $0, machineID: machineID) + } + } + + func machineUSBAttach( + _ machineID: String, + busID: String + ) async throws -> DorydMachineUSBAttachment { + try await withTimeout(atLeast: 30).statusCommand { proxy, reply in + proxy.machineUSBAttach(machineID, busID: busID, reply: reply) + } decode: { + Self.machineUSBAttachment( + from: $0, + expectedMachineID: machineID, + expectedBusID: busID + ) + } + } + + func hostUSBDevices() async throws -> [DorydHostUSBDevice] { + try await call { proxy, finish in + proxy.hostUSBDevices { ok, rows, message in + guard ok else { + finish(.failure(DorydClientError.daemon(message))) + return + } + guard let devices = Self.hostUSBDevices(from: rows) else { + finish(.failure(DorydClientError.daemon( + message.isEmpty ? "invalid host USB device list" : message + ))) + return + } + finish(.success(devices)) + } + } + } + + func machineUSBDetach(_ machineID: String, busID: String) async throws { + _ = try await withTimeout(atLeast: 30).statusCommand { proxy, reply in + proxy.machineUSBDetach(machineID, busID: busID, reply: reply) + } decode: { response -> Bool? in + guard let keys = response.allKeys as? [String], + Set(keys) == ["machineID", "busID"], + response["machineID"] as? String == machineID, + response["busID"] as? String == busID else { + return nil + } + return true + } + } + + func machineProvision(_ machineID: String, recipe: String) async throws -> DorydMachineProvisionResult { + try await withTimeout(atLeast: Self.machineProvisionControlTimeout).statusCommand { proxy, reply in + proxy.machineProvision(machineID, request: ["recipe": recipe] as NSDictionary, reply: reply) + } decode: { + Self.machineProvisionResult(from: $0) + } + } + + func machineDesktopUpdate( + _ machineID: String, + operationID: UUID = UUID(), + distro: String, + version: String, + distributionInstallationName: String, + runtimeInstallationName: String + ) async throws -> DorydDesktopUpdateResult { + let request: NSDictionary = [ + "operationID": operationID.uuidString.lowercased(), + "distro": distro, + "version": version, + "distributionInstallationName": distributionInstallationName, + "runtimeInstallationName": runtimeInstallationName, + ] + return try await withTimeout(atLeast: 3_900).statusCommand { proxy, reply in + proxy.machineDesktopUpdate(machineID, request: request, reply: reply) + } decode: { + Self.desktopUpdateResult(from: $0) + } + } + + func machineSnapshot( + _ machineID: String, + note: String = "", + createdISO: String, + snapshotID: String? = nil + ) async throws -> DorydMachineSnapshot { + var request: [String: Any] = [ + "note": note, + "createdISO": createdISO, + ] + if let snapshotID { + request["snapshotID"] = snapshotID + } + return try await withTimeout(atLeast: 60).statusCommand { proxy, reply in + proxy.machineSnapshot(machineID, request: request as NSDictionary, reply: reply) + } decode: { + Self.machineSnapshot(from: $0) + } + } + + func machineSnapshots(machineID: String? = nil) async throws -> [DorydMachineSnapshot] { + try await call { proxy, finish in + proxy.machineSnapshots(machineID ?? "") { rows, error in + if !error.isEmpty { + finish(.failure(DorydClientError.daemon(error))) + return + } + guard let snapshots = Self.machineSnapshots(from: rows) else { + finish(.failure(DorydClientError.daemon("invalid machine snapshot list"))) + return + } + finish(.success(snapshots)) + } + } + } + + func machineCloneSnapshot(machineID: String, snapshotID: String, newID: String) async throws -> DorydMachineStatus { + try await withTimeout(atLeast: 120).statusCommand { proxy, reply in + proxy.machineCloneSnapshot(machineID, snapshotID: snapshotID, newID: newID, reply: reply) + } decode: { + Self.machineStatus(from: $0) + } + } + + func machineRestoreSnapshot(machineID: String, snapshotID: String) async throws -> DorydMachineStatus { + try await withTimeout(atLeast: 120).statusCommand { proxy, reply in + proxy.machineRestoreSnapshot(machineID, snapshotID: snapshotID, reply: reply) + } decode: { + Self.machineStatus(from: $0) + } + } + + func machineDeleteSnapshot(machineID: String, snapshotID: String) async throws -> DorydCommandResult { + try await command { proxy, reply in + proxy.machineDeleteSnapshot(machineID, snapshotID: snapshotID, reply: reply) + } + } + + func machineExportSnapshot(machineID: String, snapshotID: String, to path: String) async throws -> DorydCommandResult { + try await withTimeout(atLeast: 120).command { proxy, reply in proxy.machineExportSnapshot(machineID, snapshotID: snapshotID, path: path, reply: reply) } } - func machineImportSnapshot(from path: String) async throws -> DorydMachineSnapshot { + func machineAssessSnapshotImport( + from path: String + ) async throws -> DorydMachineImportAssessment { + try await withTimeout(atLeast: 120).statusCommand { proxy, reply in + proxy.machineAssessSnapshotImport(path, reply: reply) + } decode: { + Self.machineImportAssessment(from: $0) + } + } + + func machineImportSnapshot( + from path: String, + expectedContentID: String + ) async throws -> DorydMachineSnapshot { try await withTimeout(atLeast: 120).statusCommand { proxy, reply in - proxy.machineImportSnapshot(path, reply: reply) + proxy.machineImportSnapshot( + path, + expectedContentID: expectedContentID, + reply: reply + ) } decode: { Self.machineSnapshot(from: $0) } } + /// Compatibility entry for older call sites. New product flows assess first and use the + /// content-bound overload above. + func machineImportSnapshot(from path: String) async throws -> DorydMachineSnapshot { + let assessment = try await machineAssessSnapshotImport(from: path) + return try await machineImportSnapshot( + from: path, + expectedContentID: assessment.contentID + ) + } + func machineBackupSchedules() async throws -> [DorydMachineBackupStatus] { try await call { proxy, finish in proxy.machineBackupSchedules { rows, error in @@ -820,6 +2670,67 @@ nonisolated final class DorydClient: @unchecked Sendable { } } + func machineEvents(afterSequence: UInt64) async throws -> DorydMachineEventBatch { + try await statusCommand { proxy, reply in + proxy.machineEvents(afterSequence, reply: reply) + } decode: { + Self.machineEventBatch(from: $0, afterSequence: afterSequence) + } + } + + func machineFlightRecorder( + machineID: String, + afterSequence: UInt64 + ) async throws -> DorydMachineFlightRecorderBatch { + try await statusCommand { proxy, reply in + proxy.machineFlightRecorder( + machineID, + afterSequence: afterSequence, + reply: reply + ) + } decode: { + Self.machineFlightRecorderBatch( + from: $0, + machineID: machineID, + afterSequence: afterSequence + ) + } + } + + func machineSerialConsole( + machineID: String, + cursor: DorydMachineSerialConsoleCursor = .init(), + limit: UInt32 = 64 * 1_024 + ) async throws -> DorydMachineSerialConsoleBatch { + try await statusCommand { proxy, reply in + proxy.machineSerialConsoleRead( + machineID, + cursor: cursor.xpcDictionary, + limit: limit, + reply: reply + ) + } decode: { + Self.machineSerialConsoleBatch( + from: $0, + machineID: machineID, + cursor: cursor, + limit: limit + ) + } + } + + func writeMachineSerialConsole( + machineID: String, + data: Data + ) async throws -> DorydCommandResult { + guard !data.isEmpty, data.count <= 4 * 1_024 else { + throw DorydClientError.daemon("machine serial console input is invalid") + } + return try await command { proxy, reply in + proxy.machineSerialConsoleWrite(machineID, data: data as NSData, reply: reply) + } + } + func machineList() async throws -> [DorydMachineStatus] { try await call { proxy, finish in proxy.machineList { rows, error in @@ -1138,7 +3049,71 @@ nonisolated final class DorydClient: @unchecked Sendable { nonisolated private static func machineStatus(from dictionary: NSDictionary) -> DorydMachineStatus? { guard let id = dictionary["id"] as? String, - let state = dictionary["state"] as? String else { + let state = dictionary["state"] as? String, + let runtimeIdentity = machineRuntimeIdentity(from: dictionary) else { + return nil + } + guard let runtimeGraphicsSelection = machineRuntimeGraphicsSelection( + from: dictionary["runtimeGraphicsSelection"], + state: state, + runtimeIdentity: runtimeIdentity + ) else { return nil } + let environment = machineEnvironment(from: dictionary["env"]) + guard let typedSettings = machineTypedSettings(from: dictionary) else { + return nil + } + guard let displayPresentation = machineDisplayPresentation( + from: dictionary["displayPresentation"] + ) else { return nil } + guard let installedDesktopPayloadReceipt = machineInstalledDesktopPayloadReceipt( + from: dictionary, + legacyEnvironment: environment + ) else { + return nil + } + guard let cloneReceipt = machineCloneReceipt(from: dictionary["cloneReceipt"]) else { + return nil + } + guard let agentHandshake = machineAgentHandshake(from: dictionary) else { + return nil + } + guard let shares = machineShares(from: dictionary["shares"]) else { + return nil + } + guard let savedState = machineSavedState(from: dictionary["savedState"]), + state != "suspended" || savedState.value != nil, + savedState.value == nil || ["suspended", "starting", "running"].contains(state) else { + return nil + } + guard let failure = machineFailure(from: dictionary["failure"]), + let activeOperation = machineActiveOperation( + from: dictionary["activeOperation"] + ), + let flightRecorder = machineFlightRecorderSummary( + from: dictionary["flightRecorder"] + ) else { + return nil + } + let displayMode = (dictionary["displayMode"] as? String) + .flatMap(MachineDisplayMode.init(rawValue:)) ?? .headless + let agentBuild = nonEmptyString(dictionary["agentBuild"]) + let clipboardPolicy = typedSettings.value?.clipboardPolicy + ?? (displayMode == .desktop + ? DoryVMClipboardPolicy.legacyDesktop(.bidirectional) + : .disabled) + guard let integrationHealth = machineIntegrationHealth( + from: dictionary, + machineIsRunning: state == "running", + desktopIntegrationsExpected: displayMode == .desktop, + clipboardTextExpected: clipboardPolicy.text != .off, + clipboardImageExpected: clipboardPolicy.image != .off, + sharedFoldersExpected: !shares.isEmpty, + expectedRuntimeIdentityMode: runtimeIdentity.mode, + expectedAgentBuild: state == "running" && agentHandshake.protocolVersion != nil + ? agentBuild : nil, + expectedAgentProtocolVersion: state == "running" + ? agentHandshake.protocolVersion : nil + ) else { return nil } return DorydMachineStatus( @@ -1146,8 +3121,15 @@ nonisolated final class DorydClient: @unchecked Sendable { state: state, pid: int32(dictionary["pid"]), lastError: nonEmptyString(dictionary["lastError"]), + failure: failure.value, + activeOperation: activeOperation.value, + flightRecorderHeadSequence: flightRecorder.headSequence, + flightRecorderAvailable: flightRecorder.available, handoffSocketPath: nonEmptyString(dictionary["handoffSocketPath"]), - agentBuild: nonEmptyString(dictionary["agentBuild"]), + agentBuild: agentBuild, + agentProtocolVersion: agentHandshake.protocolVersion, + agentCapabilities: agentHandshake.capabilities, + integrationHealth: integrationHealth.value, agentSocketPath: nonEmptyString(dictionary["agentSocketPath"]), dockerdSocketPath: nonEmptyString(dictionary["dockerdSocketPath"]), shellSocketPath: nonEmptyString(dictionary["shellSocketPath"]), @@ -1159,123 +3141,1951 @@ nonisolated final class DorydClient: @unchecked Sendable { memoryMB: uint64(dictionary["memoryMB"]), currentBalloonTargetMB: uint64(dictionary["currentBalloonTargetMB"]), cpuCount: int(dictionary["cpuCount"]), - displayMode: (dictionary["displayMode"] as? String).flatMap(MachineDisplayMode.init(rawValue:)) ?? .headless, - shares: machineShares(from: dictionary["shares"]), - environment: machineEnvironment(from: dictionary["env"]) + displayMode: displayMode, + bootMode: (dictionary["bootMode"] as? String).flatMap(MachineBootMode.init(rawValue:)) ?? .linuxKernel, + installerMediaAttached: (dictionary["installerMediaAttached"] as? Bool) + ?? (dictionary["installerMediaAttached"] as? NSNumber)?.boolValue + ?? false, + shares: shares, + environment: environment, + typedSettings: typedSettings.value, + displayPresentation: displayPresentation, + runtimeIdentity: runtimeIdentity, + runtimeGraphicsSelection: runtimeGraphicsSelection.value, + installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value, + cloneReceipt: cloneReceipt.value, + savedState: savedState.value ) } - nonisolated private static func machineShares(from value: Any?) -> [DorydMachineShareConfiguration] { - let rows: [NSDictionary] - if let swiftRows = value as? [NSDictionary] { - rows = swiftRows - } else if let nsRows = value as? NSArray { - rows = nsRows.compactMap { $0 as? NSDictionary } - } else { - return [] - } - return rows.compactMap { row in - guard let tag = row["tag"] as? String, - let hostPath = row["hostPath"] as? String, - let guestPath = row["guestPath"] as? String else { + nonisolated private static func machineDisplayPresentation( + from encoded: Any? + ) -> DoryMachineDisplayPresentation? { + guard let encoded else { return .windowed } + guard let dictionary = encoded as? NSDictionary, + dictionary.allKeys.count == 2, + Set(dictionary.allKeys.compactMap { $0 as? String }) + == Set(["schemaVersion", "assignments"]), + let schema = dictionary["schemaVersion"] as? NSNumber, + String(cString: schema.objCType) != "c", + schema.intValue == DoryMachineDisplayPresentation.currentSchemaVersion, + let rows = dictionary["assignments"] as? NSArray else { return nil } + var assignments: [DoryGuestDisplayPresentationAssignment] = [] + assignments.reserveCapacity(rows.count) + for encodedRow in rows { + guard let row = encodedRow as? NSDictionary, + let guestDisplayID = row["guestDisplayID"] as? String, + let rawMode = row["mode"] as? String, + let mode = DoryGuestDisplayPresentationMode(rawValue: rawMode) else { return nil } - let readOnly = (row["readOnly"] as? Bool) - ?? (row["readOnly"] as? NSNumber)?.boolValue - ?? ((row["mode"] as? String) == "ro") - return DorydMachineShareConfiguration( - tag: tag, - hostPath: hostPath, - guestPath: guestPath, - readOnly: readOnly + let expected: Set = mode == .dedicatedFullscreen + ? ["guestDisplayID", "mode", "hostDisplayUUID"] + : ["guestDisplayID", "mode"] + guard row.allKeys.count == expected.count, + Set(row.allKeys.compactMap { $0 as? String }) == expected else { return nil } + let assignment = DoryGuestDisplayPresentationAssignment( + guestDisplayID: guestDisplayID, + mode: mode, + hostDisplayUUID: row["hostDisplayUUID"] as? String ) + guard assignment.isValid else { return nil } + assignments.append(assignment) } + let presentation = DoryMachineDisplayPresentation( + schemaVersion: schema.intValue, + assignments: assignments + ) + return presentation.isValid ? presentation.canonicalized : nil } - nonisolated private static func machineEnvironment(from value: Any?) -> [String: String] { - let rows: [NSDictionary] - if let swiftRows = value as? [NSDictionary] { - rows = swiftRows - } else if let nsRows = value as? NSArray { - rows = nsRows.compactMap { $0 as? NSDictionary } - } else { - return [:] + nonisolated private static func displayPresentationDictionary( + _ presentation: DoryMachineDisplayPresentation + ) -> NSDictionary { + [ + "schemaVersion": presentation.schemaVersion, + "assignments": presentation.canonicalized.assignments.map { assignment in + var row: [String: Any] = [ + "guestDisplayID": assignment.guestDisplayID, + "mode": assignment.mode.rawValue, + ] + if let hostDisplayUUID = assignment.hostDisplayUUID { + row["hostDisplayUUID"] = hostDisplayUUID + } + return row as NSDictionary + } as NSArray, + ] + } + + private struct ParsedMachineCloneReceipt { + var value: DorydMachineCloneReceipt? + } + + nonisolated private static func machineCloneReceipt( + from encoded: Any? + ) -> ParsedMachineCloneReceipt? { + guard let encoded else { return ParsedMachineCloneReceipt(value: nil) } + guard let value = encoded as? NSDictionary, + let keys = value.allKeys as? [String], + keys.count == 7, + Set(keys) == [ + "schemaVersion", "sourceMachineID", "sourceSnapshotID", + "sourceRootfsSHA256", "sourceRootfsByteCount", "storageMode", + "createdAtUnixMilliseconds", + ], + strictUInt64(value["schemaVersion"]) == 1, + let sourceMachineID = value["sourceMachineID"] as? String, + sourceMachineID.isSafeMachineIdentifier, + let sourceSnapshotID = value["sourceSnapshotID"] as? String, + sourceSnapshotID.isSafeMachineIdentifier, + let sourceRootfsSHA256 = value["sourceRootfsSHA256"] as? String, + sourceRootfsSHA256.isLowercaseSHA256, + let sourceRootfsByteCount = strictUInt64(value["sourceRootfsByteCount"]), + sourceRootfsByteCount > 0, + value["storageMode"] as? String == "apfs-copy-on-write", + let createdAtUnixMilliseconds = int64(value["createdAtUnixMilliseconds"]), + createdAtUnixMilliseconds > 0 else { + return nil } - var result: [String: String] = [:] - for row in rows { - guard let key = row["key"] as? String, - key.wholeMatch(of: /[A-Za-z_][A-Za-z0-9_]*/) != nil else { - continue + return ParsedMachineCloneReceipt(value: DorydMachineCloneReceipt( + sourceMachineID: sourceMachineID, + sourceSnapshotID: sourceSnapshotID, + sourceRootfsSHA256: sourceRootfsSHA256, + sourceRootfsByteCount: sourceRootfsByteCount, + storageMode: "apfs-copy-on-write", + createdAtUnixMilliseconds: createdAtUnixMilliseconds + )) + } + + nonisolated private static func machineUSBAttachment( + from dictionary: NSDictionary, + expectedMachineID: String, + expectedBusID: String + ) -> DorydMachineUSBAttachment? { + let expectedKeys: Set = [ + "machineID", "busID", "port", "vsockPort", "deviceID", "speed", + ] + guard let keys = dictionary.allKeys as? [String], + Set(keys) == expectedKeys, + keys.count == expectedKeys.count, + dictionary["machineID"] as? String == expectedMachineID, + dictionary["busID"] as? String == expectedBusID, + let port = exactUnsignedInteger(dictionary["port"], maximum: 65_535), + let vsockPort = exactUnsignedInteger( + dictionary["vsockPort"], maximum: UInt64(UInt32.max) + ), vsockPort == 1_025, + let deviceID = exactUnsignedInteger( + dictionary["deviceID"], maximum: UInt64(UInt32.max) + ), deviceID > 0, + let speed = exactUnsignedInteger( + dictionary["speed"], maximum: UInt64(UInt32.max) + ), speed > 0 else { + return nil + } + return DorydMachineUSBAttachment( + machineID: expectedMachineID, + busID: expectedBusID, + port: Int(port), + vsockPort: UInt32(vsockPort), + deviceID: UInt32(deviceID), + speed: UInt32(speed) + ) + } + + nonisolated private static func hostUSBDevices( + from rows: NSArray + ) -> [DorydHostUSBDevice]? { + guard rows.count <= 256 else { return nil } + var devices: [DorydHostUSBDevice] = [] + var busIDs = Set() + for raw in rows { + guard let dictionary = raw as? NSDictionary, + let device = hostUSBDevice(from: dictionary), + busIDs.insert(device.busID).inserted else { + return nil + } + devices.append(device) + } + guard devices == devices.sorted(by: { $0.busID < $1.busID }) else { return nil } + return devices + } + + nonisolated private static func hostUSBDevice( + from dictionary: NSDictionary + ) -> DorydHostUSBDevice? { + let expectedKeys: Set = [ + "busID", "vendorID", "productID", "vendorName", "productName", "deviceClass", "speed", + ] + guard let keys = dictionary.allKeys as? [String], + Set(keys) == expectedKeys, + keys.count == expectedKeys.count, + let busID = dictionary["busID"] as? String, + isValidUSBBusID(busID), + let vendorID = exactUnsignedInteger(dictionary["vendorID"], maximum: UInt64(UInt16.max)), + let productID = exactUnsignedInteger(dictionary["productID"], maximum: UInt64(UInt16.max)), + let vendorName = dictionary["vendorName"] as? String, + isValidUSBDisplayName(vendorName), + let productName = dictionary["productName"] as? String, + isValidUSBDisplayName(productName), + let deviceClass = exactUnsignedInteger(dictionary["deviceClass"], maximum: UInt64(UInt8.max)), + let speed = exactUnsignedInteger(dictionary["speed"], maximum: UInt64(UInt32.max)) else { + return nil + } + return DorydHostUSBDevice( + busID: busID, + vendorID: UInt16(vendorID), + productID: UInt16(productID), + vendorName: vendorName, + productName: productName, + deviceClass: UInt8(deviceClass), + speed: UInt32(speed) + ) + } + + nonisolated private static func isValidUSBBusID(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard (1..<32).contains(bytes.count), + bytes.first.map({ (48...57).contains($0) }) == true, + bytes.last.map({ (48...57).contains($0) }) == true else { + return false + } + return bytes.allSatisfy { (48...57).contains($0) || $0 == 45 || $0 == 46 } + } + + nonisolated private static func isValidUSBDisplayName(_ value: String) -> Bool { + value.utf8.count <= 128 + && value.unicodeScalars.allSatisfy { + !CharacterSet.controlCharacters.contains($0) + } + } + + nonisolated private static func exactUnsignedInteger( + _ raw: Any?, + maximum: UInt64 + ) -> UInt64? { + guard let number = raw as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue.isFinite, + number.doubleValue >= 0, + number.doubleValue.rounded(.towardZero) == number.doubleValue, + number.doubleValue <= Double(maximum) else { + return nil + } + let value = number.uint64Value + return value <= maximum && Double(value) == number.doubleValue ? value : nil + } + + private struct ParsedMachineFailure { + var value: DorydMachineFailure? + } + + nonisolated private static func machineFailure( + from raw: Any? + ) -> ParsedMachineFailure? { + guard let raw else { return ParsedMachineFailure(value: nil) } + guard let dictionary = raw as? NSDictionary, + let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count else { + return nil + } + let requiredKeys: Set = [ + "schemaVersion", "code", "occurredAtUnixMilliseconds", "causalChain", + "recoveryDisposition", "evidenceReferences", + ] + let keys = Set(rawKeys) + guard keys == requiredKeys || keys == requiredKeys.union(["operationID"]), + strictUInt64(dictionary["schemaVersion"]) == 1, + let rawCode = dictionary["code"] as? String, + let code = DorydMachineFailureCode(rawValue: rawCode), + let occurred = strictInt64(dictionary["occurredAtUnixMilliseconds"]), + occurred > 0, + let rawCauses = dictionary["causalChain"] as? [String], + !rawCauses.isEmpty, rawCauses.count <= 8, + rawCauses.count == (dictionary["causalChain"] as? NSArray)?.count, + let causes = Optional(rawCauses.compactMap( + DorydMachineFailureCauseCode.init(rawValue:) + )), causes.count == rawCauses.count, + let rawRecovery = dictionary["recoveryDisposition"] as? String, + let recovery = DorydMachineRecoveryDisposition(rawValue: rawRecovery), + let rawEvidence = dictionary["evidenceReferences"] as? NSArray, + rawEvidence.count <= 16 else { + return nil + } + var evidence: [DorydMachineFailureEvidenceReference] = [] + for row in rawEvidence { + guard let row = row as? NSDictionary, + let evidenceKeys = row.allKeys as? [String], + evidenceKeys.count == row.allKeys.count, + Set(evidenceKeys) == ["kind", "identifier"], + let rawKind = row["kind"] as? String, + let kind = DorydMachineFailureEvidenceKind(rawValue: rawKind), + let identifier = row["identifier"] as? String, + identifier.utf8.count <= 256, + identifier.wholeMatch( + of: /[A-Za-z0-9][A-Za-z0-9._:@+-]{0,255}/ + ) != nil else { + return nil + } + evidence.append(.init(kind: kind, identifier: identifier)) + } + guard Set(evidence).count == evidence.count else { return nil } + let operationID = dictionary["operationID"] as? String + guard dictionary["operationID"] == nil || operationID != nil, + operationID.map(isMachineOperationID) ?? true else { + return nil + } + return ParsedMachineFailure(value: DorydMachineFailure( + schemaVersion: 1, + code: code, + occurredAtUnixMilliseconds: occurred, + operationID: operationID, + causalChain: causes, + recoveryDisposition: recovery, + evidenceReferences: evidence + )) + } + + private struct ParsedMachineActiveOperation { + var value: DorydMachineOperationSummary? + } + + private struct ParsedMachineFlightRecorderSummary { + var headSequence: UInt64 + var available: Bool + } + + nonisolated private static func machineFlightRecorderSummary( + from raw: Any? + ) -> ParsedMachineFlightRecorderSummary? { + // Absence means an older daemon; never claim recorder availability without evidence. + guard let raw else { + return ParsedMachineFlightRecorderSummary( + headSequence: 0, + available: false + ) + } + guard let dictionary = raw as? NSDictionary, + let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys) == ["headSequence", "available"], + let headSequence = strictUInt64(dictionary["headSequence"]), + let available = dictionary["available"] as? NSNumber, + CFGetTypeID(available) == CFBooleanGetTypeID() else { + return nil + } + return ParsedMachineFlightRecorderSummary( + headSequence: headSequence, + available: available.boolValue + ) + } + + nonisolated private static func machineActiveOperation( + from raw: Any? + ) -> ParsedMachineActiveOperation? { + guard let raw else { return ParsedMachineActiveOperation(value: nil) } + guard let dictionary = raw as? NSDictionary, + let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys) == ["operationID", "kind"], + let operationID = dictionary["operationID"] as? String, + isMachineOperationID(operationID), + let rawKind = dictionary["kind"] as? String, + let kind = DorydMachineOperationKind(rawValue: rawKind) else { + return nil + } + return ParsedMachineActiveOperation(value: .init( + operationID: operationID, + kind: kind + )) + } + + nonisolated private static func isMachineOperationID(_ value: String) -> Bool { + value.wholeMatch( + of: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ + ) != nil + } + + private struct ParsedMachineSavedState { + var value: DorydMachineSavedStateSummary? + } + + nonisolated private static func machineSavedState( + from raw: Any? + ) -> ParsedMachineSavedState? { + guard let raw else { return ParsedMachineSavedState(value: nil) } + let expectedKeys: Set = Set([ + "schemaVersion", "backend", "stateFileSHA256", "stateFileByteCount", + "hostHardwareModel", "hostOperatingSystemBuild", + "createdAtUnixMilliseconds", "portable", + ]) + guard let dictionary = raw as? NSDictionary, + let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys) == expectedKeys, + strictUInt64(dictionary["schemaVersion"]) == 1, + dictionary["backend"] as? String == "apple-virtualization-framework", + let digest = dictionary["stateFileSHA256"] as? String, + digest.count == 64, + digest.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }), + let byteCount = strictUInt64(dictionary["stateFileByteCount"]), byteCount > 0, + let hostModel = nonEmptyString(dictionary["hostHardwareModel"]), + hostModel.utf8.count <= 256, !hostModel.contains("\0"), + let hostBuild = nonEmptyString(dictionary["hostOperatingSystemBuild"]), + hostBuild.utf8.count <= 256, !hostBuild.contains("\0"), + let created = strictInt64(dictionary["createdAtUnixMilliseconds"]), created > 0, + let portable = dictionary["portable"] as? Bool, portable == false else { + return nil + } + return ParsedMachineSavedState(value: DorydMachineSavedStateSummary( + stateFileSHA256: digest, + stateFileByteCount: byteCount, + hostHardwareModel: hostModel, + hostOperatingSystemBuild: hostBuild, + createdAtUnixMilliseconds: created + )) + } + + private struct ParsedMachineAgentHandshake { + var protocolVersion: UInt32? + var capabilities: [DorydAgentCapability] + } + + private struct ParsedMachineIntegrationHealth { + var value: DoryGuestIntegrationHealth? + } + + /// Older daemons may omit the projection. Once present, this is a capability claim used for + /// repair and product support decisions, so malformed/future shapes reject the machine row. + nonisolated private static func machineIntegrationHealth( + from dictionary: NSDictionary, + machineIsRunning: Bool, + desktopIntegrationsExpected: Bool, + clipboardTextExpected: Bool, + clipboardImageExpected: Bool, + sharedFoldersExpected: Bool, + expectedRuntimeIdentityMode: String, + expectedAgentBuild: String?, + expectedAgentProtocolVersion: UInt32? + ) -> ParsedMachineIntegrationHealth? { + guard let encoded = dictionary["integrationHealth"] else { + return ParsedMachineIntegrationHealth(value: nil) + } + guard let value = encoded as? NSDictionary, + let rawKeys = value.allKeys as? [String], + rawKeys.count == Set(rawKeys).count, + Set(rawKeys).isSuperset(of: [ + "schemaVersion", "state", "runtimeAuthority", "features", + ]), + Set(rawKeys).isSubset(of: [ + "schemaVersion", "state", "runtimeAuthority", "agentBuild", + "agentProtocolVersion", "features", + ]), + let schema = strictUInt64(value["schemaVersion"]), + schema <= UInt16.max, + let rawState = value["state"] as? String, + let state = DoryGuestIntegrationHealthState(rawValue: rawState), + let rawAuthority = value["runtimeAuthority"] as? String, + let authority = DoryGuestIntegrationRuntimeAuthority(rawValue: rawAuthority), + let rawFeatures = value["features"] as? NSArray else { + return nil + } + let agentBuild: String? + if let encodedBuild = value["agentBuild"] { + guard let build = encodedBuild as? String else { return nil } + agentBuild = build + } else { + agentBuild = nil + } + let agentProtocolVersion: UInt32? + if let encodedProtocol = value["agentProtocolVersion"] { + guard let version = strictUInt64(encodedProtocol), + version <= UInt32.max else { return nil } + agentProtocolVersion = UInt32(version) + } else { + agentProtocolVersion = nil + } + + var features: [DoryGuestIntegrationFeatureHealth] = [] + features.reserveCapacity(rawFeatures.count) + for rawFeature in rawFeatures { + guard let feature = rawFeature as? NSDictionary, + let keys = feature.allKeys as? [String], + keys.count == Set(keys).count, + Set(keys).isSuperset(of: ["id", "provider", "required", "state"]), + Set(keys).isSubset(of: [ + "id", "provider", "required", "minimumVersion", + "negotiatedVersion", "state", + ]), + let rawID = feature["id"] as? String, + let id = DoryGuestIntegrationCapabilityID(rawValue: rawID), + let rawProvider = feature["provider"] as? String, + let provider = DoryGuestIntegrationFeatureProvider(rawValue: rawProvider), + let requiredNumber = feature["required"] as? NSNumber, + CFGetTypeID(requiredNumber) == CFBooleanGetTypeID(), + let rawFeatureState = feature["state"] as? String, + let featureState = DoryGuestIntegrationFeatureState( + rawValue: rawFeatureState + ) else { + return nil + } + func version(_ key: String) -> UInt32? { + guard let encoded = feature[key], + let value = strictUInt64(encoded), + value <= UInt32.max else { return nil } + return UInt32(value) + } + let minimumVersion = feature["minimumVersion"] == nil + ? nil : version("minimumVersion") + let negotiatedVersion = feature["negotiatedVersion"] == nil + ? nil : version("negotiatedVersion") + if feature["minimumVersion"] != nil && minimumVersion == nil { return nil } + if feature["negotiatedVersion"] != nil && negotiatedVersion == nil { return nil } + features.append(DoryGuestIntegrationFeatureHealth( + id: id, + provider: provider, + required: requiredNumber.boolValue, + minimumVersion: minimumVersion, + negotiatedVersion: negotiatedVersion, + state: featureState + )) + } + let health = DoryGuestIntegrationHealth( + schemaVersion: UInt16(schema), + state: state, + runtimeAuthority: authority, + agentBuild: agentBuild, + agentProtocolVersion: agentProtocolVersion, + features: features + ) + let expectedAuthority: DoryGuestIntegrationRuntimeAuthority + switch expectedRuntimeIdentityMode { + case "resolved-plan": expectedAuthority = .resolvedPlan + case "requires-replanning": expectedAuthority = .requiresReplanning + case "legacy-compatibility": expectedAuthority = .legacyCompatibility + default: return nil + } + guard health.isValid( + desktopIntegrationsExpected: desktopIntegrationsExpected, + clipboardTextExpected: clipboardTextExpected, + clipboardImageExpected: clipboardImageExpected, + sharedFoldersExpected: sharedFoldersExpected + ), + health.runtimeAuthority == expectedAuthority, + health.agentBuild == expectedAgentBuild, + health.agentProtocolVersion == expectedAgentProtocolVersion, + machineIsRunning ? health.state != .inactive : health.state == .inactive else { + return nil + } + return ParsedMachineIntegrationHealth(value: health) + } + + nonisolated private static func machineAgentHandshake( + from dictionary: NSDictionary + ) -> ParsedMachineAgentHandshake? { + let encodedProtocol = dictionary["agentProtocolVersion"] + let encodedCapabilities = dictionary["agentCapabilities"] + guard encodedProtocol != nil || encodedCapabilities != nil else { + return ParsedMachineAgentHandshake(protocolVersion: nil, capabilities: []) + } + guard let encodedProtocol, + dictionary["agentBuild"] is String, + let number = encodedProtocol as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue.rounded(.towardZero) == number.doubleValue, + number.uint64Value > 0, + number.uint64Value <= UInt64(UInt32.max) else { + return nil + } + let protocolVersion = UInt32(number.uint64Value) + guard let encodedCapabilities else { + return ParsedMachineAgentHandshake(protocolVersion: protocolVersion, capabilities: []) + } + guard let capabilities = agentCapabilities(from: encodedCapabilities) else { return nil } + return ParsedMachineAgentHandshake( + protocolVersion: protocolVersion, + capabilities: capabilities + ) + } + + nonisolated private static func agentCapabilities( + from encodedCapabilities: Any + ) -> [DorydAgentCapability]? { + guard let rawCapabilities = encodedCapabilities as? NSArray else { return nil } + var capabilities: [DorydAgentCapability] = [] + capabilities.reserveCapacity(rawCapabilities.count) + for encoded in rawCapabilities { + guard let raw = encoded as? NSDictionary, + let keys = raw.allKeys as? [String], + Set(keys) == ["id", "version"], + keys.count == 2, + let id = raw["id"] as? String, + let versionNumber = raw["version"] as? NSNumber, + CFGetTypeID(versionNumber) != CFBooleanGetTypeID(), + versionNumber.doubleValue.rounded(.towardZero) == versionNumber.doubleValue, + versionNumber.uint64Value > 0, + versionNumber.uint64Value <= UInt64(UInt32.max) else { + return nil + } + let capability = DorydAgentCapability( + id: id, + version: UInt32(versionNumber.uint64Value) + ) + guard capability.isValid else { return nil } + capabilities.append(capability) + } + guard capabilities == capabilities.sorted(by: { $0.id < $1.id }), + Set(capabilities.map(\.id)).count == capabilities.count else { + return nil + } + return capabilities + } + + private struct ParsedMachineTypedSettings { + var value: DorydMachineTypedSettings? + } + + nonisolated private static func machineTypedSettings( + from dictionary: NSDictionary + ) -> ParsedMachineTypedSettings? { + guard let encoded = dictionary["typedSettings"] else { + return ParsedMachineTypedSettings(value: nil) + } + guard let value = encoded as? NSDictionary, + let keys = value.allKeys as? [String], + Set(keys).isSubset(of: [ + "guestIdentityIntent", "clipboardPolicy", + "desktopRuntimePreference", "desktopGraphicsPreference", "networkMode", + "portForwards", "audio", "cameraEnabled", + "intelApplicationTranslationEnabled", + ]), keys.count == Set(keys).count else { + return nil + } + + var identity = DoryVMGuestIdentityIntent.unspecified + if let encodedIdentity = value["guestIdentityIntent"] { + guard let rawIdentity = encodedIdentity as? NSDictionary, + let identityKeys = rawIdentity.allKeys as? [String], + Set(identityKeys).isSubset(of: ["account", "desktop"]), + identityKeys.count == Set(identityKeys).count else { return nil } + if let encodedAccount = rawIdentity["account"] { + guard let raw = encodedAccount as? NSDictionary, + let rawKeys = raw.allKeys as? [String], + Set(rawKeys).isSubset(of: ["username", "numericUserID"]), + rawKeys.count == Set(rawKeys).count else { return nil } + let username: String? + if let encoded = raw["username"] { + guard let string = encoded as? String, + DoryVMGuestAccountIntent.isValidUsername(string) else { return nil } + username = string + } else { username = nil } + let numericUserID: UInt32? + if let encoded = raw["numericUserID"] { + guard let number = encoded as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue == Double(number.uint32Value), + DoryVMGuestAccountIntent.isValidNumericUserID(number.uint32Value) + else { return nil } + numericUserID = number.uint32Value + } else { numericUserID = nil } + let account = DoryVMGuestAccountIntent( + username: username, + numericUserID: numericUserID + ) + guard account.isValidForPersistence else { return nil } + identity.account = account + } + if let encodedDesktop = rawIdentity["desktop"] { + guard let raw = encodedDesktop as? NSDictionary, + let rawKeys = raw.allKeys as? [String], + Set(rawKeys).isSubset(of: [ + "distributionIdentifier", "displayName", "version", + "desktopEnvironment", + ]), rawKeys.count == Set(rawKeys).count else { return nil } + func string(_ key: String, validating: (String) -> Bool) -> String? { + guard let encoded = raw[key] else { return nil } + guard let value = encoded as? String, validating(value) else { return nil } + return value + } + let distribution = string( + "distributionIdentifier", + validating: DoryVMDesktopIdentityIntent.isValidDistributionIdentifier + ) + if raw["distributionIdentifier"] != nil, distribution == nil { return nil } + let displayName = string( + "displayName", validating: DoryVMDesktopIdentityIntent.isValidLabel + ) + if raw["displayName"] != nil, displayName == nil { return nil } + let version = string( + "version", validating: DoryVMDesktopIdentityIntent.isValidLabel + ) + if raw["version"] != nil, version == nil { return nil } + let desktopEnvironment = string( + "desktopEnvironment", validating: DoryVMDesktopIdentityIntent.isValidLabel + ) + if raw["desktopEnvironment"] != nil, desktopEnvironment == nil { return nil } + let desktop = DoryVMDesktopIdentityIntent( + distributionIdentifier: distribution, + displayName: displayName, + version: version, + desktopEnvironment: desktopEnvironment + ) + guard desktop.isValidForPersistence else { return nil } + identity.desktop = desktop + } + } + + let clipboard: DoryVMClipboardPolicy? + if let encoded = value["clipboardPolicy"] { + guard let raw = encoded as? NSDictionary, + let rawKeys = raw.allKeys as? [String], + Set(rawKeys) == ["text", "image", "files"], + let text = (raw["text"] as? String).flatMap(DoryVMClipboardDirection.init), + let image = (raw["image"] as? String).flatMap(DoryVMClipboardDirection.init), + let files = (raw["files"] as? String).flatMap(DoryVMClipboardDirection.init) + else { return nil } + clipboard = DoryVMClipboardPolicy(text: text, image: image, files: files) + } else { clipboard = nil } + let runtime: DoryDesktopVMMPreference? + if let encoded = value["desktopRuntimePreference"] { + guard let raw = encoded as? String, + let parsed = DoryDesktopVMMPreference(rawValue: raw) else { return nil } + runtime = parsed + } else { runtime = nil } + let graphics: DoryDesktopGraphicsPreference? + if let encoded = value["desktopGraphicsPreference"] { + guard let raw = encoded as? String, + let parsed = DoryDesktopGraphicsPreference(rawValue: raw) else { return nil } + graphics = parsed + } else { graphics = nil } + let networkMode: DoryVMNetworkMode? + if let encoded = value["networkMode"] { + guard let raw = encoded as? String, + let parsed = DoryVMNetworkMode(rawValue: raw) else { return nil } + networkMode = parsed + } else { networkMode = nil } + let portForwards: [DoryVMPortForward] + if let encoded = value["portForwards"] { + guard let rows = encoded as? NSArray, + rows.count <= DoryVMPortForward.maximumCount else { return nil } + var parsed: [DoryVMPortForward] = [] + var identifiers: Set = [] + var bindings: Set = [] + for rawRow in rows { + guard let row = rawRow as? NSDictionary, + let rowKeys = row.allKeys as? [String], + Set(rowKeys) == ["id", "transport", "hostPort", "guestPort", "exposure"], + rowKeys.count == 5, + let id = row["id"] as? String, + isSafePortForwardIdentifier(id), + identifiers.insert(id).inserted, + let transportRaw = row["transport"] as? String, + let transport = DoryVMPortForwardTransport(rawValue: transportRaw), + let hostPort = machinePort(row["hostPort"]), hostPort >= 1_024, + let guestPort = machinePort(row["guestPort"]), guestPort > 0, + let exposureRaw = row["exposure"] as? String, + let exposure = DoryVMPortForwardExposure(rawValue: exposureRaw), + bindings.insert("\(transport.rawValue):\(hostPort)").inserted else { + return nil + } + parsed.append(DoryVMPortForward( + id: id, + transport: transport, + hostPort: hostPort, + guestPort: guestPort, + exposure: exposure + )) + } + portForwards = parsed + } else { portForwards = [] } + if !portForwards.isEmpty { + guard networkMode == .sharedNAT || networkMode == .isolated, + !portForwards.contains(where: { + $0.exposure == .lan && networkMode != .sharedNAT + }) else { return nil } + } + let audioConfiguration: DoryVMAudioConfiguration? + if let encoded = value["audio"] { + guard let raw = encoded as? NSDictionary, + let rawKeys = raw.allKeys as? [String], + Set(rawKeys) == ["inputEnabled", "outputEnabled"], + rawKeys.count == 2, + let input = raw["inputEnabled"] as? NSNumber, + CFGetTypeID(input) == CFBooleanGetTypeID(), + let output = raw["outputEnabled"] as? NSNumber, + CFGetTypeID(output) == CFBooleanGetTypeID() else { return nil } + audioConfiguration = DoryVMAudioConfiguration( + inputEnabled: input.boolValue, + outputEnabled: output.boolValue + ) + } else { audioConfiguration = nil } + let cameraConfiguration: DoryVMCameraConfiguration? + if let encoded = value["cameraEnabled"] { + guard let number = encoded as? NSNumber, + CFGetTypeID(number) == CFBooleanGetTypeID() else { return nil } + cameraConfiguration = DoryVMCameraConfiguration(enabled: number.boolValue) + } else { cameraConfiguration = nil } + let intelApplicationTranslationEnabled: Bool? + if let encoded = value["intelApplicationTranslationEnabled"] { + guard let number = encoded as? NSNumber, + CFGetTypeID(number) == CFBooleanGetTypeID() else { return nil } + intelApplicationTranslationEnabled = number.boolValue + } else { intelApplicationTranslationEnabled = nil } + return ParsedMachineTypedSettings(value: DorydMachineTypedSettings( + guestIdentityIntent: identity, + clipboardPolicy: clipboard, + runtimePreference: runtime, + graphicsPreference: graphics, + networkMode: networkMode, + portForwards: portForwards, + audioConfiguration: audioConfiguration, + cameraConfiguration: cameraConfiguration, + intelApplicationTranslationEnabled: intelApplicationTranslationEnabled + )) + } + + nonisolated private static func machinePort(_ raw: Any?) -> UInt16? { + guard let number = raw as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue.rounded(.towardZero) == number.doubleValue, + (1...Double(UInt16.max)).contains(number.doubleValue) else { + return nil + } + return UInt16(number.uint64Value) + } + + nonisolated private static func isSafePortForwardIdentifier(_ value: String) -> Bool { + let bytes = Array(value.utf8) + func alphaNumeric(_ byte: UInt8) -> Bool { + (48...57).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte) + } + guard (1...63).contains(bytes.count), alphaNumeric(bytes[0]) else { return false } + return bytes.dropFirst().allSatisfy { + alphaNumeric($0) || $0 == 95 || $0 == 46 || $0 == 45 + } + } + + private struct ParsedInstalledDesktopPayloadReceipt { + var value: DorydInstalledDesktopPayloadReceipt? + } + + /// Only absence gets legacy compatibility. A present typed receipt is an evidence claim and + /// malformed or future-schema values fail the entire row instead of downgrading to env. + nonisolated private static func machineInstalledDesktopPayloadReceipt( + from dictionary: NSDictionary, + legacyEnvironment: [String: String]? = nil + ) -> ParsedInstalledDesktopPayloadReceipt? { + guard let encoded = dictionary["installedDesktopPayloadReceipt"] else { + return ParsedInstalledDesktopPayloadReceipt( + value: legacyEnvironment.flatMap( + DorydInstalledDesktopPayloadReceipt.legacyEnvironment + ) + ) + } + guard let receiptDictionary = encoded as? NSDictionary, + let receipt = strictInstalledDesktopPayloadReceipt(from: receiptDictionary), + receipt.isValid else { + return nil + } + return ParsedInstalledDesktopPayloadReceipt(value: receipt) + } + + nonisolated private static func strictInstalledDesktopPayloadReceipt( + from dictionary: NSDictionary + ) -> DorydInstalledDesktopPayloadReceipt? { + let allowed: Set = [ + "schemaVersion", "provenance", "distributionIdentifier", "releaseVersion", + "inputSHA256", "bundleSHA256", "distributionComponentIdentifier", + "distributionInstallationName", "distributionCatalogSHA256", + "bundleAssetIdentifier", "runtimeComponentIdentifier", "runtimeInstallationName", + "runtimeCatalogSHA256", "kernelAssetIdentifier", "kernelSHA256", + ] + guard let keys = dictionary.allKeys as? [String], Set(keys).isSubset(of: allowed), + keys.count == Set(keys).count, + let schemaNumber = dictionary["schemaVersion"] as? NSNumber, + CFGetTypeID(schemaNumber) != CFBooleanGetTypeID(), + schemaNumber.uint64Value == 1, + schemaNumber.doubleValue == 1, + let provenance = dictionary["provenance"] as? String, + let distributionIdentifier = dictionary["distributionIdentifier"] as? String, + let releaseVersion = dictionary["releaseVersion"] as? String, + let inputSHA256 = dictionary["inputSHA256"] as? String else { + return nil + } + let optionalKeys = allowed.subtracting([ + "schemaVersion", "provenance", "distributionIdentifier", "releaseVersion", + "inputSHA256", + ]) + for key in optionalKeys where dictionary[key] != nil { + guard dictionary[key] is String else { return nil } + } + let receipt = DorydInstalledDesktopPayloadReceipt( + schemaVersion: 1, + provenance: provenance, + distributionIdentifier: distributionIdentifier, + releaseVersion: releaseVersion, + inputSHA256: inputSHA256, + bundleSHA256: dictionary["bundleSHA256"] as? String, + distributionComponentIdentifier: dictionary["distributionComponentIdentifier"] as? String, + distributionInstallationName: dictionary["distributionInstallationName"] as? String, + distributionCatalogSHA256: dictionary["distributionCatalogSHA256"] as? String, + bundleAssetIdentifier: dictionary["bundleAssetIdentifier"] as? String, + runtimeComponentIdentifier: dictionary["runtimeComponentIdentifier"] as? String, + runtimeInstallationName: dictionary["runtimeInstallationName"] as? String, + runtimeCatalogSHA256: dictionary["runtimeCatalogSHA256"] as? String, + kernelAssetIdentifier: dictionary["kernelAssetIdentifier"] as? String, + kernelSHA256: dictionary["kernelSHA256"] as? String + ) + return receipt.isValid ? receipt : nil + } + + /// Only an absent key is compatible with an older daemon. A present identity is an evidence + /// claim and must decode and validate exactly; malformed or future-schema claims fail closed. + nonisolated private static func machineRuntimeIdentity( + from dictionary: NSDictionary + ) -> DorydMachineRuntimeIdentity? { + guard let encoded = dictionary["runtimeIdentity"] else { + return .legacyCompatibility + } + guard let identity = decoded(DorydMachineRuntimeIdentity.self, from: encoded), + identity.isValid else { + return nil + } + return identity + } + + private struct OptionalRuntimeGraphicsSelection { + var value: DorydMachineRuntimeGraphicsSelection? + } + + /// The plan describes what may launch; this receipt describes what the active RawHV helper + /// actually selected. Resolved-plan receipts must bind exactly to their durable plan. A + /// qualification-bootstrap launch intentionally retains legacy compatibility authority, so + /// its self-valid live receipt is observable but never promoted to resolved-plan authority. + /// A running resolved RawHV machine without an exact matching receipt is rejected instead of + /// inheriting the plan's acceleration label. + nonisolated private static func machineRuntimeGraphicsSelection( + from encoded: Any?, + state: String, + runtimeIdentity: DorydMachineRuntimeIdentity + ) -> OptionalRuntimeGraphicsSelection? { + guard let encoded else { + if ["running", "paused"].contains(state), + runtimeIdentity.mode == "resolved-plan", + runtimeIdentity.backend == DoryVirtualizationBackendIdentity.doryHypervisor.rawValue { + return nil + } + return OptionalRuntimeGraphicsSelection(value: nil) + } + guard ["starting", "running", "paused"].contains(state), + let dictionary = encoded as? NSDictionary, + let keys = dictionary.allKeys as? [String], + keys.count == Set(keys).count, + Set(keys).isSubset(of: [ + "schemaVersion", "operationID", "resolvedPlanSHA256", "planRevision", + "accelerationLevel", "backend", "rendererGeneration", + "rendererWorkerReceiptSHA256", "guestProducerFenceProofSHA256", + ]), + let schemaVersion = uint16(dictionary["schemaVersion"]), + let operationID = dictionary["operationID"] as? String, + let resolvedPlanSHA256 = dictionary["resolvedPlanSHA256"] as? String, + let planRevision = strictUInt64(dictionary["planRevision"]), + let accelerationLevel = dictionary["accelerationLevel"] as? String, + let backend = dictionary["backend"] as? String else { + return nil + } + let selection = DorydMachineRuntimeGraphicsSelection( + schemaVersion: schemaVersion, + operationID: operationID, + resolvedPlanSHA256: resolvedPlanSHA256, + planRevision: planRevision, + accelerationLevel: accelerationLevel, + backend: backend, + rendererGeneration: dictionary["rendererGeneration"].flatMap(strictUInt64), + rendererWorkerReceiptSHA256: + dictionary["rendererWorkerReceiptSHA256"] as? String, + guestProducerFenceProofSHA256: + dictionary["guestProducerFenceProofSHA256"] as? String + ) + guard selection.isValid else { return nil } + switch runtimeIdentity.mode { + case "legacy-compatibility": + break + case "resolved-plan": + guard runtimeIdentity.backend + == DoryVirtualizationBackendIdentity.doryHypervisor.rawValue, + selection.resolvedPlanSHA256 == runtimeIdentity.planSHA256, + selection.planRevision == runtimeIdentity.planRevision, + selection.accelerationLevel == runtimeIdentity.graphics else { + return nil + } + default: + return nil + } + return OptionalRuntimeGraphicsSelection(value: selection) + } + + /// An absent field is compatible with an older daemon. Once present, every row is a durable + /// device-identity claim; silently dropping a malformed row could make the next app edit + /// delete a share the user never removed. + nonisolated private static func machineShares( + from value: Any? + ) -> [DorydMachineShareConfiguration]? { + guard let value else { return [] } + guard let rawRows = value as? NSArray else { return nil } + var shares: [DorydMachineShareConfiguration] = [] + var tags: Set = [] + shares.reserveCapacity(rawRows.count) + for rawRow in rawRows { + guard let row = rawRow as? NSDictionary, + let keys = row.allKeys as? [String], + Set(keys).isSubset(of: ["tag", "hostPath", "guestPath", "readOnly", "mode"]), + keys.count == Set(keys).count, + let tag = row["tag"] as? String, + !tag.isEmpty, + tag.utf8.count < 36, + tag.allSatisfy({ + $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == "." + }), + tags.insert(tag).inserted, + let hostPath = row["hostPath"] as? String, + hostPath.hasPrefix("/"), + !hostPath.contains("\0"), + let guestPath = row["guestPath"] as? String, + guestPath.hasPrefix("/"), + guestPath != "/", + !guestPath.contains("\0") else { + return nil + } + let encodedMode = row["mode"] + guard encodedMode == nil + || (encodedMode as? String).map({ $0 == "ro" || $0 == "rw" }) == true else { + return nil + } + let readOnly: Bool + if let encoded = row["readOnly"] { + guard let number = encoded as? NSNumber, + CFGetTypeID(number) == CFBooleanGetTypeID() else { + return nil + } + readOnly = number.boolValue + } else { + readOnly = (encodedMode as? String) == "ro" + } + if let mode = encodedMode as? String, + (mode == "ro") != readOnly { + return nil + } + shares.append(DorydMachineShareConfiguration( + tag: tag, + hostPath: hostPath, + guestPath: guestPath, + readOnly: readOnly + )) + } + return shares + } + + nonisolated private static func machineEnvironment(from value: Any?) -> [String: String] { + let rows: [NSDictionary] + if let swiftRows = value as? [NSDictionary] { + rows = swiftRows + } else if let nsRows = value as? NSArray { + rows = nsRows.compactMap { $0 as? NSDictionary } + } else { + return [:] + } + var result: [String: String] = [:] + for row in rows { + guard let key = row["key"] as? String, + key.wholeMatch(of: /[A-Za-z_][A-Za-z0-9_]*/) != nil else { + continue + } + result[key] = (row["value"] as? String) ?? "" + } + return result + } + + nonisolated private static func machineStatuses(from rows: NSArray) -> [DorydMachineStatus]? { + let dictionaries = rows.compactMap { $0 as? NSDictionary } + guard dictionaries.count == rows.count else { return nil } + let statuses = dictionaries.compactMap(machineStatus(from:)) + guard statuses.count == dictionaries.count else { return nil } + return statuses + } + + nonisolated private static func machineExecResult(from dictionary: NSDictionary) -> DorydMachineExecResult? { + guard let exitCode = int32(dictionary["exitCode"]), + let stdout = outputString(dictionary["stdout"]), + let stderr = outputString(dictionary["stderr"]) else { + return nil + } + return DorydMachineExecResult( + exitCode: exitCode, + stdout: stdout, + stderr: stderr, + timedOut: (dictionary["timedOut"] as? Bool) ?? false, + stdoutTruncated: (dictionary["stdoutTruncated"] as? Bool) ?? false, + stderrTruncated: (dictionary["stderrTruncated"] as? Bool) ?? false + ) + } + + nonisolated private static func machineStats(from dictionary: NSDictionary) -> DorydMachineStats? { + guard dictionary["schema"] as? String == "dev.dory.machine.stats", + int(dictionary["version"]) == 1, + let cpuPercent = double(dictionary["cpuPercent"]), + let memoryUsedBytes = uint64(dictionary["memoryUsedBytes"]), + let memoryTotalBytes = uint64(dictionary["memoryTotalBytes"]), + let networkReceiveBytes = uint64(dictionary["networkReceiveBytes"]), + let networkTransmitBytes = uint64(dictionary["networkTransmitBytes"]), + let blockReadBytes = uint64(dictionary["blockReadBytes"]), + let blockWriteBytes = uint64(dictionary["blockWriteBytes"]), + let processCount = uint64(dictionary["processCount"]), + let uptimeSeconds = double(dictionary["uptimeSeconds"]), + cpuPercent >= 0, cpuPercent <= 100, memoryUsedBytes <= memoryTotalBytes else { + return nil + } + return DorydMachineStats( + cpuPercent: cpuPercent, + memoryUsedBytes: memoryUsedBytes, + memoryTotalBytes: memoryTotalBytes, + networkReceiveBytes: networkReceiveBytes, + networkTransmitBytes: networkTransmitBytes, + blockReadBytes: blockReadBytes, + blockWriteBytes: blockWriteBytes, + processCount: processCount, + uptimeSeconds: uptimeSeconds + ) + } + + nonisolated private static func machineDeviceTelemetry( + from dictionary: NSDictionary, + machineID: String + ) -> DorydDeviceTelemetrySnapshot? { + guard machineID.isSafeMachineIdentifier, + let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys) == [ + "schemaVersion", "machineID", "operationID", "backend", "sampleSequence", + "sampledAtUnixMilliseconds", "monotonicNanoseconds", "devices", "events", + ], + strictUInt64(dictionary["schemaVersion"]) == 1, + dictionary["machineID"] as? String == machineID, + let operationID = dictionary["operationID"] as? String, + isMachineOperationID(operationID), + let rawBackend = dictionary["backend"] as? String, + let backend = DoryVirtualizationBackendIdentity(rawValue: rawBackend), + let sampleSequence = strictUInt64(dictionary["sampleSequence"]), + sampleSequence > 0, + let sampledAt = strictUInt64(dictionary["sampledAtUnixMilliseconds"]), + sampledAt > 0, + let monotonic = strictUInt64(dictionary["monotonicNanoseconds"]), + monotonic > 0, + let rawDevices = dictionary["devices"] as? NSArray, + rawDevices.count > 0, rawDevices.count <= 64, + let rawEvents = dictionary["events"] as? NSArray, + rawEvents.count <= 256 else { + return nil + } + let devices = rawDevices.compactMap { raw -> DorydDeviceTelemetryDevice? in + guard let row = raw as? NSDictionary else { return nil } + return machineDeviceTelemetryDevice(from: row) + } + let events = rawEvents.compactMap { raw -> DorydDeviceTelemetryEvent? in + guard let row = raw as? NSDictionary else { return nil } + return machineDeviceTelemetryEvent(from: row) + } + guard devices.count == rawDevices.count, + events.count == rawEvents.count, + Set(devices.map(\.id)).count == devices.count, + events == events.sorted(by: { $0.sequence < $1.sequence }), + Set(events.map(\.sequence)).count == events.count, + events.allSatisfy({ event in devices.contains { $0.id == event.deviceID } }) else { + return nil + } + return DorydDeviceTelemetrySnapshot( + machineID: machineID, + operationID: operationID, + backend: backend, + sampleSequence: sampleSequence, + sampledAtUnixMilliseconds: sampledAt, + monotonicNanoseconds: monotonic, + devices: devices, + events: events + ) + } + + nonisolated private static func machineDeviceTelemetryDevice( + from dictionary: NSDictionary + ) -> DorydDeviceTelemetryDevice? { + guard let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys) == ["id", "kind", "health", "metrics"], + let id = dictionary["id"] as? String, + !id.isEmpty, id.utf8.count <= 128, + !id.contains("\0"), !id.contains("\n"), !id.contains("\r"), + let rawKind = dictionary["kind"] as? String, + let kind = DorydDeviceTelemetryKind(rawValue: rawKind), + let rawHealth = dictionary["health"] as? String, + let health = DorydDeviceTelemetryHealth(rawValue: rawHealth), + let rawMetrics = dictionary["metrics"] as? NSArray, + rawMetrics.count > 0, + rawMetrics.count <= deviceTelemetryMetricKinds.count else { + return nil + } + let metrics = rawMetrics.compactMap { raw -> DorydDeviceTelemetryMetric? in + guard let row = raw as? NSDictionary else { return nil } + return machineDeviceTelemetryMetric(from: row) + } + guard metrics.count == rawMetrics.count, + Set(metrics.map(\.kind)).count == metrics.count, + health != .unavailable + ? metrics.contains(where: { $0.availability == .measured }) + : metrics.allSatisfy({ $0.availability == .unavailable }) else { + return nil + } + return DorydDeviceTelemetryDevice(id: id, kind: kind, health: health, metrics: metrics) + } + + nonisolated private static func machineDeviceTelemetryMetric( + from dictionary: NSDictionary + ) -> DorydDeviceTelemetryMetric? { + guard let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + let kind = dictionary["kind"] as? String, + deviceTelemetryMetricKinds.contains(kind), + let unit = dictionary["unit"] as? String, + unit == deviceTelemetryMetricUnit(for: kind), + let rawAvailability = dictionary["availability"] as? String, + let availability = DorydDeviceTelemetryMetricAvailability( + rawValue: rawAvailability + ) else { + return nil + } + switch availability { + case .measured: + guard Set(rawKeys) == ["kind", "unit", "availability", "value"], + let value = strictUInt64(dictionary["value"]) else { return nil } + return DorydDeviceTelemetryMetric( + kind: kind, + unit: unit, + availability: availability, + value: value + ) + case .unavailable: + guard Set(rawKeys) == ["kind", "unit", "availability", "unavailableReason"], + let reason = dictionary["unavailableReason"] as? String, + !reason.isEmpty, reason.utf8.count <= 256, + !reason.contains("\0"), !reason.contains("\n"), !reason.contains("\r") else { + return nil + } + return DorydDeviceTelemetryMetric( + kind: kind, + unit: unit, + availability: availability, + unavailableReason: reason + ) + } + } + + nonisolated private static func machineDeviceTelemetryEvent( + from dictionary: NSDictionary + ) -> DorydDeviceTelemetryEvent? { + guard let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys) == [ + "sequence", "monotonicNanoseconds", "deviceID", "kind", "occurrences", + ], + let sequence = strictUInt64(dictionary["sequence"]), sequence > 0, + let monotonic = strictUInt64(dictionary["monotonicNanoseconds"]), monotonic > 0, + let deviceID = dictionary["deviceID"] as? String, + !deviceID.isEmpty, deviceID.utf8.count <= 128, + let kind = dictionary["kind"] as? String, + deviceTelemetryEventKinds.contains(kind), + let occurrences = strictUInt64(dictionary["occurrences"]), occurrences > 0 else { + return nil + } + return DorydDeviceTelemetryEvent( + sequence: sequence, + monotonicNanoseconds: monotonic, + deviceID: deviceID, + kind: kind, + occurrences: occurrences + ) + } + + nonisolated private static func machineProvisionResult(from dictionary: NSDictionary) -> DorydMachineProvisionResult? { + let decodedRecipeID = (dictionary["recipeID"] as? String) ?? (dictionary["recipe"] as? String) + guard let recipeID = decodedRecipeID, + let installDictionary = dictionary["install"] as? NSDictionary, + let verifyDictionary = dictionary["verify"] as? NSDictionary, + let install = machineExecResult(from: installDictionary), + let verify = machineExecResult(from: verifyDictionary) else { + return nil + } + return DorydMachineProvisionResult(recipeID: recipeID, install: install, verify: verify) + } + + nonisolated private static func desktopUpdateResult(from dictionary: NSDictionary) -> DorydDesktopUpdateResult? { + let operationID: String? + if let rawOperationID = dictionary["operationID"] { + guard let value = rawOperationID as? String else { return nil } + operationID = value + } else { + operationID = nil + } + if let operationID { + guard let parsed = UUID(uuidString: operationID), + parsed != UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)), + operationID == parsed.uuidString.lowercased() else { + return nil + } + } + guard let machineID = dictionary["machineID"] as? String, + let distro = dictionary["distro"] as? String, + let version = dictionary["version"] as? String, + let inputSHA256 = dictionary["inputSHA256"] as? String, + let bundleSHA256 = dictionary["bundleSHA256"] as? String, + let snapshotID = dictionary["snapshotID"] as? String, + let statusDictionary = dictionary["status"] as? NSDictionary, + let status = machineStatus(from: statusDictionary), + let restoredRunningState = dictionary["restoredRunningState"] as? Bool else { + return nil + } + return DorydDesktopUpdateResult( + operationID: operationID, + machineID: machineID, + distro: distro, + version: version, + inputSHA256: inputSHA256, + bundleSHA256: bundleSHA256, + snapshotID: snapshotID, + status: status, + restoredRunningState: restoredRunningState + ) + } + + nonisolated private static func machineEventBatch( + from dictionary: NSDictionary, + afterSequence: UInt64 + ) -> DorydMachineEventBatch? { + guard let keys = dictionary.allKeys as? [String], + keys.count == 4, + Set(keys) == [ + "schemaVersion", "headSequence", "snapshotRequired", "events", + ], + uint16(dictionary["schemaVersion"]) == 1, + let headSequence = uint64(dictionary["headSequence"]), + let snapshotNumber = dictionary["snapshotRequired"] as? NSNumber, + CFGetTypeID(snapshotNumber) == CFBooleanGetTypeID(), + let rows = dictionary["events"] as? NSArray else { + return nil + } + var events: [DorydMachineEvent] = [] + for rawRow in rows { + guard let row = rawRow as? NSDictionary, + let event = machineEvent(from: row) else { return nil } + events.append(event) + } + let snapshotRequired = snapshotNumber.boolValue + guard events == events.sorted(by: { $0.sequence < $1.sequence }), + Set(events.map(\.sequence)).count == events.count else { + return nil + } + if snapshotRequired { + guard events.isEmpty else { return nil } + } else if events.isEmpty { + guard headSequence == afterSequence else { return nil } + } else { + guard afterSequence < UInt64.max, + events.first?.sequence == afterSequence + 1, + events.last?.sequence == headSequence, + zip(events, events.dropFirst()).allSatisfy({ lhs, rhs in + lhs.sequence < UInt64.max && lhs.sequence + 1 == rhs.sequence + }) else { + return nil + } + } + return DorydMachineEventBatch( + headSequence: headSequence, + snapshotRequired: snapshotRequired, + events: events + ) + } + + nonisolated private static func machineFlightRecorderBatch( + from dictionary: NSDictionary, + machineID: String, + afterSequence: UInt64 + ) -> DorydMachineFlightRecorderBatch? { + guard machineID.isSafeMachineIdentifier, + let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys) == [ + "schemaVersion", "machineID", "headSequence", "snapshotRequired", "events", + ], + strictUInt64(dictionary["schemaVersion"]) == 1, + dictionary["machineID"] as? String == machineID, + let headSequence = strictUInt64(dictionary["headSequence"]), + let snapshot = dictionary["snapshotRequired"] as? NSNumber, + CFGetTypeID(snapshot) == CFBooleanGetTypeID(), + let rows = dictionary["events"] as? NSArray, + rows.count <= 256 else { + return nil + } + var events: [DorydMachineFlightEvent] = [] + for raw in rows { + guard let row = raw as? NSDictionary, + let event = machineFlightEvent(from: row), + event.machineID == machineID else { + return nil + } + events.append(event) + } + guard events == events.sorted(by: { $0.sequence < $1.sequence }), + Set(events.map(\.sequence)).count == events.count, + zip(events, events.dropFirst()).allSatisfy({ lhs, rhs in + lhs.sequence < UInt64.max && lhs.sequence + 1 == rhs.sequence + }) else { + return nil + } + if events.isEmpty { + guard headSequence == 0 || (!snapshot.boolValue && headSequence == afterSequence) else { + return nil + } + } else { + guard events.last?.sequence == headSequence else { return nil } + if !snapshot.boolValue { + guard afterSequence < UInt64.max, + events.first?.sequence == afterSequence + 1 else { return nil } + } + } + return DorydMachineFlightRecorderBatch( + machineID: machineID, + headSequence: headSequence, + snapshotRequired: snapshot.boolValue, + events: events + ) + } + + nonisolated private static func machineSerialConsoleBatch( + from dictionary: NSDictionary, + machineID: String, + cursor: DorydMachineSerialConsoleCursor, + limit: UInt32 + ) -> DorydMachineSerialConsoleBatch? { + let required: Set = [ + "schemaVersion", "machineID", "startOffset", "nextOffset", "totalBytes", + "snapshotRequired", "inputAvailable", "bytesBase64", + ] + let optional: Set = ["generation"] + guard machineID.isSafeMachineIdentifier, + limit > 0, limit <= 64 * 1_024, + let rawKeys = dictionary.allKeys as? [String] else { return nil } + let keys = Set(rawKeys) + guard rawKeys.count == keys.count, + required.isSubset(of: keys), + keys.subtracting(required).isSubset(of: optional), + strictUInt64(dictionary["schemaVersion"]) == 1, + dictionary["machineID"] as? String == machineID, + let startOffset = strictUInt64(dictionary["startOffset"]), + let nextOffset = strictUInt64(dictionary["nextOffset"]), + let totalBytes = strictUInt64(dictionary["totalBytes"]), + let snapshot = dictionary["snapshotRequired"] as? NSNumber, + CFGetTypeID(snapshot) == CFBooleanGetTypeID(), + let input = dictionary["inputAvailable"] as? NSNumber, + CFGetTypeID(input) == CFBooleanGetTypeID(), + let encoded = dictionary["bytesBase64"] as? String, + let bytes = Data(base64Encoded: encoded), + bytes.base64EncodedString() == encoded, + bytes.count <= Int(limit), + startOffset <= nextOffset, + nextOffset <= totalBytes, + nextOffset - startOffset == UInt64(bytes.count), + dictionary["generation"] == nil + || dictionary["generation"] is String else { + return nil + } + let generation = dictionary["generation"] as? String + guard generation.map(\.isLowercaseSHA256) ?? true else { return nil } + if generation == nil { + guard startOffset == 0, nextOffset == 0, totalBytes == 0, bytes.isEmpty else { + return nil + } + } + if !snapshot.boolValue { + guard generation == cursor.generation, startOffset == cursor.offset else { + return nil } - result[key] = (row["value"] as? String) ?? "" } - return result + return DorydMachineSerialConsoleBatch( + machineID: machineID, + generation: generation, + startOffset: startOffset, + nextOffset: nextOffset, + totalBytes: totalBytes, + snapshotRequired: snapshot.boolValue, + inputAvailable: input.boolValue, + bytes: bytes + ) } - nonisolated private static func machineStatuses(from rows: NSArray) -> [DorydMachineStatus]? { - let dictionaries = rows.compactMap { $0 as? NSDictionary } - guard dictionaries.count == rows.count else { return nil } - let statuses = dictionaries.compactMap(machineStatus(from:)) - guard statuses.count == dictionaries.count else { return nil } - return statuses + nonisolated private static func machineFlightEvent( + from dictionary: NSDictionary + ) -> DorydMachineFlightEvent? { + let required: Set = [ + "schemaVersion", "sequence", "occurredAtUnixMilliseconds", "machineID", + "kind", "evidenceReferences", + ] + let optional: Set = [ + "operationID", "operationKind", "phase", "machineState", "failureCode", + "recoveryDisposition", "backend", "virtualHardwareABIVersion", "planSHA256", + "durationMilliseconds", "deadlineUnixMilliseconds", "deviceID", + "deviceEventKind", "deviceEventSequence", "deviceEventOccurrences", + ] + guard let rawKeys = dictionary.allKeys as? [String] else { return nil } + let keys = Set(rawKeys) + guard rawKeys.count == keys.count, + required.isSubset(of: keys), + keys.subtracting(required).isSubset(of: optional), + strictUInt64(dictionary["schemaVersion"]) == 1, + let sequence = strictUInt64(dictionary["sequence"]), sequence > 0, + let occurredAt = strictInt64(dictionary["occurredAtUnixMilliseconds"]), + occurredAt > 0, + let machineID = dictionary["machineID"] as? String, + machineID.isSafeMachineIdentifier, + let rawKind = dictionary["kind"] as? String, + let kind = DorydMachineFlightEventKind(rawValue: rawKind), + let rawEvidence = dictionary["evidenceReferences"] as? NSArray, + rawEvidence.count <= 16 else { + return nil + } + var evidence: [DorydMachineFailureEvidenceReference] = [] + for raw in rawEvidence { + guard let row = raw as? NSDictionary, + let rowKeys = row.allKeys as? [String], + rowKeys.count == row.allKeys.count, + Set(rowKeys) == ["kind", "identifier"], + let rawEvidenceKind = row["kind"] as? String, + let evidenceKind = DorydMachineFailureEvidenceKind( + rawValue: rawEvidenceKind + ), + let identifier = row["identifier"] as? String, + identifier.utf8.count <= 256, + identifier.wholeMatch( + of: /[A-Za-z0-9][A-Za-z0-9._:@+-]{0,255}/ + ) != nil else { + return nil + } + evidence.append(.init(kind: evidenceKind, identifier: identifier)) + } + guard Set(evidence).count == evidence.count else { return nil } + + let operationID = dictionary["operationID"] as? String + let operationKind = (dictionary["operationKind"] as? String).flatMap( + DorydMachineOperationKind.init(rawValue:) + ) + let phase = dictionary["phase"] as? String + let machineState = dictionary["machineState"] as? String + let failureCode = (dictionary["failureCode"] as? String).flatMap( + DorydMachineFailureCode.init(rawValue:) + ) + let recovery = (dictionary["recoveryDisposition"] as? String).flatMap( + DorydMachineRecoveryDisposition.init(rawValue:) + ) + let backend = (dictionary["backend"] as? String).flatMap( + DoryVirtualizationBackendIdentity.init(rawValue:) + ) + let abi = dictionary["virtualHardwareABIVersion"] + .flatMap { strictUInt64($0) } + .flatMap { UInt16(exactly: $0) } + let planSHA256 = dictionary["planSHA256"] as? String + let duration = dictionary["durationMilliseconds"].flatMap(strictUInt64) + let deadline = dictionary["deadlineUnixMilliseconds"].flatMap(strictInt64) + let deviceID = dictionary["deviceID"] as? String + let deviceEventKind = dictionary["deviceEventKind"] as? String + let deviceEventSequence = dictionary["deviceEventSequence"].flatMap(strictUInt64) + let deviceEventOccurrences = dictionary["deviceEventOccurrences"].flatMap(strictUInt64) + guard (dictionary["operationID"] == nil) == (operationID == nil), + (dictionary["operationKind"] == nil) == (operationKind == nil), + (operationID == nil) == (operationKind == nil), + operationID.map(isMachineOperationID) ?? true, + (dictionary["phase"] == nil) + || phase.map(machineFlightPhases.contains) == true, + (dictionary["machineState"] == nil) + || machineState.map(machineEventStates.contains) == true, + (dictionary["failureCode"] == nil) == (failureCode == nil), + (dictionary["recoveryDisposition"] == nil) == (recovery == nil), + (failureCode == nil) == (recovery == nil), + (dictionary["backend"] == nil) == (backend == nil), + (dictionary["virtualHardwareABIVersion"] == nil) == (abi == nil), + abi.map({ $0 > 0 }) ?? true, + (dictionary["planSHA256"] == nil) + || planSHA256?.isLowercaseSHA256 == true, + (dictionary["durationMilliseconds"] == nil) == (duration == nil), + duration.map({ $0 <= 31 * 24 * 60 * 60 * 1_000 }) ?? true, + (dictionary["deadlineUnixMilliseconds"] == nil) == (deadline == nil), + deadline.map({ $0 > 0 }) ?? true, + (dictionary["deviceID"] == nil) == (deviceID == nil), + deviceID.map({ !$0.isEmpty && $0.utf8.count <= 128 }) ?? true, + (dictionary["deviceEventKind"] == nil) == (deviceEventKind == nil), + deviceEventKind.map(deviceTelemetryEventKinds.contains) ?? true, + (dictionary["deviceEventSequence"] == nil) == (deviceEventSequence == nil), + deviceEventSequence.map({ $0 > 0 }) ?? true, + (dictionary["deviceEventOccurrences"] == nil) == (deviceEventOccurrences == nil), + deviceEventOccurrences.map({ $0 > 0 }) ?? true else { + return nil + } + if [.failureRecorded, .readinessRejected, .operationFailed, .recoveryRequired] + .contains(kind), failureCode == nil { + return nil + } + let deviceFields = [ + deviceID != nil, + deviceEventKind != nil, + deviceEventSequence != nil, + deviceEventOccurrences != nil, + ] + guard kind == .deviceHealthEvent + ? deviceFields.allSatisfy({ $0 }) + : deviceFields.allSatisfy({ !$0 }) else { + return nil + } + return DorydMachineFlightEvent( + sequence: sequence, + occurredAtUnixMilliseconds: occurredAt, + machineID: machineID, + operationID: operationID, + operationKind: operationKind, + kind: kind, + phase: phase, + machineState: machineState, + failureCode: failureCode, + recoveryDisposition: recovery, + backend: backend, + virtualHardwareABIVersion: abi, + planSHA256: planSHA256, + durationMilliseconds: duration, + deadlineUnixMilliseconds: deadline, + deviceID: deviceID, + deviceEventKind: deviceEventKind, + deviceEventSequence: deviceEventSequence, + deviceEventOccurrences: deviceEventOccurrences, + evidenceReferences: evidence + ) } - nonisolated private static func machineExecResult(from dictionary: NSDictionary) -> DorydMachineExecResult? { - guard let exitCode = int32(dictionary["exitCode"]), - let stdout = outputString(dictionary["stdout"]), - let stderr = outputString(dictionary["stderr"]) else { + nonisolated private static func machineEvent( + from dictionary: NSDictionary + ) -> DorydMachineEvent? { + let requiredKeys: Set = [ + "schemaVersion", "sequence", "observedAtUnixMilliseconds", "machineID", + "kind", + ] + guard let rawKeys = dictionary.allKeys as? [String] else { return nil } + let keys = Set(rawKeys) + guard rawKeys.count == keys.count, + requiredKeys.isSubset(of: keys), + keys.subtracting(requiredKeys).isSubset(of: ["status"]), + uint16(dictionary["schemaVersion"]) == 1, + let sequence = uint64(dictionary["sequence"]), sequence > 0, + let observedAt = int64(dictionary["observedAtUnixMilliseconds"]), + observedAt > 0, + let machineID = dictionary["machineID"] as? String, + machineID.isSafeMachineIdentifier, + let kindValue = dictionary["kind"] as? String, + let kind = DorydMachineEventKind(rawValue: kindValue) else { return nil } - return DorydMachineExecResult( - exitCode: exitCode, - stdout: stdout, - stderr: stderr, - timedOut: (dictionary["timedOut"] as? Bool) ?? false, - stdoutTruncated: (dictionary["stdoutTruncated"] as? Bool) ?? false, - stderrTruncated: (dictionary["stderrTruncated"] as? Bool) ?? false + let status = (dictionary["status"] as? NSDictionary).flatMap( + machineEventStatus(from:) + ) + guard (kind == .updated && status?.machineID == machineID) + || (kind == .removed && dictionary["status"] == nil) else { + return nil + } + return DorydMachineEvent( + sequence: sequence, + observedAtUnixMilliseconds: observedAt, + machineID: machineID, + kind: kind, + status: status ) } - nonisolated private static func machineStats(from dictionary: NSDictionary) -> DorydMachineStats? { - guard dictionary["schema"] as? String == "dev.dory.machine.stats", - int(dictionary["version"]) == 1, - let cpuPercent = double(dictionary["cpuPercent"]), - let memoryUsedBytes = uint64(dictionary["memoryUsedBytes"]), - let memoryTotalBytes = uint64(dictionary["memoryTotalBytes"]), - let networkReceiveBytes = uint64(dictionary["networkReceiveBytes"]), - let networkTransmitBytes = uint64(dictionary["networkTransmitBytes"]), - let blockReadBytes = uint64(dictionary["blockReadBytes"]), - let blockWriteBytes = uint64(dictionary["blockWriteBytes"]), - let processCount = uint64(dictionary["processCount"]), - let uptimeSeconds = double(dictionary["uptimeSeconds"]), - cpuPercent >= 0, cpuPercent <= 100, memoryUsedBytes <= memoryTotalBytes else { + nonisolated private static func machineEventStatus( + from dictionary: NSDictionary + ) -> DorydMachineEventStatus? { + let requiredKeys: Set = [ + "schemaVersion", "machineID", "configurationRevision", "observedRevision", + "state", "hasFailure", "memoryMB", "cpuCount", "displayMode", "bootMode", + "installerMediaAttached", "shareCount", "integrationHealth", "runtimeMode", + "virtualHardwareABIVersion", + ] + guard let rawKeys = dictionary.allKeys as? [String] else { return nil } + let keys = Set(rawKeys) + guard rawKeys.count == keys.count, + requiredKeys.isSubset(of: keys), + keys.subtracting(requiredKeys).isSubset(of: [ + "failureCode", "recoveryDisposition", "operationID", "operationKind", + "planRevision", "planSHA256", "backend", "savedStateSHA256", + ]), + uint16(dictionary["schemaVersion"]) == 1, + let machineID = dictionary["machineID"] as? String, + machineID.isSafeMachineIdentifier, + let configurationRevision = dictionary["configurationRevision"] as? String, + configurationRevision.isLowercaseSHA256, + let observedRevision = dictionary["observedRevision"] as? String, + observedRevision.isLowercaseSHA256, + let state = dictionary["state"] as? String, + Self.machineEventStates.contains(state), + let failureNumber = dictionary["hasFailure"] as? NSNumber, + CFGetTypeID(failureNumber) == CFBooleanGetTypeID(), + let memoryMB = uint64(dictionary["memoryMB"]), memoryMB > 0, + let cpuCount = int(dictionary["cpuCount"]), cpuCount > 0, + let displayMode = dictionary["displayMode"] as? String, + ["headless", "desktop"].contains(displayMode), + let bootMode = dictionary["bootMode"] as? String, + ["linux-kernel", "efi"].contains(bootMode), + let installerNumber = dictionary["installerMediaAttached"] as? NSNumber, + CFGetTypeID(installerNumber) == CFBooleanGetTypeID(), + let shareCount = int(dictionary["shareCount"]), shareCount >= 0, + let integrationHealth = dictionary["integrationHealth"] as? String, + Self.machineIntegrationHealthStates.contains(integrationHealth), + let runtimeMode = dictionary["runtimeMode"] as? String, + Self.machineRuntimeModes.contains(runtimeMode), + let abi = uint16(dictionary["virtualHardwareABIVersion"]), abi > 0 else { return nil } - return DorydMachineStats( - cpuPercent: cpuPercent, - memoryUsedBytes: memoryUsedBytes, - memoryTotalBytes: memoryTotalBytes, - networkReceiveBytes: networkReceiveBytes, - networkTransmitBytes: networkTransmitBytes, - blockReadBytes: blockReadBytes, - blockWriteBytes: blockWriteBytes, - processCount: processCount, - uptimeSeconds: uptimeSeconds + let planRevision = dictionary["planRevision"].flatMap { uint64($0) } + let failureCode = (dictionary["failureCode"] as? String).flatMap( + DorydMachineFailureCode.init(rawValue:) + ) + let recoveryDisposition = (dictionary["recoveryDisposition"] as? String).flatMap( + DorydMachineRecoveryDisposition.init(rawValue:) + ) + let operationID = dictionary["operationID"] as? String + let operationKind = (dictionary["operationKind"] as? String).flatMap( + DorydMachineOperationKind.init(rawValue:) + ) + let planSHA256 = dictionary["planSHA256"] as? String + let backend = (dictionary["backend"] as? String).flatMap( + DoryVirtualizationBackendIdentity.init(rawValue:) + ) + let savedStateSHA256 = dictionary["savedStateSHA256"] as? String + guard (dictionary["planRevision"] == nil) == (planRevision == nil), + (dictionary["planSHA256"] == nil) + || planSHA256?.isLowercaseSHA256 == true, + (dictionary["backend"] == nil) == (backend == nil), + (dictionary["savedStateSHA256"] == nil) + || savedStateSHA256?.isLowercaseSHA256 == true, + (dictionary["failureCode"] == nil) == (failureCode == nil), + (dictionary["recoveryDisposition"] == nil) == (recoveryDisposition == nil), + (dictionary["operationID"] == nil) == (operationID == nil), + (dictionary["operationKind"] == nil) == (operationKind == nil), + operationID.map(isMachineOperationID) ?? true, + (operationID == nil) == (operationKind == nil) else { + return nil + } + if failureNumber.boolValue { + guard failureCode != nil, recoveryDisposition != nil else { return nil } + } else { + guard failureCode == nil, recoveryDisposition == nil else { return nil } + } + if runtimeMode == "resolved-plan" { + guard planRevision.map({ $0 > 0 }) == true, + planSHA256 != nil, + backend != nil else { return nil } + } else { + guard planRevision == nil, planSHA256 == nil, backend == nil else { return nil } + } + return DorydMachineEventStatus( + machineID: machineID, + configurationRevision: configurationRevision, + observedRevision: observedRevision, + state: state, + hasFailure: failureNumber.boolValue, + failureCode: failureCode, + recoveryDisposition: recoveryDisposition, + operationID: operationID, + operationKind: operationKind, + memoryMB: memoryMB, + cpuCount: cpuCount, + displayMode: displayMode, + bootMode: bootMode, + installerMediaAttached: installerNumber.boolValue, + shareCount: shareCount, + integrationHealth: integrationHealth, + runtimeMode: runtimeMode, + virtualHardwareABIVersion: abi, + planRevision: planRevision, + planSHA256: planSHA256, + backend: backend, + savedStateSHA256: savedStateSHA256 ) } - nonisolated private static func machineProvisionResult(from dictionary: NSDictionary) -> DorydMachineProvisionResult? { - let decodedRecipeID = (dictionary["recipeID"] as? String) ?? (dictionary["recipe"] as? String) - guard let recipeID = decodedRecipeID, - let installDictionary = dictionary["install"] as? NSDictionary, - let verifyDictionary = dictionary["verify"] as? NSDictionary, - let install = machineExecResult(from: installDictionary), - let verify = machineExecResult(from: verifyDictionary) else { + nonisolated private static let machineEventStates: Set = [ + "created", "starting", "running", "paused", "suspended", "stopped", "failed", + ] + nonisolated private static let machineFlightPhases: Set = [ + "planned", "quiescing", "staging", "verifying", "readyToPublish", + "publishing", "validating", "completed", + ] + nonisolated private static let machineIntegrationHealthStates: Set = [ + "inactive", "missing-tools", "incompatible", "degraded", "compatibility", "healthy", + ] + nonisolated private static let machineRuntimeModes: Set = [ + "legacy-compatibility", "resolved-plan", "requires-replanning", + ] + nonisolated private static let deviceTelemetryMetricKinds: Set = [ + "queue-notifications", "queue-state-changes", "used-interrupts", + "configuration-interrupts", "device-resets", "transmitted-frames", + "transmitted-bytes", "transmit-drops", "received-frames", "received-bytes", + "receive-deferred", "receive-drops", "receive-truncations", "reconnects", + "configured-port-forwards", "active-port-forwards", + "port-forward-reconciliation-failures", + "display-frames", "display-drops", "audio-drops", "storage-flushes", + "maximum-storage-flush-latency-nanoseconds", "graphics-fences", + "graphics-device-losses", "share-invalidations", "share-invalidation-failures", + ] + nonisolated private static let deviceTelemetryEventKinds: Set = [ + "queue-stall", "reset", "graphics-fence-timeout", "graphics-device-loss", + "network-reconnect", "port-forward-unavailable", "port-forward-recovered", + "audio-drop", "storage-flush-slow", + "share-invalidation-failure", + ] + + nonisolated private static func deviceTelemetryMetricUnit(for kind: String) -> String { + switch kind { + case "transmitted-bytes", "received-bytes": + "bytes" + case "maximum-storage-flush-latency-nanoseconds": + "nanoseconds" + default: + "count" + } + } + + nonisolated private static func machineImportAssessment( + from dictionary: NSDictionary + ) -> DorydMachineImportAssessment? { + let requiredKeys: Set = [ + "schemaVersion", "contentID", "sourceMachineID", "sourceSnapshotID", + "architecture", "bootMode", "diskSizeBytes", "virtualHardwareABIVersion", + "sourceRuntimeMode", "portable", "disposition", "issues", "components", + ] + guard let rawKeys = dictionary.allKeys as? [String] else { return nil } + let keys = Set(rawKeys) + guard rawKeys.count == keys.count, + requiredKeys.isSubset(of: keys), + keys.subtracting(requiredKeys).isSubset(of: ["sourceBackend"]), + let rawSchemaVersion = strictUInt64(dictionary["schemaVersion"]), + rawSchemaVersion == 1, + let contentID = dictionary["contentID"] as? String, + contentID.isLowercaseSHA256, + let sourceMachineID = dictionary["sourceMachineID"] as? String, + sourceMachineID.isSafeMachineIdentifier, + let sourceSnapshotID = dictionary["sourceSnapshotID"] as? String, + sourceSnapshotID.isSafeMachineIdentifier, + let architecture = dictionary["architecture"] as? String, + ["arm64", "x86_64"].contains(architecture), + let bootMode = dictionary["bootMode"] as? String, + ["linux-kernel", "efi"].contains(bootMode), + let diskSizeBytes = strictUInt64(dictionary["diskSizeBytes"]), + diskSizeBytes > 0, + let rawVirtualHardwareABIVersion = strictUInt64( + dictionary["virtualHardwareABIVersion"] + ), + rawVirtualHardwareABIVersion > 0, + rawVirtualHardwareABIVersion <= UInt16.max, + let sourceRuntimeMode = dictionary["sourceRuntimeMode"] as? String, + ["legacy-compatibility", "resolved-plan", "requires-replanning"] + .contains(sourceRuntimeMode), + let portable = dictionary["portable"] as? Bool, + let dispositionRaw = dictionary["disposition"] as? String, + let disposition = DorydMachineImportDisposition(rawValue: dispositionRaw), + let issueRows = dictionary["issues"] as? NSArray, + let issues = issueRows as? [String], + Set(issues).count == issues.count, + issues.allSatisfy({ Self.machineImportIssueCodes.contains($0) }), + let componentRows = dictionary["components"] as? NSArray else { return nil } - return DorydMachineProvisionResult(recipeID: recipeID, install: install, verify: verify) + let sourceBackend: DoryVirtualizationBackendIdentity? + if let rawBackend = dictionary["sourceBackend"] { + guard let encoded = rawBackend as? String, + let decoded = DoryVirtualizationBackendIdentity(rawValue: encoded) else { + return nil + } + sourceBackend = decoded + } else { + sourceBackend = nil + } + var components: [DorydMachineImportComponentAssessment] = [] + for row in componentRows { + guard let component = row as? NSDictionary, + let componentKeys = component.allKeys as? [String], + componentKeys.count == 4, + Set(componentKeys) == [ + "componentIdentifier", "buildIdentifier", "artifactSHA256", + "availability", + ], + let componentIdentifier = component["componentIdentifier"] as? String, + componentIdentifier.isSafeEvidenceIdentifier, + let buildIdentifier = component["buildIdentifier"] as? String, + buildIdentifier.isSafeEvidenceIdentifier, + let artifactSHA256 = component["artifactSHA256"] as? String, + artifactSHA256.isLowercaseSHA256, + let availabilityRaw = component["availability"] as? String, + let availability = DorydMachineImportComponentAvailability( + rawValue: availabilityRaw + ) else { + return nil + } + components.append(DorydMachineImportComponentAssessment( + componentIdentifier: componentIdentifier, + buildIdentifier: buildIdentifier, + artifactSHA256: artifactSHA256, + availability: availability + )) + } + let hasUnavailableComponents = components.contains { + $0.availability != .available + } + guard Set(components.map(\.componentIdentifier)).count == components.count, + (sourceRuntimeMode == "resolved-plan") == (sourceBackend != nil), + (sourceRuntimeMode == "resolved-plan") == !components.isEmpty, + portable == (disposition != .unavailable), + (disposition == .requiresComponents) == (portable && hasUnavailableComponents), + disposition == .unavailable || !hasUnavailableComponents, + disposition != .ready || sourceRuntimeMode == "legacy-compatibility" else { + return nil + } + return DorydMachineImportAssessment( + schemaVersion: UInt16(rawSchemaVersion), + contentID: contentID, + sourceMachineID: sourceMachineID, + sourceSnapshotID: sourceSnapshotID, + architecture: architecture, + bootMode: bootMode, + diskSizeBytes: diskSizeBytes, + virtualHardwareABIVersion: UInt16(rawVirtualHardwareABIVersion), + sourceRuntimeMode: sourceRuntimeMode, + sourceBackend: sourceBackend, + portable: portable, + disposition: disposition, + issues: issues, + components: components + ) } + nonisolated private static let machineImportIssueCodes: Set = [ + "architecture-mismatch", "virtual-hardware-abi-mismatch", + "backend-runtime-differs", "missing-components", "mismatched-components", + "resolved-plan-requires-replanning", "source-requires-replanning", + "legacy-requires-migration", + ] + nonisolated private static func machineSnapshot(from dictionary: NSDictionary) -> DorydMachineSnapshot? { guard let id = dictionary["id"] as? String, let machineID = dictionary["machineID"] as? String, @@ -1286,7 +5096,20 @@ nonisolated final class DorydClient: @unchecked Sendable { let kernelPath = dictionary["kernelPath"] as? String, let architecture = dictionary["architecture"] as? String, let memoryMB = uint64(dictionary["memoryMB"]), - let cpuCount = int(dictionary["cpuCount"]) else { + let cpuCount = int(dictionary["cpuCount"]), + let runtimeIdentity = machineRuntimeIdentity(from: dictionary), + let installedDesktopPayloadReceipt = machineInstalledDesktopPayloadReceipt( + from: dictionary + ), + let consistency = machineSnapshotConsistency(from: dictionary), + let guestQuiesceReceipt = machineSnapshotQuiesceReceipt( + from: dictionary, + consistency: consistency + ), + let artifactEvidence = machineSnapshotArtifactEvidence( + from: dictionary, + runtimeIdentity: runtimeIdentity + ) else { return nil } return DorydMachineSnapshot( @@ -1299,8 +5122,91 @@ nonisolated final class DorydClient: @unchecked Sendable { kernelPath: kernelPath, architecture: architecture, memoryMB: memoryMB, - cpuCount: cpuCount + cpuCount: cpuCount, + runtimeIdentity: runtimeIdentity, + artifactEvidence: artifactEvidence.value, + installedDesktopPayloadReceipt: installedDesktopPayloadReceipt.value, + consistency: consistency, + guestQuiesceReceipt: guestQuiesceReceipt.value + ) + } + + /// Absence preserves compatibility with older daemons. Once the field is present, its type + /// and value are closed so invented consistency claims cannot be displayed as trusted facts. + nonisolated private static func machineSnapshotConsistency( + from dictionary: NSDictionary + ) -> DorydMachineSnapshotConsistency? { + guard let encoded = dictionary["consistency"] else { return .coldStopped } + guard let rawValue = encoded as? String else { return nil } + return DorydMachineSnapshotConsistency(rawValue: rawValue) + } + + private struct ParsedMachineSnapshotQuiesceReceipt { + var value: DorydMachineSnapshotQuiesceReceipt? + } + + nonisolated private static func machineSnapshotQuiesceReceipt( + from dictionary: NSDictionary, + consistency: DorydMachineSnapshotConsistency + ) -> ParsedMachineSnapshotQuiesceReceipt? { + guard let encoded = dictionary["guestQuiesceReceipt"] else { + guard consistency == .coldStopped else { return nil } + return ParsedMachineSnapshotQuiesceReceipt(value: nil) + } + guard consistency == .guestQuiesced, + let raw = encoded as? NSDictionary, + let keys = raw.allKeys as? [String], + Set(keys) == [ + "schemaVersion", + "receiptID", + "agentBuild", + "agentProtocolVersion", + "capabilityVersion", + ], + keys.count == 5, + let rawSchemaVersion = strictUInt64(raw["schemaVersion"]), + rawSchemaVersion <= UInt16.max, + let receiptID = raw["receiptID"] as? String, + let agentBuild = raw["agentBuild"] as? String, + let rawAgentProtocolVersion = strictUInt64(raw["agentProtocolVersion"]), + rawAgentProtocolVersion <= UInt32.max, + let rawCapabilityVersion = strictUInt64(raw["capabilityVersion"]), + rawCapabilityVersion <= UInt32.max else { + return nil + } + let receipt = DorydMachineSnapshotQuiesceReceipt( + schemaVersion: UInt16(rawSchemaVersion), + receiptID: receiptID, + agentBuild: agentBuild, + agentProtocolVersion: UInt32(rawAgentProtocolVersion), + capabilityVersion: UInt32(rawCapabilityVersion) ) + guard receipt.isValid else { return nil } + return ParsedMachineSnapshotQuiesceReceipt(value: receipt) + } + + private struct ParsedMachineSnapshotArtifactEvidence { + var value: DorydMachineSnapshotArtifactEvidence? + } + + /// Artifact evidence follows the same compatibility rule as runtime identity: absence is + /// accepted only for snapshots from a legacy daemon. A present claim must be well formed, + /// and every non-legacy identity requires evidence. + nonisolated private static func machineSnapshotArtifactEvidence( + from dictionary: NSDictionary, + runtimeIdentity: DorydMachineRuntimeIdentity + ) -> ParsedMachineSnapshotArtifactEvidence? { + guard let encoded = dictionary["artifactEvidence"] else { + guard runtimeIdentity.mode == "legacy-compatibility" else { return nil } + return ParsedMachineSnapshotArtifactEvidence(value: nil) + } + guard let evidence = decoded( + DorydMachineSnapshotArtifactEvidence.self, + from: encoded + ), evidence.isValid else { + return nil + } + return ParsedMachineSnapshotArtifactEvidence(value: evidence) } nonisolated private static func machineSnapshots(from rows: NSArray) -> [DorydMachineSnapshot]? { @@ -1364,11 +5270,19 @@ nonisolated final class DorydClient: @unchecked Sendable { let uptimeSeconds = uint64(dictionary["uptimeSeconds"]) else { return nil } + let capabilities: [DorydAgentCapability] + if let encodedCapabilities = dictionary["capabilities"] { + guard let parsed = agentCapabilities(from: encodedCapabilities) else { return nil } + capabilities = parsed + } else { + capabilities = [] + } return DorydAgentInfo( protocolVersion: protocolVersion, kernel: kernel, agentBuild: agentBuild, - uptimeSeconds: uptimeSeconds + uptimeSeconds: uptimeSeconds, + capabilities: capabilities ) } @@ -1421,6 +5335,427 @@ nonisolated final class DorydClient: @unchecked Sendable { return DorydPushStats(filesSent: filesSent, bytesSent: bytesSent, filesDeleted: filesDeleted) } + nonisolated private static func machineFileTransferResult( + from dictionary: NSDictionary + ) -> DorydMachineFileTransferResult? { + guard Set(dictionary.allKeys.compactMap { $0 as? String }) + == ["schema", "transferID", "guestDestination", "filesSent", "bytesSent"], + dictionary.allKeys.count == 5, + strictUInt64(dictionary["schema"]) == 1, + let transferID = dictionary["transferID"] as? String, + transferID.utf8.count == 32, + transferID.utf8.allSatisfy({ byte in + (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) + }), + let guestDestination = dictionary["guestDestination"] as? String, + guestDestination.utf8.count <= 4_096, + !guestDestination.contains("\0"), + let filesSent = strictUInt64(dictionary["filesSent"]), + let bytesSent = strictUInt64(dictionary["bytesSent"]) else { + return nil + } + let suffix = "/Downloads/Dory Transfer " + transferID + guard guestDestination.hasPrefix("/home/"), + guestDestination.hasSuffix(suffix) else { + return nil + } + let usernameStart = guestDestination.index( + guestDestination.startIndex, + offsetBy: "/home/".count + ) + let usernameEnd = guestDestination.index( + guestDestination.endIndex, + offsetBy: -suffix.count + ) + guard usernameStart < usernameEnd, + DoryVMGuestAccountIntent.isValidUsername( + String(guestDestination[usernameStart.. DorydMachineFileTransferOperation? { + let requiredKeys: Set = [ + "schema", "operationID", "machineID", "phase", "filesTotal", + "filesCompleted", "bytesTotal", "bytesCompleted", + ] + let optionalKeys: Set = [ + "currentPath", "guestDestination", "result", "failure", + ] + guard let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys).isSuperset(of: requiredKeys), + Set(rawKeys).isSubset(of: requiredKeys.union(optionalKeys)), + strictUInt64(dictionary["schema"]) == 1, + let operationID = dictionary["operationID"] as? String, + isValidMachineTransferOperationID(operationID), + let machineID = dictionary["machineID"] as? String, + machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil, + let rawPhase = dictionary["phase"] as? String, + let phase = DorydMachineFileTransferPhase(rawValue: rawPhase), + let filesTotal = strictUInt64(dictionary["filesTotal"]), + filesTotal <= UInt64(DoryMachineFileTransferStager.maximumFileCount), + let filesCompleted = strictUInt64(dictionary["filesCompleted"]), + filesCompleted <= filesTotal, + let bytesTotal = strictUInt64(dictionary["bytesTotal"]), + bytesTotal <= DoryMachineFileTransferStager.maximumTransferBytes, + let bytesCompleted = strictUInt64(dictionary["bytesCompleted"]), + bytesCompleted <= bytesTotal else { + return nil + } + let currentPath: String? + if let rawCurrentPath = dictionary["currentPath"] { + guard let value = rawCurrentPath as? String, + isSafeMachineTransferRelativePath(value), + !phase.isTerminal else { + return nil + } + currentPath = value + } else { + currentPath = nil + } + let guestDestination: String? + if let rawGuestDestination = dictionary["guestDestination"] { + guard let value = rawGuestDestination as? String, + isValidMachineTransferDestination(value, transferID: operationID) else { + return nil + } + guestDestination = value + } else { + guestDestination = nil + } + let result: DorydMachineFileTransferResult? + if let rawResult = dictionary["result"] { + guard let row = rawResult as? NSDictionary, + let decoded = machineFileTransferResult(from: row), + decoded.transferID == operationID else { + return nil + } + result = decoded + } else { + result = nil + } + let failure: DorydMachineFileTransferFailure? + if let rawFailure = dictionary["failure"] { + guard let row = rawFailure as? NSDictionary, + Set(row.allKeys.compactMap { $0 as? String }) + == ["schema", "code", "message"], + row.allKeys.count == 3, + strictUInt64(row["schema"]) == 1, + let rawCode = row["code"] as? String, + let code = DorydMachineFileTransferFailureCode(rawValue: rawCode), + let message = row["message"] as? String, + !message.isEmpty, + message.utf8.count <= 1_024, + !message.contains("\0") else { + return nil + } + failure = DorydMachineFileTransferFailure(code: code, message: message) + } else { + failure = nil + } + + switch phase { + case .completed: + guard let result, + failure == nil, + guestDestination == result.guestDestination, + filesTotal == result.filesSent, + filesCompleted == result.filesSent, + bytesTotal == result.bytesSent, + bytesCompleted == result.bytesSent else { + return nil + } + case .failed: + guard failure != nil, result == nil else { return nil } + case .cancelled: + guard result == nil, failure == nil else { return nil } + case .preparing, .transferring, .finalizing, .cancelling: + guard result == nil, failure == nil else { return nil } + } + return DorydMachineFileTransferOperation( + operationID: operationID, + machineID: machineID, + phase: phase, + filesTotal: filesTotal, + filesCompleted: filesCompleted, + bytesTotal: bytesTotal, + bytesCompleted: bytesCompleted, + currentPath: currentPath, + guestDestination: guestDestination, + result: result, + failure: failure + ) + } + + nonisolated private static func machineFileTransferCurrent( + from dictionary: NSDictionary, + machineID: String + ) -> DorydMachineFileTransferCurrent? { + guard let keys = dictionary.allKeys as? [String], + keys.count == dictionary.allKeys.count, + strictUInt64(dictionary["schema"]) == 1, + let activeNumber = dictionary["active"] as? NSNumber, + CFGetTypeID(activeNumber) == CFBooleanGetTypeID() else { + return nil + } + if activeNumber.boolValue { + guard Set(keys) == ["schema", "active", "operation"], + let row = dictionary["operation"] as? NSDictionary, + let operation = machineFileTransferOperation(from: row), + operation.machineID == machineID, + !operation.phase.isTerminal else { + return nil + } + return DorydMachineFileTransferCurrent(operation: operation) + } + guard Set(keys) == ["schema", "active"] else { return nil } + return DorydMachineFileTransferCurrent(operation: nil) + } + + nonisolated private static func machineGuestFileExportResult( + from dictionary: NSDictionary + ) -> DorydMachineGuestFileExportResult? { + guard Set(dictionary.allKeys.compactMap { $0 as? String }) == [ + "schema", "exportID", "privateStagingRoot", "filesReceived", + "directoriesReceived", "bytesReceived", + ], + dictionary.allKeys.count == 6, + strictUInt64(dictionary["schema"]) == 1, + let exportID = dictionary["exportID"] as? String, + isValidMachineTransferOperationID(exportID), + let privateStagingRoot = dictionary["privateStagingRoot"] as? String, + isValidMachineGuestExportRoot( + privateStagingRoot, + exportID: exportID + ), + let filesReceived = strictUInt64(dictionary["filesReceived"]), + filesReceived <= UInt64(DoryMachineFileTransferStager.maximumFileCount), + let directoriesReceived = strictUInt64(dictionary["directoriesReceived"]), + let bytesReceived = strictUInt64(dictionary["bytesReceived"]), + bytesReceived <= DoryMachineFileTransferStager.maximumTransferBytes else { + return nil + } + let (entriesReceived, overflow) = filesReceived.addingReportingOverflow( + directoriesReceived + ) + guard !overflow, + entriesReceived <= UInt64(DoryMachineFileTransferStager.maximumEntryCount) else { + return nil + } + return DorydMachineGuestFileExportResult( + exportID: exportID, + privateStagingRoot: privateStagingRoot, + filesReceived: filesReceived, + directoriesReceived: directoriesReceived, + bytesReceived: bytesReceived + ) + } + + nonisolated private static func machineGuestFileExportOperation( + from dictionary: NSDictionary, + allowsOmittedCompletedResult: Bool = false + ) -> DorydMachineGuestFileExportOperation? { + let requiredKeys: Set = [ + "schema", "operationID", "machineID", "phase", "filesTotal", + "filesCompleted", "bytesTotal", "bytesCompleted", + ] + let optionalKeys: Set = ["currentPath", "result", "failure"] + guard let rawKeys = dictionary.allKeys as? [String], + rawKeys.count == dictionary.allKeys.count, + Set(rawKeys).isSuperset(of: requiredKeys), + Set(rawKeys).isSubset(of: requiredKeys.union(optionalKeys)), + strictUInt64(dictionary["schema"]) == 1, + let operationID = dictionary["operationID"] as? String, + isValidMachineTransferOperationID(operationID), + let machineID = dictionary["machineID"] as? String, + machineID.wholeMatch(of: /[A-Za-z0-9][A-Za-z0-9_.-]{0,62}/) != nil, + let rawPhase = dictionary["phase"] as? String, + let phase = DorydMachineFileTransferPhase(rawValue: rawPhase), + let filesTotal = strictUInt64(dictionary["filesTotal"]), + filesTotal <= UInt64(DoryMachineFileTransferStager.maximumFileCount), + let filesCompleted = strictUInt64(dictionary["filesCompleted"]), + filesCompleted <= filesTotal, + let bytesTotal = strictUInt64(dictionary["bytesTotal"]), + bytesTotal <= DoryMachineFileTransferStager.maximumTransferBytes, + let bytesCompleted = strictUInt64(dictionary["bytesCompleted"]), + bytesCompleted <= bytesTotal else { + return nil + } + let currentPath: String? + if let rawCurrentPath = dictionary["currentPath"] { + guard let value = rawCurrentPath as? String, + isSafeMachineTransferRelativePath(value), + !phase.isTerminal else { + return nil + } + currentPath = value + } else { + currentPath = nil + } + let result: DorydMachineGuestFileExportResult? + if let rawResult = dictionary["result"] { + guard let row = rawResult as? NSDictionary, + let decoded = machineGuestFileExportResult(from: row), + decoded.exportID == operationID else { + return nil + } + result = decoded + } else { + result = nil + } + let failure: DorydMachineFileTransferFailure? + if let rawFailure = dictionary["failure"] { + guard let row = rawFailure as? NSDictionary, + Set(row.allKeys.compactMap { $0 as? String }) + == ["schema", "code", "message"], + row.allKeys.count == 3, + strictUInt64(row["schema"]) == 1, + let rawCode = row["code"] as? String, + let code = DorydMachineFileTransferFailureCode(rawValue: rawCode), + let message = row["message"] as? String, + !message.isEmpty, + message.utf8.count <= 1_024, + !message.contains("\0") else { + return nil + } + failure = DorydMachineFileTransferFailure(code: code, message: message) + } else { + failure = nil + } + + switch phase { + case .completed: + guard failure == nil, + filesCompleted == filesTotal, + bytesCompleted == bytesTotal else { + return nil + } + if let result { + guard filesTotal == result.filesReceived, + bytesTotal == result.bytesReceived else { + return nil + } + } else if !allowsOmittedCompletedResult { + return nil + } + case .failed: + guard failure != nil, result == nil else { return nil } + case .cancelled: + guard result == nil, failure == nil else { return nil } + case .preparing, .transferring, .finalizing, .cancelling: + guard result == nil, failure == nil else { return nil } + } + return DorydMachineGuestFileExportOperation( + operationID: operationID, + machineID: machineID, + phase: phase, + filesTotal: filesTotal, + filesCompleted: filesCompleted, + bytesTotal: bytesTotal, + bytesCompleted: bytesCompleted, + currentPath: currentPath, + result: result, + failure: failure + ) + } + + nonisolated private static func machineGuestFileExportCurrent( + from dictionary: NSDictionary, + machineID: String + ) -> DorydMachineGuestFileExportCurrent? { + guard let keys = dictionary.allKeys as? [String], + keys.count == dictionary.allKeys.count, + strictUInt64(dictionary["schema"]) == 1, + let activeNumber = dictionary["active"] as? NSNumber, + CFGetTypeID(activeNumber) == CFBooleanGetTypeID() else { + return nil + } + if activeNumber.boolValue { + guard Set(keys) == ["schema", "active", "operation"], + let row = dictionary["operation"] as? NSDictionary, + let operation = machineGuestFileExportOperation(from: row), + operation.machineID == machineID, + !operation.phase.isTerminal || operation.phase == .completed else { + return nil + } + return DorydMachineGuestFileExportCurrent(operation: operation) + } + guard Set(keys) == ["schema", "active"] else { return nil } + return DorydMachineGuestFileExportCurrent(operation: nil) + } + + nonisolated private static func isValidMachineGuestExportRoot( + _ value: String, + exportID: String + ) -> Bool { + guard value.hasPrefix("/"), + value.utf8.count <= 4_096, + !value.contains("\0") else { + return false + } + let url = URL(fileURLWithPath: value, isDirectory: true) + guard url.standardizedFileURL.path == value, + url.deletingLastPathComponent().standardizedFileURL + == DoryMachineFileTransferStager.defaultStagingDirectory.standardizedFileURL else { + return false + } + let name = url.lastPathComponent + let prefix = "export-" + let suffix = "-" + exportID + guard name.hasPrefix(prefix), name.hasSuffix(suffix) else { return false } + let processStart = name.index(name.startIndex, offsetBy: prefix.count) + let processEnd = name.index(name.endIndex, offsetBy: -suffix.count) + guard processStart < processEnd, + let processID = UInt64(name[processStart.. 0 else { + return false + } + return true + } + + nonisolated private static func isValidMachineTransferOperationID(_ value: String) -> Bool { + value.utf8.count == 32 && value.utf8.allSatisfy { byte in + (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102) + } + } + + nonisolated private static func isSafeMachineTransferRelativePath(_ value: String) -> Bool { + guard !value.isEmpty, + value.utf8.count <= 4_096, + !value.hasPrefix("/"), + !value.contains("\0") else { + return false + } + return value.split(separator: "/", omittingEmptySubsequences: false).allSatisfy { + !$0.isEmpty && $0 != "." && $0 != ".." + } + } + + nonisolated private static func isValidMachineTransferDestination( + _ value: String, + transferID: String + ) -> Bool { + guard value.utf8.count <= 4_096, !value.contains("\0") else { return false } + let suffix = "/Downloads/Dory Transfer " + transferID + guard value.hasPrefix("/home/"), value.hasSuffix(suffix) else { return false } + let usernameStart = value.index(value.startIndex, offsetBy: "/home/".count) + let usernameEnd = value.index(value.endIndex, offsetBy: -suffix.count) + return usernameStart < usernameEnd + && DoryVMGuestAccountIntent.isValidUsername( + String(value[usernameStart.. DorydRemoteMachineStatus? { guard let id = dictionary["id"] as? String, let state = dictionary["state"] as? String else { @@ -1602,6 +5937,14 @@ nonisolated final class DorydClient: @unchecked Sendable { machineExecControlTimeout(timeoutMs: 600_000) * 2 } + nonisolated private static func machineTransferControlTimeout( + byteCount: UInt64 + ) -> TimeInterval { + // Allow two hours at the 64 GiB staging ceiling, with a two-minute fixed setup budget. + let transferSeconds = Double(byteCount) / (10 * 1024 * 1024) + return min(2 * 60 * 60, 120 + transferSeconds) + } + nonisolated private static func machineExecControlTimeout(timeoutMs: UInt64) -> TimeInterval { let effectiveTimeoutMs: UInt64 = timeoutMs == 0 ? 30_000 : min(timeoutMs, 600_000) return TimeInterval(effectiveTimeoutMs) / 1000 + 10 @@ -1617,6 +5960,26 @@ nonisolated final class DorydClient: @unchecked Sendable { return value as? UInt64 } + nonisolated private static func strictUInt64(_ value: Any?) -> UInt64? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() else { + return nil + } + let decoded = number.uint64Value + guard number.stringValue == String(decoded) else { return nil } + return decoded + } + + nonisolated private static func strictInt64(_ value: Any?) -> Int64? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() else { + return nil + } + let decoded = number.int64Value + guard number.stringValue == String(decoded) else { return nil } + return decoded + } + nonisolated private static func uint32(_ value: Any?) -> UInt32? { if let number = value as? NSNumber { return number.uint32Value diff --git a/Dory/Runtime/Doryd/DorydLaunchAgent.swift b/Dory/Runtime/Doryd/DorydLaunchAgent.swift index 74f7a9e4..0266b47d 100644 --- a/Dory/Runtime/Doryd/DorydLaunchAgent.swift +++ b/Dory/Runtime/Doryd/DorydLaunchAgent.swift @@ -6,6 +6,23 @@ enum DorydLaunchAgent { static let label = "dev.dory.doryd" static let stateDirectory = "\(NSHomeDirectory())/.dory" static let logPath = "\(NSHomeDirectory())/.dory/doryd.log" + /// dory-hv and Linux Machine helpers create private Unix sockets and disposable boot assets. + /// Keep those beneath Darwin's per-user 0700 temporary directory rather than the user's home: + /// a permissive or network-backed home is not a trustworthy Unix-socket ancestor, and the + /// hypervisor deliberately refuses to weaken that boundary. Durable Docker and Machine data + /// remains in the selected Dory data drive under Application Support. + static var runtimeDirectory: URL { + runtimeDirectory(temporaryDirectory: FileManager.default.temporaryDirectory) + } + + static func runtimeDirectory(temporaryDirectory: URL) -> URL { + temporaryDirectory + // Darwin Unix-domain sockets have only 103 usable pathname bytes. The private + // per-user temporary-directory prefix is already long on real systems, so keep this + // disposable namespace deliberately short. Durable state still uses the Dory drive. + .appendingPathComponent("d", isDirectory: true) + .standardizedFileURL + } // Docker gets 20 seconds, dory-hv gets 25, and doryd gets 30 before its own last resort. // launchd's system default is only five seconds on current macOS, so make upgrade/logout // replacement honor the same graceful shutdown contract as an explicit engine stop. @@ -26,6 +43,9 @@ enum DorydLaunchAgent { var httpProxyPort: UInt16 var httpsProxyPort: UInt16 var hostCLIEnabled: Bool + /// Candidate-only switch for creating compatibility-labeled VMs while their exact bytes + /// are under live qualification. Public builds leave this false. + var vmQualificationBootstrapEnabled: Bool /// Enables Dory's FEX/binfmt runtime in the native arm64 guest. Keeping this in the /// LaunchAgent makes the persisted Settings choice authoritative for doryd. var amd64EmulationEnabled: Bool @@ -46,6 +66,7 @@ enum DorydLaunchAgent { httpProxyPort: UInt16 = 8080, httpsProxyPort: UInt16 = 8443, hostCLIEnabled: Bool = true, + vmQualificationBootstrapEnabled: Bool = false, amd64EmulationEnabled: Bool = false, gpuVenusEnabled: Bool = false, cpuCount: UInt16? = nil, @@ -60,10 +81,13 @@ enum DorydLaunchAgent { self.httpProxyPort = httpProxyPort self.httpsProxyPort = httpsProxyPort self.hostCLIEnabled = hostCLIEnabled + self.vmQualificationBootstrapEnabled = vmQualificationBootstrapEnabled self.amd64EmulationEnabled = amd64EmulationEnabled self.gpuVenusEnabled = gpuVenusEnabled self.cpuCount = max(1, cpuCount ?? Self.hostScaledCPUCount()) - self.memoryMB = max(256, memoryMB ?? Self.hostScaledMemoryMB()) + self.memoryMB = UInt32(clamping: DoryEngineMemoryPolicy.clampedMemoryMB( + memoryMB ?? Self.hostScaledMemoryMB() + )) self.bridgeSubnetCIDR = (try? DoryIPv4BridgeNetwork(bridgeSubnetCIDR).cidr) ?? DoryIPv4BridgeNetwork.defaultCIDR self.sshAuthSock = sshAuthSock.flatMap { @@ -80,9 +104,9 @@ enum DorydLaunchAgent { } nonisolated static func hostScaledMemoryMB(physicalMemory: UInt64 = ProcessInfo.processInfo.physicalMemory) -> UInt32 { - let hostMB = Int(clamping: physicalMemory / (1024 * 1024)) - let ceiling = max(2048, min(hostMB / 2, hostMB - 4096)) - return UInt32(clamping: ceiling) + UInt32(clamping: DoryEngineMemoryPolicy.hostScaledMemoryMB( + physicalMemory: physicalMemory + )) } } @@ -357,6 +381,15 @@ enum DorydLaunchAgent { let directory = url.deletingLastPathComponent() try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try FileManager.default.createDirectory(atPath: stateDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: runtimeDirectory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: runtimeDirectory.path + ) if let existing = try? String(contentsOf: url, encoding: .utf8), existing == install.plistContents { return false @@ -368,10 +401,13 @@ enum DorydLaunchAgent { static func launchAgentPlist( program: String, helpersDirectory: URL, - configuration: Configuration = Configuration() + configuration: Configuration = Configuration(), + runtimeDirectory: URL = DorydLaunchAgent.runtimeDirectory ) -> String { let vmm = vmmExecutablePath(helpersDirectory: helpersDirectory) - let hv = helpersDirectory.appendingPathComponent("dory-hv").path + let hv = helpersDirectory + .appendingPathComponent("DoryHVRunner.app/Contents/MacOS/dory-hv") + .path let gvproxy = helpersDirectory.appendingPathComponent("gvproxy").path let resourcesDirectory = helpersDirectory .deletingLastPathComponent() @@ -411,8 +447,19 @@ enum DorydLaunchAgent { \(xmlEscaped(helpersDirectory.path)) DORYD_RESOURCES_DIR \(xmlEscaped(resourcesDirectory)) + DORYD_STATE_DIR + \(xmlEscaped(runtimeDirectory.appendingPathComponent("docker", isDirectory: true).path)) + DORYD_MACHINE_RUNTIME_DIR + \(xmlEscaped(runtimeDirectory.appendingPathComponent("m", isDirectory: true).path)) + + DORYD_SHARE_HOME + 1 DORYD_HOST_CLI \(configuration.hostCLIEnabled ? "1" : "0") + DORYD_VM_QUALIFICATION_BOOTSTRAP + \(configuration.vmQualificationBootstrapEnabled ? "1" : "0") DORYD_AMD64 \(configuration.amd64EmulationEnabled ? "1" : "0") DORYD_GPU diff --git a/Dory/Runtime/Kubernetes/KubernetesProvisioner.swift b/Dory/Runtime/Kubernetes/KubernetesProvisioner.swift index 7c0c8dc5..e1c468c3 100644 --- a/Dory/Runtime/Kubernetes/KubernetesProvisioner.swift +++ b/Dory/Runtime/Kubernetes/KubernetesProvisioner.swift @@ -16,7 +16,7 @@ enum KubernetesProvisioner { /// Increment whenever the container's mounts or nested containerd runtime contract changes. /// A mismatched container is never replaced automatically because its writable layer contains /// the user's cluster state. - static let runtimeContract = "2" + static let runtimeContract = "3" static let contractLabel = "dev.dory.kubernetes.contract" static let emulationLabel = "dev.dory.kubernetes.amd64" static let imageLabel = "dev.dory.kubernetes.image" @@ -109,7 +109,6 @@ enum KubernetesProvisioner { private struct HostConfiguration: Encodable { let Privileged = true let PortBindings: [String: [PortBinding]] - let Binds: [String]? } private struct CreateRequest: Encodable { @@ -128,17 +127,15 @@ enum KubernetesProvisioner { "--tls-san=host.docker.internal", ] - /// k3s embeds and pins its own runc. Preserve that exact binary as runc.real and configure only - /// containerd's BinaryName to enter Dory's OCI wrapper. Reusing dockerd's runc.real here caused - /// native k3s workloads to fail because the two runtime stacks are not interchangeable. + /// Dory's engine OCI admission layer interposes the exact nested runc as runc.real and mounts + /// dory-runc into this privileged container. Configure only containerd's BinaryName here; the + /// app must not copy or replace runtime binaries from inside the container after admission. static let fexStartupScript = #""" set -eu test -x /usr/lib/dory/fex/FEX test -x /usr/lib/dory/fex/FEXServer test -x /usr/local/bin/dory-runc - test -x /bin/runc - install -m 0755 /bin/runc /usr/local/bin/runc.real - cmp -s /bin/runc /usr/local/bin/runc.real + test -x /usr/local/bin/runc.real install -d -m 0755 /var/lib/rancher/k3s/agent/etc/containerd/config-v3.toml.d runtime_config=/var/lib/rancher/k3s/agent/etc/containerd/config-v3.toml.d/10-dory-fex.toml runtime_config_tmp="${runtime_config}.tmp" @@ -359,8 +356,7 @@ enum KubernetesProvisioner { Labels: labels, ExposedPorts: [port: EmptyObject()], HostConfig: HostConfiguration( - PortBindings: [port: [PortBinding(HostPort: "\(apiPort)")]], - Binds: amd64Emulation ? ["\(fexWrapperPath):\(fexWrapperPath):ro"] : nil + PortBindings: [port: [PortBinding(HostPort: "\(apiPort)")]] ) ) // All fields above are JSON-encodable value types. Encoding rather than interpolation keeps diff --git a/Dory/Runtime/Machines/DesktopMachineAssets.swift b/Dory/Runtime/Machines/DesktopMachineAssets.swift index c6435087..97d1fdbc 100644 --- a/Dory/Runtime/Machines/DesktopMachineAssets.swift +++ b/Dory/Runtime/Machines/DesktopMachineAssets.swift @@ -1,4 +1,5 @@ import Darwin +import CryptoKit import DoryOperations import Foundation @@ -25,12 +26,17 @@ nonisolated enum DesktopMachineDistro: String, CaseIterable, Identifiable, Senda } } - var desktopName: String { "Xfce" } + var desktopName: String { + switch self { + case .ubuntu: "GNOME" + case .debian, .kali: "Xfce" + } + } var summary: String { switch self { case .debian: "Stable, clean desktop for everyday Linux and development" - case .ubuntu: "Familiar Ubuntu base with long-term support packages" + case .ubuntu: "Canonical's Ubuntu GNOME desktop with long-term support" case .kali: "Security lab desktop with Kali's official rolling repository" } } @@ -154,6 +160,45 @@ nonisolated enum DesktopMachineAssetProvisioner { ) } + /// Hashes the prepared, uncompressed kernel that doryd will reopen. The descriptor and its + /// identity are checked before and after reading so an asset-cache replacement cannot be + /// mistaken for the bytes the app actually approved. + static func preparedKernelSHA256(at path: String) throws -> String { + let descriptor = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + guard descriptor >= 0 else { + throw DesktopMachineAssetError.invalidAsset(path) + } + defer { close(descriptor) } + var before = stat() + guard fstat(descriptor, &before) == 0, + (before.st_mode & S_IFMT) == S_IFREG, + before.st_uid == getuid(), + before.st_nlink == 1, + before.st_size > 0, + (before.st_mode & 0o077) == 0 else { + throw DesktopMachineAssetError.unsafeAsset(path) + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + var hasher = SHA256() + while true { + let chunk = try handle.read(upToCount: 4 * 1024 * 1024) ?? Data() + if chunk.isEmpty { break } + hasher.update(data: chunk) + } + var after = stat() + guard fstat(descriptor, &after) == 0, + before.st_dev == after.st_dev, + before.st_ino == after.st_ino, + before.st_size == after.st_size, + before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, + before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec, + before.st_ctimespec.tv_sec == after.st_ctimespec.tv_sec, + before.st_ctimespec.tv_nsec == after.st_ctimespec.tv_nsec else { + throw DesktopMachineAssetError.invalidAsset(path) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + static func prepare( kernelSource: String, rootfsSource: String, diff --git a/Dory/Runtime/Machines/HostDisplayChoice.swift b/Dory/Runtime/Machines/HostDisplayChoice.swift new file mode 100644 index 00000000..7f78779c --- /dev/null +++ b/Dory/Runtime/Machines/HostDisplayChoice.swift @@ -0,0 +1,23 @@ +import AppKit +import CoreGraphics + +nonisolated struct HostDisplayChoice: Identifiable, Hashable, Sendable { + var id: String + var name: String + + @MainActor + static func connectedDisplays() -> [HostDisplayChoice] { + NSScreen.screens.compactMap { screen in + let key = NSDeviceDescriptionKey("NSScreenNumber") + guard let number = screen.deviceDescription[key] as? NSNumber, + let unmanaged = CGDisplayCreateUUIDFromDisplayID( + CGDirectDisplayID(number.uint32Value) + ) else { return nil } + let uuid = unmanaged.takeRetainedValue() + return HostDisplayChoice( + id: (CFUUIDCreateString(nil, uuid) as String).lowercased(), + name: screen.localizedName + ) + } + } +} diff --git a/Dory/Runtime/Machines/MachineEnvImport.swift b/Dory/Runtime/Machines/MachineEnvImport.swift deleted file mode 100644 index b84369d7..00000000 --- a/Dory/Runtime/Machines/MachineEnvImport.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation - -nonisolated enum MachineEnvImport { - static let defaultNames: [String] = ["ANTHROPIC_API_KEY"] - static let optionalExtras: [String] = ["OPENAI_API_KEY", "GH_TOKEN", "HF_TOKEN"] - - static func normalize(_ names: [String]) -> [String] { - var seen = Set() - var ordered: [String] = [] - for name in names { - let cleaned = name.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() - guard !cleaned.isEmpty, cleaned.wholeMatch(of: /[A-Z_][A-Z0-9_]*/) != nil else { continue } - guard seen.insert(cleaned).inserted else { continue } - ordered.append(cleaned) - } - return ordered - } - - static func parse(_ raw: String) -> [String] { - let separators = CharacterSet(charactersIn: ", \t\n") - return normalize(raw.components(separatedBy: separators)) - } - - static func serialize(_ names: [String]) -> String { - normalize(names).joined(separator: ",") - } - - static let sentinel = "@@DORYENV@@" - - static func probeCommand(for names: [String]) -> String { - normalize(names).map { name in - "printf '\(sentinel)\(name)=%s\(sentinel)' \"${\(name):-}\"" - }.joined(separator: "; ") - } - - static func parseProbeOutput(_ output: String) -> [String: String] { - var result: [String: String] = [:] - let segments = output.components(separatedBy: sentinel) - for segment in segments { - guard let eq = segment.firstIndex(of: "="), segment.hasSuffix("=") == false else { continue } - let key = String(segment[segment.startIndex.. [String: String] { - let normalized = normalize(names) - guard !normalized.isEmpty else { return [:] } - let command = probeCommand(for: normalized) - let result = await withTimeout(seconds: 6) { - await Shell.runAsyncResult(loginShell(), ["-lic", command]) - } - guard let output = result?.output else { return [:] } - return parseProbeOutput(output) - } - - private static func loginShell() -> String { - if let shell = ProcessInfo.processInfo.environment["SHELL"], - FileManager.default.isExecutableFile(atPath: shell) { return shell } - return Shell.find("zsh", candidates: ["/bin/zsh", "/opt/homebrew/bin/zsh", "/usr/local/bin/zsh"]) ?? "/bin/zsh" - } - - private static func withTimeout(seconds: Double, _ operation: @escaping @Sendable () async -> T) async -> T? { - await withTaskGroup(of: T?.self) { group in - group.addTask { await operation() } - group.addTask { - try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - return nil - } - let first = await group.next() ?? nil - group.cancelAll() - return first - } - } -} diff --git a/Dory/Runtime/Machines/MachineService.swift b/Dory/Runtime/Machines/MachineService.swift index 20b5068c..1bc958f5 100644 --- a/Dory/Runtime/Machines/MachineService.swift +++ b/Dory/Runtime/Machines/MachineService.swift @@ -1,11 +1,23 @@ import Foundation - -nonisolated struct MountPair: Sendable, Hashable { var host: String; var guest: String; var readOnly: Bool = false } +import DoryOperations + +nonisolated struct MountPair: Sendable, Hashable { + var host: String + var guest: String + var readOnly: Bool = false + /// Stable daemon-owned VirtioFS share identity. New mounts leave this nil until their + /// first write assigns a collision-free tag; subsequent reads and edits preserve it. + var shareTag: String? = nil +} nonisolated struct PortPair: Sendable, Hashable { var host: Int; var guest: Int } nonisolated enum MachineDisplayMode: String, Sendable, Hashable, CaseIterable { case headless case desktop } +nonisolated enum MachineBootMode: String, Sendable, Hashable, CaseIterable { + case linuxKernel = "linux-kernel" + case efi +} nonisolated struct MachineSettings: Sendable, Hashable { var cpus: Int? var memoryMB: Int? @@ -13,8 +25,15 @@ nonisolated struct MachineSettings: Sendable, Hashable { var ports: [PortPair] = [] var identity: MacIdentity? = nil var env: [String: String] = [:] + /// Closed, non-secret VM intent used by new doryd writes. `env` remains only so older + /// machine.json records and container recipes can be read without data loss. + var virtualMachineSettings: DorydMachineTypedSettings? = nil + var displayPresentation: DoryMachineDisplayPresentation? = nil var address: String? = nil var displayMode: MachineDisplayMode = .headless + var bootMode: MachineBootMode = .linuxKernel + var installerISOPath: String? = nil + var diskSizeGB: Int? = nil nonisolated static let `default` = MachineSettings(cpus: nil, memoryMB: nil) } diff --git a/Dory/Runtime/Machines/MachineSnapshot.swift b/Dory/Runtime/Machines/MachineSnapshot.swift index 600916c4..2c5a3fbf 100644 --- a/Dory/Runtime/Machines/MachineSnapshot.swift +++ b/Dory/Runtime/Machines/MachineSnapshot.swift @@ -16,11 +16,19 @@ struct MachineSnapshot: Identifiable, Hashable, Sendable { let uid: Int? let homePath: String? let loginShell: String + let runtimeIdentity: DorydMachineRuntimeIdentity + let artifactEvidence: DorydMachineSnapshotArtifactEvidence? + let consistency: DorydMachineSnapshotConsistency? + let guestQuiesceReceipt: DorydMachineSnapshotQuiesceReceipt? nonisolated init(id: String, imageRef: String, machineName: String, note: String, createdISO: String, sizeBytes: Int64, distro: String, version: String, arch: String, boot: String, recipe: String, username: String = "root", uid: Int? = nil, homePath: String? = nil, - loginShell: String = "/bin/sh") { + loginShell: String = "/bin/sh", + runtimeIdentity: DorydMachineRuntimeIdentity = .legacyCompatibility, + artifactEvidence: DorydMachineSnapshotArtifactEvidence? = nil, + consistency: DorydMachineSnapshotConsistency? = nil, + guestQuiesceReceipt: DorydMachineSnapshotQuiesceReceipt? = nil) { self.id = id self.imageRef = imageRef self.machineName = machineName @@ -36,6 +44,10 @@ struct MachineSnapshot: Identifiable, Hashable, Sendable { self.uid = uid self.homePath = homePath self.loginShell = loginShell + self.runtimeIdentity = runtimeIdentity + self.artifactEvidence = artifactEvidence + self.consistency = consistency + self.guestQuiesceReceipt = guestQuiesceReceipt } } diff --git a/Dory/Runtime/MigrationAssistant.swift b/Dory/Runtime/MigrationAssistant.swift index 6e130000..e4d3035f 100644 --- a/Dory/Runtime/MigrationAssistant.swift +++ b/Dory/Runtime/MigrationAssistant.swift @@ -1905,7 +1905,7 @@ enum MigrationAssistant { } else { try await target.start(containerID: created.id) } - case .paused: + case .paused, .suspended: if hasFixedHostPort(created.spec) { summary.containersAwaitingSourcePorts.append(created.container.name) summary.warnings.append( diff --git a/Dory/Runtime/MigrationOperationPlanBuilder+Canonical.swift b/Dory/Runtime/MigrationOperationPlanBuilder+Canonical.swift index 2deb7b9e..3e2d0039 100644 --- a/Dory/Runtime/MigrationOperationPlanBuilder+Canonical.swift +++ b/Dory/Runtime/MigrationOperationPlanBuilder+Canonical.swift @@ -116,7 +116,7 @@ extension MigrationOperationPlanBuilder { } switch container.status { case .running: return .running - case .paused: return .paused + case .paused, .suspended: return .paused case .stopped: return .exited } } diff --git a/Dory/Runtime/MigrationStrictCapacity.swift b/Dory/Runtime/MigrationStrictCapacity.swift index e086bd96..35e1533f 100644 --- a/Dory/Runtime/MigrationStrictCapacity.swift +++ b/Dory/Runtime/MigrationStrictCapacity.swift @@ -62,6 +62,16 @@ struct MigrationCapacityInput { let engineCapacity: MigrationEngineCapacity } +/// Target usage together with the live guest-filesystem ceiling that admission may rely on. +/// +/// The configured ext4 ceiling remains an upper bound, but the guest can expose less usable space +/// through reserved blocks or a changed filesystem. Keeping the effective value beside the usage +/// makes it part of the immutable capacity contract and therefore part of staging revalidation. +struct MigrationTargetCapacityUsage: Sendable, Equatable { + let usedBytes: Int64 + let effectiveUsableBytes: Int64 +} + extension MigrationStrictInventoryCollector { static func namedVolumeSizes( expected names: [String], @@ -96,18 +106,71 @@ extension MigrationStrictInventoryCollector { } static func dockerUsage(runtime: any ContainerRuntime) async throws -> Int64 { + try Task.checkCancellation() guard let response = await runtime.proxyRequest( method: "GET", path: "/system/df", headers: [(name: "Accept", value: "application/json")], body: Data() - ), response.isSuccess, - let usage = try? DockerDiskUsageParser.totalDockerBytes(from: response.body) else { + ) else { + throw MigrationStrictInventoryError.incomplete( + "target Docker storage usage request did not return a response" + ) + } + try Task.checkCancellation() + guard response.isSuccess else { throw MigrationStrictInventoryError.incomplete( - "target Docker storage usage is unavailable" + "target Docker storage usage request returned HTTP \(response.statusCode)" + ) + } + do { + return try DockerDiskUsageParser.totalDockerBytes(from: response.body) + } catch let error as DockerDiskUsageParserError { + throw MigrationStrictInventoryError.incomplete( + "target Docker storage usage response is invalid: \(error)" + ) + } catch { + throw MigrationStrictInventoryError.incomplete( + "target Docker storage usage response could not be decoded: \(error)" ) } - return usage + } + + static func targetStorageUsage( + runtime: any ContainerRuntime, + engineCapacity: MigrationEngineCapacity + ) async throws -> MigrationTargetCapacityUsage { + try Task.checkCancellation() + let authoritative: MigrationTargetStorageUsage? + do { + authoritative = try await runtime.migrationTargetStorageUsage() + } catch is CancellationError { + throw CancellationError() + } catch { + throw MigrationStrictInventoryError.incomplete( + "authoritative target data-disk usage is unavailable: \(error)" + ) + } + try Task.checkCancellation() + guard let authoritative else { + guard runtime.kind != .sharedVM else { + throw MigrationStrictInventoryError.incomplete( + "Dory's shared-VM target did not provide authoritative guest data-disk usage" + ) + } + return MigrationTargetCapacityUsage( + usedBytes: try await dockerUsage(runtime: runtime), + effectiveUsableBytes: engineCapacity.usableBytes + ) + } + let liveUsableBytes = try validateTargetStorageUsage( + authoritative, + engineCapacity: engineCapacity + ) + return MigrationTargetCapacityUsage( + usedBytes: authoritative.usedBytes, + effectiveUsableBytes: min(engineCapacity.usableBytes, liveUsableBytes) + ) } static func capacityContract( @@ -173,6 +236,32 @@ extension MigrationStrictInventoryCollector { } private extension MigrationStrictInventoryCollector { + static func validateTargetStorageUsage( + _ usage: MigrationTargetStorageUsage, + engineCapacity: MigrationEngineCapacity + ) throws -> Int64 { + let accounted = usage.usedBytes.addingReportingOverflow(usage.availableBytes) + guard usage.totalBytes > 0, + usage.usedBytes >= 0, + usage.availableBytes >= 0, + usage.usedBytes <= usage.totalBytes, + usage.availableBytes <= usage.totalBytes, + !accounted.overflow, + accounted.partialValue > 0, + accounted.partialValue <= usage.totalBytes else { + throw MigrationStrictInventoryError.incomplete( + "authoritative target data-disk usage is internally inconsistent" + ) + } + guard usage.totalBytes >= engineCapacity.usableBytes, + usage.totalBytes <= engineCapacity.logicalBytes else { + throw MigrationStrictInventoryError.incomplete( + "authoritative target data-disk capacity does not match Dory's selected disk" + ) + } + return accounted.partialValue + } + static func requiredBytes(used: Int64, field: String) throws -> Int64 { guard used > 0 else { return 0 } return try sum([used, max(safetyFloorBytes, used / 5)], field: field) diff --git a/Dory/Runtime/MigrationStrictInventory.swift b/Dory/Runtime/MigrationStrictInventory.swift index b297300e..641eaf3f 100644 --- a/Dory/Runtime/MigrationStrictInventory.swift +++ b/Dory/Runtime/MigrationStrictInventory.swift @@ -269,7 +269,14 @@ private extension MigrationStrictInventoryCollector { expected: selectedVolumeNames, runtime: sourceRuntime ) - let targetDockerBytes = try await dockerUsage(runtime: targetRuntime) + let targetUsage = try await targetStorageUsage( + runtime: targetRuntime, + engineCapacity: engineCapacity + ) + let effectiveEngineCapacity = MigrationEngineCapacity( + logicalBytes: engineCapacity.logicalBytes, + usableBytes: targetUsage.effectiveUsableBytes + ) let selectedImageIDs = Set(selectedKeys.compactMap { $0.kind == .image ? $0.sourceID : nil }) @@ -294,9 +301,9 @@ private extension MigrationStrictInventoryCollector { target: base.targetSnapshot, volumeBytes: volumeBytes, writableSizes: selectedWritableSizes, - targetDockerBytes: targetDockerBytes, + targetDockerBytes: targetUsage.usedBytes, availableHostBytes: availableHostBytes, - engineCapacity: engineCapacity + engineCapacity: effectiveEngineCapacity )) return MigrationStrictStorageInventory(volumeBytes: volumeBytes, capacity: capacity) } diff --git a/Dory/Runtime/Shared/SharedVMProvisioner.swift b/Dory/Runtime/Shared/SharedVMProvisioner.swift index 7585da69..e1b72294 100644 --- a/Dory/Runtime/Shared/SharedVMProvisioner.swift +++ b/Dory/Runtime/Shared/SharedVMProvisioner.swift @@ -1,4 +1,5 @@ import Darwin +import DoryOperations import Foundation /// Brings up Dory's single shared Linux VM — `dory-hv`, our own VMM on Hypervisor.framework — which @@ -204,9 +205,11 @@ nonisolated enum SharedVMProvisioner { ) -> Config { let info = ProcessInfo.processInfo let cpus = max(4, info.activeProcessorCount - 2) - let hostMB = Int(info.physicalMemory / (1024 * 1024)) let floorMB = rosettaX86 ? amd64EmulationMemoryMB : SharedVMProvisioner.defaultEngineMemoryMB - let engineMB = max(floorMB, min(hostMB / 2, hostMB - 4096)) + let engineMB = DoryEngineMemoryPolicy.hostScaledMemoryMB( + physicalMemory: info.physicalMemory, + minimumMemoryMB: floorMB + ) return Config(cpus: cpus, memory: "\(engineMB)M", rosettaX86: rosettaX86, gpuVenus: gpuVenus) } } @@ -283,13 +286,16 @@ nonisolated enum SharedVMProvisioner { guard icdCandidates.contains(where: { fileManager.fileExists(atPath: $0) }) else { return false } // The Venus path exposes host-visible blobs by hv_vm_mapping the pointer virglrenderer returns // from virgl_renderer_resource_map (the libkrun/krunkit model), so probe the renderer actually - // exports a blob-map entrypoint before enabling the toggle. + // exports a blob-map entrypoint and Dory's async macOS fence fix before enabling the + // toggle. A stock or older renderer can appear usable but stall Vulkan applications. for path in rendererCandidates where fileManager.fileExists(atPath: path) { guard let handle = dlopen(path, RTLD_LAZY | RTLD_LOCAL) else { continue } let hasBlobMap = dlsym(handle, "virgl_renderer_resource_map") != nil || dlsym(handle, "virgl_renderer_resource_get_map_ptr") != nil + let hasDoryFenceFix = dlsym(handle, "dory_virglrenderer_macos_venus_fence_fix") != nil + let hasDoryMoltenVKFix = dlsym(handle, "dory_moltenvk_spirv_native_array_fix") != nil dlclose(handle) - if hasBlobMap { return true } + if hasBlobMap && hasDoryFenceFix && hasDoryMoltenVKFix { return true } } return false } @@ -654,20 +660,27 @@ nonisolated enum SharedVMProvisioner { } private static func hvHelperBinary() -> String? { + if Bundle.main.bundleURL.pathExtension == "app" { + let runner = bundledHVRunnerExecutable(in: Bundle.main.bundleURL) + return FileManager.default.isExecutableFile(atPath: runner) ? runner : nil + } let environment = ProcessInfo.processInfo.environment if let override = environment["DORY_HV_HELPER"], !override.isEmpty, FileManager.default.isExecutableFile(atPath: override) { return override } - if let helper = bundledHelperPath(named: "dory-hv"), - FileManager.default.isExecutableFile(atPath: helper) { - return helper - } let cwd = FileManager.default.currentDirectoryPath return helperDevCandidates(named: "dory-hv", cwd: cwd).first { FileManager.default.isExecutableFile(atPath: $0) } } + nonisolated static func bundledHVRunnerExecutable(in applicationURL: URL) -> String { + applicationURL + .appendingPathComponent("Contents/Helpers/DoryHVRunner.app/Contents/MacOS/dory-hv") + .standardizedFileURL + .path + } + nonisolated static func helperDevCandidates( named helperName: String, cwd: String, diff --git a/Dory/Shim/DockerShim.swift b/Dory/Shim/DockerShim.swift index eb854e64..de2f72e1 100644 --- a/Dory/Shim/DockerShim.swift +++ b/Dory/Shim/DockerShim.swift @@ -2112,7 +2112,7 @@ struct DockerShim: Sendable { private static func containerStateStatus(_ status: RunState) -> String { switch status { case .running: "running" - case .paused: "paused" + case .paused, .suspended: "paused" case .stopped: "exited" } } diff --git a/Dory/Shim/ShimContainerMapping.swift b/Dory/Shim/ShimContainerMapping.swift index 597e978c..0b86ea86 100644 --- a/Dory/Shim/ShimContainerMapping.swift +++ b/Dory/Shim/ShimContainerMapping.swift @@ -87,7 +87,7 @@ enum ShimContainerMapping { static func state(_ status: RunState) -> String { switch status { case .running: "running" - case .paused: "paused" + case .paused, .suspended: "paused" case .stopped: "exited" } } @@ -95,7 +95,7 @@ enum ShimContainerMapping { static func statusText(_ container: Container) -> String { switch container.status { case .running: "Up \(container.uptime)" - case .paused: "Paused" + case .paused, .suspended: "Paused" case .stopped: "Exited" } } @@ -278,7 +278,7 @@ enum DockerListFilters { private static func statusValue(_ status: RunState) -> String { switch status { case .running: "running" - case .paused: "paused" + case .paused, .suspended: "paused" case .stopped: "exited" } } diff --git a/DoryTests/AppStoreEnvAllowListTests.swift b/DoryTests/AppStoreEnvAllowListTests.swift index 83904a93..f1e473b0 100644 --- a/DoryTests/AppStoreEnvAllowListTests.swift +++ b/DoryTests/AppStoreEnvAllowListTests.swift @@ -4,38 +4,33 @@ import Testing @MainActor struct AppStoreEnvAllowListTests { - @Test func defaultAllowListIsAnthropicOnly() { + @Test func hostEnvironmentImportDefaultsToDisabled() { let store = AppStore(runtime: MockRuntime()) - #expect(store.machineEnvAllowList == ["ANTHROPIC_API_KEY"]) - } - - @Test func setAllowListNormalizesDedupesAndPersistsUserChoice() { - defer { UserDefaults.standard.removeObject(forKey: AppStore.machineEnvAllowListKey) } - let store = AppStore(runtime: MockRuntime()) - store.setMachineEnvAllowList(["gh_token", " ", "gh_token"]) - #expect(store.machineEnvAllowList == ["GH_TOKEN"]) - #expect(UserDefaults.standard.string(forKey: AppStore.machineEnvAllowListKey) == "GH_TOKEN") + #expect(store.machineEnvAllowList.isEmpty) } - @Test func setAllowListCanDisableAutomaticEnvTransfer() { + @Test func legacyAllowListPreferenceIsClearedAndCannotBeReenabled() { defer { UserDefaults.standard.removeObject(forKey: AppStore.machineEnvAllowListKey) } + UserDefaults.standard.set("ANTHROPIC_API_KEY,GH_TOKEN", forKey: AppStore.machineEnvAllowListKey) let store = AppStore(runtime: MockRuntime()) - store.setMachineEnvAllowList([]) + store.setMachineEnvAllowList(["ANTHROPIC_API_KEY", "GH_TOKEN"]) #expect(store.machineEnvAllowList.isEmpty) - #expect(UserDefaults.standard.string(forKey: AppStore.machineEnvAllowListKey) == "") + #expect(UserDefaults.standard.string(forKey: AppStore.machineEnvAllowListKey) == nil) } - @Test func mergingEnvAddsResolvedButUserKeysWin() { - var settings = MachineSettings.default - settings.env = ["ANTHROPIC_API_KEY": "user-set"] - let merged = AppStore.mergingEnv(settings, resolved: ["ANTHROPIC_API_KEY": "probed", "GH_TOKEN": "gh-123"]) - #expect(merged.env["ANTHROPIC_API_KEY"] == "user-set") - #expect(merged.env["GH_TOKEN"] == "gh-123") - } + @Test func newMachineEnvironmentKeepsOnlyBoundedDoryMetadata() { + let result = AppStore.sanitizedNewMachineEnvironment([ + "ANTHROPIC_API_KEY": "sk-ant-host", + "GH_TOKEN": "gh-host", + "APP_ENV": "development", + "DORY_DESKTOP_DISTRO": "ubuntu", + "DORY_GUEST_USER": "dory", + ]) - @Test func mergingEnvIgnoresEmptyResolved() { - let merged = AppStore.mergingEnv(.default, resolved: [:]) - #expect(merged.env.isEmpty) + #expect(result == [ + "DORY_DESKTOP_DISTRO": "ubuntu", + "DORY_GUEST_USER": "dory", + ]) } @Test func createMachineRejectsPathTraversalName() async { diff --git a/DoryTests/DesktopMachineAssetTests.swift b/DoryTests/DesktopMachineAssetTests.swift index ba607ed0..328f0242 100644 --- a/DoryTests/DesktopMachineAssetTests.swift +++ b/DoryTests/DesktopMachineAssetTests.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import Testing @testable import Dory @@ -10,6 +11,16 @@ struct DesktopMachineAssetTests { #expect(!AppInfo.desktopLinuxIncluded(from: NSNumber(value: false))) } + @Test func qualificationBootstrapBundleValueIsFailClosed() { + #expect(!AppInfo.explicitBuildFlagBundleValue(nil)) + #expect(AppInfo.explicitBuildFlagBundleValue(true)) + #expect(AppInfo.explicitBuildFlagBundleValue(NSNumber(value: true))) + #expect(AppInfo.explicitBuildFlagBundleValue("1")) + #expect(!AppInfo.explicitBuildFlagBundleValue("true")) + #expect(!AppInfo.explicitBuildFlagBundleValue("YES")) + #expect(!AppInfo.explicitBuildFlagBundleValue("unexpected")) + } + @Test func preparesVerifiedSparseAssetsInTheDriveAndReusesMatchingOutputs() throws { let base = FileManager.default.temporaryDirectory .appendingPathComponent("dory-desktop-assets-\(UUID().uuidString)") @@ -38,6 +49,13 @@ struct DesktopMachineAssetTests { ) #expect(prepared.kernelPath == assets.appendingPathComponent("dory-desktop-kernel-arm64").path) #expect(prepared.rootfsPath == assets.appendingPathComponent("dory-desktop-rootfs-arm64.ext4").path) + #expect( + try DesktopMachineAssetProvisioner.preparedKernelSHA256( + at: prepared.kernelPath + ) == SHA256.hash(data: kernelBytes) + .map { String(format: "%02x", $0) } + .joined() + ) let rootfsSize = try #require( try FileManager.default.attributesOfItem(atPath: prepared.rootfsPath)[.size] as? NSNumber ) diff --git a/DoryTests/DockerDiskUsageParserTests.swift b/DoryTests/DockerDiskUsageParserTests.swift index 0618ae01..6f7ba213 100644 --- a/DoryTests/DockerDiskUsageParserTests.swift +++ b/DoryTests/DockerDiskUsageParserTests.swift @@ -89,6 +89,7 @@ struct DockerDiskUsageParserTests { @Test func openSchemaFieldsDoNotBreakExactKnownFields() throws { let response = try data([ "VolumeUsage": [ + "TotalCount": 1, "TotalSize": 42, "FutureSummary": ["value": true], "Items": [[ @@ -123,6 +124,157 @@ struct DockerDiskUsageParserTests { #expect(try DockerDiskUsageParser.totalDockerBytes(from: legacy) == 1_000) } + @Test func totalUsageSupportsDockerAPI140Through155Shapes() throws { + let legacy: [String: Any] = [ + "LayersSize": 100, + "Volumes": [["UsageData": ["Size": 200]]], + "Containers": [["State": "running", "SizeRw": 300]], + "BuildCache": [["Size": 400]] + ] + let current: [String: Any] = [ + "ImageUsage": ["TotalSize": 100], + "VolumeUsage": ["TotalSize": 200], + "ContainerUsage": ["TotalSize": 300], + "BuildCacheUsage": ["TotalSize": 400] + ] + + for minor in 40...55 { + var response = minor <= 52 ? legacy : [:] + if minor >= 52 { + response.merge(current) { _, current in current } + } + #expect( + try DockerDiskUsageParser.totalDockerBytes(from: data(response)) == 1_000, + "failed Docker Engine API 1.\(minor) response shape" + ) + } + } + + @Test func totalUsageReconcilesEachCategoryIndependently() throws { + let mixed = try data([ + "ImageUsage": ["TotalSize": 100], + "Volumes": [["UsageData": ["Size": 200]]], + "ContainerUsage": ["TotalSize": 300], + "BuildCache": [["Size": 400]] + ]) + + #expect(try DockerDiskUsageParser.totalDockerBytes(from: mixed) == 1_000) + } + + @Test func overlappingUsageRepresentationsMustAgreePerCategory() throws { + let conflicts: [[String: Any]] = [ + [ + "ImageUsage": ["TotalSize": 100], + "LayersSize": 101, + "VolumeUsage": ["TotalSize": 200], + "ContainerUsage": ["TotalSize": 300], + "BuildCacheUsage": ["TotalSize": 400] + ], + [ + "ImageUsage": ["TotalSize": 100], + "VolumeUsage": ["TotalSize": 200], + "Volumes": [["UsageData": ["Size": 201]]], + "ContainerUsage": ["TotalSize": 300], + "BuildCacheUsage": ["TotalSize": 400] + ], + [ + "ImageUsage": ["TotalSize": 100], + "VolumeUsage": ["TotalSize": 200], + "ContainerUsage": ["TotalSize": 300], + "Containers": [["State": "running", "SizeRw": 301]], + "BuildCacheUsage": ["TotalSize": 400] + ], + [ + "ImageUsage": ["TotalSize": 100], + "VolumeUsage": ["TotalSize": 200], + "ContainerUsage": ["TotalSize": 300], + "BuildCacheUsage": ["TotalSize": 400], + "BuildCache": [["Size": 401]] + ], + [ + "ImageUsage": ["TotalSize": 100], + "VolumeUsage": [ + "TotalSize": 200, + "Items": [["UsageData": ["Size": 201]]] + ], + "ContainerUsage": ["TotalSize": 300], + "BuildCacheUsage": ["TotalSize": 400] + ] + ] + + for response in conflicts { + #expect(throws: DockerDiskUsageParserError.self) { + try DockerDiskUsageParser.totalDockerBytes(from: data(response)) + } + } + } + + @Test func api152DualShapeCountsSharedBuildCacheLikeTheDaemonAggregate() throws { + // Moby's daemon adds every build-cache record to BuildCacheUsage.TotalSize, including + // records marked Shared. API 1.52 returns that aggregate together with the same legacy + // records, so reconciliation must use the daemon's all-record wire semantics rather than + // the Docker client's separate pre-1.52 display de-duplication policy. + let response = try data([ + "ImageUsage": ["TotalSize": 100], + "LayersSize": 100, + "VolumeUsage": ["TotalSize": 200], + "Volumes": [["UsageData": ["Size": 200]]], + "ContainerUsage": ["TotalSize": 300], + "Containers": [["State": "running", "SizeRw": 300]], + "BuildCacheUsage": ["TotalSize": 700], + "BuildCache": [ + ["Size": 400, "Shared": false], + ["Size": 300, "Shared": true], + ], + ]) + + #expect(try DockerDiskUsageParser.totalDockerBytes(from: response) == 1_300) + } + + @Test func explicitEmptyBuildCacheIsZeroButAnAbsentCategoryIsUnknown() throws { + let withoutBuildCache: [String: Any] = [ + "ImageUsage": ["TotalSize": 100], + "VolumeUsage": ["TotalSize": 200], + "ContainerUsage": ["TotalSize": 300] + ] + let explicitEmptyRepresentations: [Any] = [ + [String: Any](), + ["Items": []] as [String: Any] + ] + + #expect(throws: DockerDiskUsageParserError.missingTotalUsage) { + try DockerDiskUsageParser.totalDockerBytes(from: data(withoutBuildCache)) + } + for usage in explicitEmptyRepresentations { + var response = withoutBuildCache + response["BuildCacheUsage"] = usage + #expect(try DockerDiskUsageParser.totalDockerBytes(from: data(response)) == 600) + } + var legacyEmpty = withoutBuildCache + legacyEmpty["BuildCache"] = [] + #expect(try DockerDiskUsageParser.totalDockerBytes(from: data(legacyEmpty)) == 600) + } + + @Test func documentedPluralAggregateAliasesAreAcceptedButCannotConflict() throws { + let aliases = try data([ + "ImagesUsage": ["TotalSize": 100], + "VolumesUsage": ["TotalSize": 200], + "ContainersUsage": ["TotalSize": 300], + "BuildCacheUsage": ["TotalSize": 400] + ]) + #expect(try DockerDiskUsageParser.totalDockerBytes(from: aliases) == 1_000) + + #expect(throws: DockerDiskUsageParserError.self) { + try DockerDiskUsageParser.totalDockerBytes(from: data([ + "ImageUsage": ["TotalSize": 100], + "ImagesUsage": ["TotalSize": 101], + "VolumeUsage": ["TotalSize": 200], + "ContainerUsage": ["TotalSize": 300], + "BuildCacheUsage": ["TotalSize": 400] + ])) + } + } + @Test func emptyDocker29AndVersionedLegacyResponsesAreExactZero() throws { let docker29 = Data(#"{"Images":[],"Containers":[],"Volumes":[],"BuildCache":[],"ImageUsage":{},"ContainerUsage":{},"VolumeUsage":{},"BuildCacheUsage":{}}"#.utf8) let versionedLegacy = Data(#"{"Images":[],"Containers":[],"Volumes":[],"BuildCache":[]}"#.utf8) @@ -131,6 +283,158 @@ struct DockerDiskUsageParserTests { #expect(try DockerDiskUsageParser.totalDockerBytes(from: versionedLegacy) == 0) } + @Test func zeroSizeAggregatesMayOmitTotalSizeWhileRetainingCounts() throws { + // Moby encodes every aggregate scalar with `omitempty`. Categories containing zero-byte + // objects therefore retain their counts while omitting an exact zero TotalSize. + let response = try data([ + "ImageUsage": ["TotalCount": 2], + "VolumeUsage": ["TotalCount": 1, "Items": [["UsageData": ["Size": 0]]]], + "ContainerUsage": [ + "ActiveCount": 1, + "TotalCount": 1, + "Items": [["State": "running", "SizeRw": 0]], + ], + "BuildCacheUsage": ["TotalCount": 1, "Items": [["Size": 0]]], + ]) + + #expect(try DockerDiskUsageParser.totalDockerBytes(from: response) == 0) + } + + @Test func omittedAggregateTotalRequiresExactNonconflictingWireEvidence() throws { + let invalid: [[String: Any]] = [ + [ + "ImageUsage": ["FutureMetric": 0], + "VolumeUsage": [:], + "ContainerUsage": [:], + "BuildCacheUsage": [:], + ], + [ + "ImageUsage": ["TotalCount": "1"], + "VolumeUsage": [:], + "ContainerUsage": [:], + "BuildCacheUsage": [:], + ], + [ + "ImageUsage": ["ActiveCount": 2, "TotalCount": 1], + "VolumeUsage": [:], + "ContainerUsage": [:], + "BuildCacheUsage": [:], + ], + [ + "ImageUsage": ["TotalCount": 1, "Reclaimable": 1], + "VolumeUsage": [:], + "ContainerUsage": [:], + "BuildCacheUsage": [:], + ], + [ + "ImageUsage": [:], + "VolumeUsage": [ + "TotalCount": 1, + "Items": [["UsageData": ["Size": 1]]], + ], + "ContainerUsage": [:], + "BuildCacheUsage": [:], + ], + [ + "ImageUsage": ["Items": NSNull()], + "VolumeUsage": [:], + "ContainerUsage": [:], + "BuildCacheUsage": [:], + ], + ] + + for response in invalid { + #expect(throws: DockerDiskUsageParserError.self) { + try DockerDiskUsageParser.totalDockerBytes(from: data(response)) + } + } + } + + @Test func aggregateItemsMustExactlyMatchTheirDeclaredCount() throws { + let contradictory: [[String: Any]] = [ + [ + "ImageUsage": ["TotalCount": 1, "Items": []], + "VolumeUsage": [:], + "ContainerUsage": [:], + "BuildCacheUsage": [:], + ], + [ + "ImageUsage": [:], + "VolumeUsage": [ + "Items": [["UsageData": ["Size": 0]]], + ], + "ContainerUsage": [:], + "BuildCacheUsage": [:], + ], + [ + "ImageUsage": [:], + "VolumeUsage": [:], + "ContainerUsage": ["TotalCount": 2, "Items": [[ + "State": "running", "SizeRw": 0, + ]]], + "BuildCacheUsage": [:], + ], + [ + "ImageUsage": [:], + "VolumeUsage": [:], + "ContainerUsage": [:], + "BuildCacheUsage": ["TotalCount": 0, "Items": [["Size": 0]]], + ], + ] + + for response in contradictory { + #expect(throws: DockerDiskUsageParserError.self) { + try DockerDiskUsageParser.totalDockerBytes(from: data(response)) + } + } + + #expect(throws: DockerDiskUsageParserError.invalidVolumeUsage( + "VolumeUsage.Items count does not match TotalCount" + )) { + try DockerDiskUsageParser.namedVolumeSizes(from: data([ + "VolumeUsage": [ + "TotalCount": 2, + "Items": legacyVolumes(["database": 42]), + ], + ])) + } + } + + @Test func omittedContainerSizeIsExactZeroForEveryMobyState() throws { + let states = ["created", "restarting", "running", "removing", "paused", "exited", "dead"] + for state in states { + let legacy = try data([ + "Images": [], + "Volumes": [], + "Containers": [["State": state]], + "BuildCache": [], + ]) + let current = try data([ + "ImageUsage": [:], + "VolumeUsage": [:], + "ContainerUsage": ["TotalCount": 1, "Items": [["State": state]]], + "BuildCacheUsage": [:], + ]) + #expect(try DockerDiskUsageParser.totalDockerBytes(from: legacy) == 0) + #expect(try DockerDiskUsageParser.totalDockerBytes(from: current) == 0) + } + + let malformed = [ + ["State": "running", "SizeRw": NSNull()] as [String: Any], + ["State": "unknown"] as [String: Any], + ] + for container in malformed { + #expect(throws: DockerDiskUsageParserError.self) { + try DockerDiskUsageParser.totalDockerBytes(from: data([ + "Images": [], + "Volumes": [], + "Containers": [container], + "BuildCache": [], + ])) + } + } + } + @Test func emptyUsageMustStillBeCompleteAndUnambiguous() throws { let invalid: [[String: Any]] = [ [ @@ -196,6 +500,7 @@ struct DockerDiskUsageParserTests { private func currentVolumeUsage(_ sizes: [String: Int64]) -> [String: Any] { [ + "TotalCount": sizes.count, "TotalSize": sizes.values.reduce(0, +), "Items": legacyVolumes(sizes) ] diff --git a/DoryTests/DoryProcessMemoryTests.swift b/DoryTests/DoryProcessMemoryTests.swift index 5529872a..5f55de60 100644 --- a/DoryTests/DoryProcessMemoryTests.swift +++ b/DoryTests/DoryProcessMemoryTests.swift @@ -10,7 +10,7 @@ struct DoryProcessMemoryTests { currentPID: 1 ) == .app) #expect(DoryProcessMemorySampler.classify(pid: 11, name: "doryd", path: "/Applications/Dory.app/Contents/Helpers/doryd", currentPID: 1) == .daemon) - #expect(DoryProcessMemorySampler.classify(pid: 12, name: "dory-hv", path: "/Applications/Dory.app/Contents/Helpers/dory-hv", currentPID: 1) == .dockerVM) + #expect(DoryProcessMemorySampler.classify(pid: 12, name: "dory-hv", path: "/Applications/Dory.app/Contents/Helpers/DoryHVRunner.app/Contents/MacOS/dory-hv", currentPID: 1) == .dockerVM) #expect(DoryProcessMemorySampler.classify(pid: 13, name: "dory-vmm", path: "/Applications/Dory.app/Contents/Helpers/dory-vmm", currentPID: 1) == .machineVM) #expect(DoryProcessMemorySampler.classify(pid: 14, name: "gvproxy", path: "/Applications/Dory.app/Contents/Helpers/gvproxy", currentPID: 1) == .networking) #expect(DoryProcessMemorySampler.classify(pid: 15, name: "Safari", path: "/Applications/Safari.app/Contents/MacOS/Safari", currentPID: 1) == nil) diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 0e2b5b18..d124212e 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -1,9 +1,608 @@ import Foundation +import DoryOperations import Testing @testable import Dory @Suite(.serialized) struct DorydClientTests { + @MainActor + @Test func machineDisplayPresentationRoundTripsExactXPCShape() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + let presentation = DoryMachineDisplayPresentation(assignments: [ + .init( + guestDisplayID: "display-0", + mode: .dedicatedFullscreen, + hostDisplayUUID: "00000000-0000-0000-0000-000000000001" + ), + ]) + let status = try await client.machineDisplayPresentationSet( + "dev", + presentation: presentation + ) + #expect(status.displayPresentation == presentation) + #expect(try await client.machineList().first?.displayPresentation == presentation) + } + + @MainActor + @Test func machineUSBControlRequiresExactResolvedResponse() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let hostDevices = try await client.hostUSBDevices() + #expect(hostDevices == [DorydHostUSBDevice( + busID: "3-2", + vendorID: 0x05ac, + productID: 0x12a8, + vendorName: "Example Vendor", + productName: "Example Device", + deviceClass: 3, + speed: 4 + )]) + + service.setHostUSBDevicesResponse([ + [ + "busID": "3-2", + "vendorID": 0x05ac, + "productID": 0x12a8, + "vendorName": "Example Vendor", + "productName": "Example Device", + "deviceClass": 3, + "speed": 4, + "serialNumber": "must-not-cross-xpc", + ], + ]) + await #expect(throws: (any Error).self) { + _ = try await client.hostUSBDevices() + } + + let attachment = try await client.machineUSBAttach("dev", busID: "3-2") + #expect(attachment == DorydMachineUSBAttachment( + machineID: "dev", + busID: "3-2", + port: 4, + vsockPort: 1_025, + deviceID: 0x0003_0002, + speed: 3 + )) + try await client.machineUSBDetach("dev", busID: "3-2") + + service.setMachineUSBAttachResponse([ + "machineID": "dev", + "busID": "3-2", + "port": 4, + "vsockPort": 1_025, + "deviceID": 0x0003_0002, + "speed": 3, + "unexpected": true, + ]) + await #expect(throws: (any Error).self) { + _ = try await client.machineUSBAttach("dev", busID: "3-2") + } + + service.setMachineUSBAttachResponse([ + "machineID": "dev", + "busID": "3-2", + "port": true, + "vsockPort": 1_025, + "deviceID": 0x0003_0002, + "speed": 3, + ]) + await #expect(throws: (any Error).self) { + _ = try await client.machineUSBAttach("dev", busID: "3-2") + } + + service.setMachineUSBAttachResponse([ + "machineID": "dev", + "busID": "3-2", + "port": 4, + "vsockPort": 1_026, + "deviceID": 0x0003_0002, + "speed": 3, + ]) + await #expect(throws: (any Error).self) { + _ = try await client.machineUSBAttach("dev", busID: "3-2") + } + + service.setMachineUSBDetachResponse([ + "machineID": "dev", + "busID": "different", + ]) + await #expect(throws: (any Error).self) { + try await client.machineUSBDetach("dev", busID: "3-2") + } + } + + @MainActor + @Test func desktopUpdateRejectsPresentMalformedOperationIdentity() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + service.setMachineDesktopUpdateOperationIDResponse(NSNumber(value: 7)) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + await #expect(throws: (any Error).self) { + _ = try await client.machineDesktopUpdate( + "dev", + distro: "ubuntu", + version: "1.0.0", + distributionInstallationName: "ubuntu-1", + runtimeInstallationName: "runtime-1" + ) + } + } + + @Test func dorydSharesPreserveStableTagsAndAllocateAroundExistingIdentity() { + let mounts = [ + MountPair(host: "/tmp/first", guest: "/workspace/first", shareTag: "doryapp0"), + MountPair(host: "/tmp/new-a", guest: "/workspace/new-a"), + MountPair(host: "/tmp/stable", guest: "/workspace/stable", shareTag: "project-src"), + MountPair(host: "/tmp/new-b", guest: "/workspace/new-b"), + ] + + let shares = AppStore.dorydShares(from: mounts) + + #expect(shares.map(\.tag) == ["doryapp0", "doryapp1", "project-src", "doryapp2"]) + #expect( + AppStore.dorydShares(from: [mounts[2], mounts[0]]).map(\.tag) + == ["project-src", "doryapp0"] + ) + } + + @MainActor + @Test func machineTransferUsesPrivateStageAndRejectsMalformedEvidence() async throws { + let root = URL(fileURLWithPath: "/tmp/dory-client-transfer-\(getpid())-\(UInt32.random(in: 0..= 60) + } + @Test func customDomainPatternsAcceptExactAndLeftmostWildcardOnly() { #expect(AppStore.normalizedCustomDomainPattern(" Admin.MyProject.Local. ") == "admin.myproject.local") #expect(AppStore.normalizedCustomDomainPattern("*.Tenant.Test") == "*.tenant.test") @@ -113,54 +716,898 @@ struct DorydClientTests { #expect(try await client.engineSleep() == DorydCommandResult(ok: true, message: "")) } - @MainActor - @Test func readsDoctorJSONAndIncidentsOverXPC() async throws { + @Test func machineStopAndDeleteOutliveTheDefaultControlTimeout() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService(machineShutdownReplyDelay: 0.05) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let client = DorydClient(endpoint: listener.endpoint, timeout: 0.01) + #expect(try await client.machineStop("dev").state == "stopped") + #expect(try await client.machineDelete("dev") == DorydCommandResult(ok: true, message: "")) + } + + @Test func machineListPrefersExactTypedSettingsAndRejectsMalformedClaims() async throws { let listener = NSXPCListener.anonymous() let service = FakeDorydService() + service.setMachineTypedSettings("dev", [ + "guestIdentityIntent": [ + "account": [ + "username": "developer", + "numericUserID": UInt32(1_000), + ] as NSDictionary, + "desktop": [ + "distributionIdentifier": "ubuntu", + "displayName": "Ubuntu", + ] as NSDictionary, + ] as NSDictionary, + "clipboardPolicy": [ + "text": "bidirectional", "image": "bidirectional", "files": "off", + ] as NSDictionary, + "desktopRuntimePreference": "accelerated", + "desktopGraphicsPreference": "virgl-venus", + "networkMode": "shared-nat", + "portForwards": [[ + "id": "web", + "transport": "tcp", + "hostPort": 8_080, + "guestPort": 80, + "exposure": "loopback", + ] as NSDictionary] as NSArray, + "audio": [ + "inputEnabled": false, + "outputEnabled": true, + ] as NSDictionary, + "cameraEnabled": true, + "intelApplicationTranslationEnabled": true, + ]) let delegate = FakeDorydListenerDelegate(service: service) listener.delegate = delegate listener.resume() defer { listener.invalidate() } let client = DorydClient(endpoint: listener.endpoint) - let version = try await client.protocolVersion() - let socketPath = try await client.dorySocketPath() - let engineStatus = try await client.engineStatus() - let started = try await client.engineStart() - let slept = try await client.engineSleep() - let woke = try await client.engineWake() - let dockerAgentInfo = try await client.dockerAgentInfo() - let dockerAgentPorts = try await client.dockerAgentPorts() - let dockerAgentTelemetry = try await client.dockerAgentTelemetry() - let stopped = try await client.engineStop() - let createdMachine = try await client.machineCreate(DorydMachineConfiguration( - id: "dev", - kernelPath: "/tmp/kernel", - rootfsPath: "/tmp/rootfs", - memoryMB: 2048, - cpuCount: 2, - address: "192.168.215.40", - displayMode: .desktop, - shares: [ - DorydMachineShareConfiguration(tag: "src", hostPath: "/Users/me/src", guestPath: "/workspace/src", readOnly: true), - ], - environment: ["FOO": "bar"] + let status = try #require((try await client.machineList()).first { $0.id == "dev" }) + #expect(status.environment.isEmpty) + #expect(status.typedSettings?.guestIdentityIntent.account?.username == "developer") + #expect(status.typedSettings?.guestIdentityIntent.desktop?.distributionIdentifier + == "ubuntu") + #expect(status.typedSettings?.runtimePreference == .accelerated) + #expect(status.typedSettings?.graphicsPreference == .virglVenus) + #expect(status.typedSettings?.networkMode == .sharedNAT) + #expect(status.typedSettings?.portForwards == [ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + ]) + #expect(status.typedSettings?.audioConfiguration == DoryVMAudioConfiguration( + inputEnabled: false, + outputEnabled: true )) - let startedMachine = try await client.machineStart("dev") - let machineStats = try await client.machineStats("dev") - let execResult = try await client.machineExec("dev", argv: ["/bin/sh", "-lc", "cargo --version"]) - let provisionedMachine = try await client.machineProvision("dev", recipe: "rust") - let snapshot = try await client.machineSnapshot( + #expect(status.typedSettings?.cameraConfiguration + == DoryVMCameraConfiguration(enabled: true)) + #expect(status.typedSettings?.intelApplicationTranslationEnabled == true) + + service.setMachineTypedSettings("dev", ["unknown": "claim"]) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + + for malformed: NSDictionary in [ + ["audio": ["inputEnabled": 0, "outputEnabled": true]], + ["cameraEnabled": 1], + ["intelApplicationTranslationEnabled": 1], + ["audio": ["inputEnabled": true]], + ["portForwards": [[ + "id": "web", "transport": "tcp", "hostPort": 443, + "guestPort": 80, "exposure": "loopback", + ]]], + [ + "networkMode": "disconnected", + "portForwards": [[ + "id": "web", "transport": "tcp", "hostPort": 8080, + "guestPort": 80, "exposure": "loopback", + ]], + ], + [ + "audio": [ + "inputEnabled": true, + "outputEnabled": true, + "route": "private-host-device", + ], + ], + ] { + service.setMachineTypedSettings("dev", malformed) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + } + } + + @Test func machineListRequiresExactSavedStateEvidenceForSuspendedRows() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let client = DorydClient(endpoint: listener.endpoint) + let valid: NSDictionary = [ + "schemaVersion": 1, + "backend": "apple-virtualization-framework", + "stateFileSHA256": String(repeating: "b", count: 64), + "stateFileByteCount": UInt64(8192), + "hostHardwareModel": "Mac16,1", + "hostOperatingSystemBuild": "25G90", + "createdAtUnixMilliseconds": Int64(1_787_318_400_000), + "portable": false, + ] + service.setMachineState("dev", "suspended") + service.setMachineSavedState("dev", valid) + + let suspended = try #require((try await client.machineList()).first { $0.id == "dev" }) + #expect(suspended.state == "suspended") + #expect(suspended.savedState?.stateFileSHA256 == String(repeating: "b", count: 64)) + #expect(suspended.savedState?.stateFileByteCount == 8192) + + let malformed = valid.mutableCopy() as! NSMutableDictionary + malformed["unknown"] = "claim" + service.setMachineSavedState("dev", malformed) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + + let wrongWireType = valid.mutableCopy() as! NSMutableDictionary + wrongWireType["schemaVersion"] = "1" + service.setMachineSavedState("dev", wrongWireType) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + + let nonStringKey = valid.mutableCopy() as! NSMutableDictionary + nonStringKey[NSNumber(value: 9)] = "claim" + service.setMachineSavedState("dev", nonStringKey) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + + service.setMachineSavedState("dev", nil) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + } + + @Test func machineListRequiresExactStructuredFailureAndOperationEvidence() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + let operationID = "01234567-89ab-4cde-8fab-0123456789ab" + let validFailure: NSDictionary = [ + "schemaVersion": UInt16(1), + "code": "readiness-timed-out", + "occurredAtUnixMilliseconds": Int64(1_787_318_400_000), + "operationID": operationID, + "causalChain": ["readiness-gate"], + "recoveryDisposition": "retry", + "evidenceReferences": [[ + "kind": "journal", + "identifier": operationID, + ] as NSDictionary], + ] + let activeOperation: NSDictionary = [ + "operationID": operationID, + "kind": "starting", + ] + service.setMachineFailure( "dev", - note: "before", - createdISO: "2026-07-07T00:00:00Z", - snapshotID: "s1" + validFailure, + activeOperation: activeOperation ) - let snapshots = try await client.machineSnapshots(machineID: "dev") - let clonedSnapshot = try await client.machineCloneSnapshot(machineID: "dev", snapshotID: "s1", newID: "dev-copy") - let restoredSnapshot = try await client.machineRestoreSnapshot(machineID: "dev", snapshotID: "s1") - let exportedSnapshot = try await client.machineExportSnapshot(machineID: "dev", snapshotID: "s1", to: "/tmp/dev.dorymachine") - let importedSnapshot = try await client.machineImportSnapshot(from: "/tmp/dev.dorymachine") + + let valid = try #require((try await client.machineList()).first) + #expect(valid.failure?.code == .readinessTimedOut) + #expect(valid.failure?.causalChain == [.readinessGate]) + #expect(valid.failure?.recoveryDisposition == .retry) + #expect(valid.failure?.operationID == operationID) + #expect(valid.activeOperation?.operationID == operationID) + #expect(valid.activeOperation?.kind == .starting) + + let unknown = validFailure.mutableCopy() as! NSMutableDictionary + unknown["detail"] = "/private/opaque" + service.setMachineFailure("dev", unknown, activeOperation: activeOperation) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + + let pathEvidence = validFailure.mutableCopy() as! NSMutableDictionary + pathEvidence["evidenceReferences"] = [[ + "kind": "journal", "identifier": "/private/journal", + ] as NSDictionary] + service.setMachineFailure("dev", pathEvidence, activeOperation: activeOperation) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + + service.setMachineFailure( + "dev", + validFailure, + activeOperation: [ + "operationID": operationID, + "kind": "future-operation", + ] as NSDictionary + ) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + } + + @Test func machineListRequiresExactFlightRecorderSummary() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + service.setMachineFlightRecorderSummary("dev", [ + "headSequence": UInt64(17), + "available": true, + ] as NSDictionary) + let current = try #require((try await client.machineList()).first) + #expect(current.flightRecorderHeadSequence == 17) + #expect(current.flightRecorderAvailable) + + service.setMachineFlightRecorderSummary("dev", nil) + let oldDaemon = try #require((try await client.machineList()).first) + #expect(oldDaemon.flightRecorderHeadSequence == 0) + #expect(!oldDaemon.flightRecorderAvailable) + + service.setMachineFlightRecorderSummary("dev", [ + "headSequence": "17", + "available": true, + ] as NSDictionary) + await #expect(throws: (any Error).self) { + _ = try await client.machineList() + } + } + + @MainActor + @Test func machineEventCursorRequiresExactOrderedSafeEvidence() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let valid = FakeDorydService.machineEventBatchRow() + service.setMachineEventBatch(valid) + let batch = try await client.machineEvents(afterSequence: 4) + #expect(batch.headSequence == 5) + #expect(!batch.snapshotRequired) + #expect(batch.events.map(\.sequence) == [5]) + #expect(batch.events.first?.status?.state == "running") + + let failureBatch = valid.mutableCopy() as! NSMutableDictionary + let failureEvent = (valid["events"] as! [NSDictionary])[0] + .mutableCopy() as! NSMutableDictionary + let failureStatus = (failureEvent["status"] as! NSDictionary) + .mutableCopy() as! NSMutableDictionary + failureStatus["hasFailure"] = true + failureStatus["failureCode"] = "helper-exited" + failureStatus["recoveryDisposition"] = "retry" + failureStatus["operationID"] = "01234567-89ab-4cde-8fab-0123456789ab" + failureStatus["operationKind"] = "starting" + failureEvent["status"] = failureStatus + failureBatch["events"] = [failureEvent] + service.setMachineEventBatch(failureBatch) + let failed = try await client.machineEvents(afterSequence: 4) + #expect(failed.events.first?.status?.failureCode == .helperExited) + #expect(failed.events.first?.status?.recoveryDisposition == .retry) + #expect(failed.events.first?.status?.operationKind == .starting) + + let truncatedFailureBatch = failureBatch.mutableCopy() as! NSMutableDictionary + let truncatedEvent = failureEvent.mutableCopy() as! NSMutableDictionary + let truncatedStatus = failureStatus.mutableCopy() as! NSMutableDictionary + truncatedStatus.removeObject(forKey: "recoveryDisposition") + truncatedEvent["status"] = truncatedStatus + truncatedFailureBatch["events"] = [truncatedEvent] + service.setMachineEventBatch(truncatedFailureBatch) + await #expect(throws: (any Error).self) { + _ = try await client.machineEvents(afterSequence: 4) + } + + let eventRows = valid["events"] as! [NSDictionary] + let invalidStatusEvent = eventRows[0].mutableCopy() as! NSMutableDictionary + let invalidStatus = (invalidStatusEvent["status"] as! NSDictionary) + .mutableCopy() as! NSMutableDictionary + invalidStatus["hostPath"] = "/private/source" + invalidStatusEvent["status"] = invalidStatus + let invalidStatusBatch = valid.mutableCopy() as! NSMutableDictionary + invalidStatusBatch["events"] = [invalidStatusEvent] + service.setMachineEventBatch(invalidStatusBatch) + await #expect(throws: (any Error).self) { + _ = try await client.machineEvents(afterSequence: 4) + } + + let gap = valid.mutableCopy() as! NSMutableDictionary + let gapEvent = eventRows[0].mutableCopy() as! NSMutableDictionary + gapEvent["sequence"] = UInt64(6) + gap["headSequence"] = UInt64(6) + gap["events"] = [gapEvent] + service.setMachineEventBatch(gap) + await #expect(throws: (any Error).self) { + _ = try await client.machineEvents(afterSequence: 4) + } + + let contradictory = valid.mutableCopy() as! NSMutableDictionary + contradictory["snapshotRequired"] = true + service.setMachineEventBatch(contradictory) + await #expect(throws: (any Error).self) { + _ = try await client.machineEvents(afterSequence: 4) + } + } + + @MainActor + @Test func appStoreUsesMachineEventCursorWithSnapshotFallback() async throws { + let base = "/tmp/dory-events-app-\(getpid())-\(UInt32.random(in: 0..= 1 + && service.machineListCount >= 1 + && store.machines.contains(where: { $0.name == "dev" }) + } + + let initialLists = service.machineListCount + let initialQueries = service.machineEventQueryCount + service.setMachineEventBatch([ + "schemaVersion": UInt16(1), + "headSequence": UInt64(1), + "snapshotRequired": false, + "events": [] as [NSDictionary], + ]) + store.loadMachines() + try await waitUntil { service.machineEventQueryCount > initialQueries } + try await Task.sleep(for: .milliseconds(50)) + #expect(service.machineListCount == initialLists) + + let beforeChangedList = service.machineListCount + service.setMachineEventBatch( + FakeDorydService.machineEventBatchRow(sequence: 2) + ) + store.loadMachines() + try await waitUntil { service.machineListCount > beforeChangedList } + + let beforeFallbackList = service.machineListCount + service.setMachineEventBatch([:]) + store.loadMachines() + try await waitUntil { service.machineListCount > beforeFallbackList } + } + + @MainActor + @Test func machineFlightRecorderRequiresExactPathFreeCursorEvidence() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let event: NSDictionary = [ + "schemaVersion": UInt16(1), + "sequence": UInt64(1), + "occurredAtUnixMilliseconds": Int64(1_000), + "machineID": "dev", + "kind": "workspace-created", + "evidenceReferences": [] as [NSDictionary], + ] + let valid: NSDictionary = [ + "schemaVersion": UInt16(1), + "machineID": "dev", + "headSequence": UInt64(1), + "snapshotRequired": false, + "events": [event], + ] + service.setMachineFlightRecorderBatch(valid) + let batch = try await client.machineFlightRecorder( + machineID: "dev", + afterSequence: 0 + ) + #expect(batch.headSequence == 1) + #expect(batch.events.first?.kind == .workspaceCreated) + + let leakedEvent = event.mutableCopy() as! NSMutableDictionary + leakedEvent["detail"] = "/private/opaque" + let leaked = valid.mutableCopy() as! NSMutableDictionary + leaked["events"] = [leakedEvent] + service.setMachineFlightRecorderBatch(leaked) + await #expect(throws: (any Error).self) { + _ = try await client.machineFlightRecorder( + machineID: "dev", + afterSequence: 0 + ) + } + + let gapEvent = event.mutableCopy() as! NSMutableDictionary + gapEvent["sequence"] = UInt64(2) + let gap = valid.mutableCopy() as! NSMutableDictionary + gap["headSequence"] = UInt64(2) + gap["events"] = [gapEvent] + service.setMachineFlightRecorderBatch(gap) + await #expect(throws: (any Error).self) { + _ = try await client.machineFlightRecorder( + machineID: "dev", + afterSequence: 0 + ) + } + + let deviceEvent = event.mutableCopy() as! NSMutableDictionary + deviceEvent["kind"] = "device-health-event" + deviceEvent["operationID"] = "12345678-1234-4234-8234-123456789abc" + deviceEvent["operationKind"] = "starting" + deviceEvent["deviceID"] = "virtio-network-7" + deviceEvent["deviceEventKind"] = "queue-stall" + deviceEvent["deviceEventSequence"] = UInt64(1) + deviceEvent["deviceEventOccurrences"] = UInt64(2) + let deviceBatch = valid.mutableCopy() as! NSMutableDictionary + deviceBatch["events"] = [deviceEvent] + service.setMachineFlightRecorderBatch(deviceBatch) + let deviceFlight = try await client.machineFlightRecorder( + machineID: "dev", + afterSequence: 0 + ) + #expect(deviceFlight.events.first?.kind == .deviceHealthEvent) + #expect(deviceFlight.events.first?.deviceID == "virtio-network-7") + #expect(deviceFlight.events.first?.deviceEventKind == "queue-stall") + #expect(deviceFlight.events.first?.deviceEventOccurrences == 2) + + deviceEvent.removeObject(forKey: "deviceEventOccurrences") + service.setMachineFlightRecorderBatch(deviceBatch) + await #expect(throws: (any Error).self) { + _ = try await client.machineFlightRecorder( + machineID: "dev", + afterSequence: 0 + ) + } + } + + @MainActor + @Test func machineDeviceTelemetryRequiresExactBoundedLaunchEvidence() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let operationID = "12345678-1234-4234-8234-123456789abc" + let metric: NSDictionary = [ + "kind": "receive-drops", + "unit": "count", + "availability": "measured", + "value": UInt64(2), + ] + let device: NSDictionary = [ + "id": "virtio-network-7", + "kind": "network", + "health": "degraded", + "metrics": [ + metric, + [ + "kind": "configured-port-forwards", + "unit": "count", + "availability": "measured", + "value": UInt64(2), + ] as NSDictionary, + [ + "kind": "active-port-forwards", + "unit": "count", + "availability": "measured", + "value": UInt64(1), + ] as NSDictionary, + [ + "kind": "port-forward-reconciliation-failures", + "unit": "count", + "availability": "measured", + "value": UInt64(3), + ] as NSDictionary, + ], + ] + let event: NSDictionary = [ + "sequence": UInt64(1), + "monotonicNanoseconds": UInt64(20), + "deviceID": "virtio-network-7", + "kind": "queue-stall", + "occurrences": UInt64(2), + ] + let valid: NSDictionary = [ + "schemaVersion": UInt16(1), + "machineID": "dev", + "operationID": operationID, + "backend": "apple-virtualization-framework", + "sampleSequence": UInt64(1), + "sampledAtUnixMilliseconds": UInt64(10), + "monotonicNanoseconds": UInt64(20), + "devices": [device], + "events": [event], + ] + service.setMachineDeviceTelemetryResponse(valid) + let snapshot = try await client.machineDeviceTelemetry("dev") + #expect(snapshot.operationID == operationID) + #expect(snapshot.backend == .appleVirtualizationFramework) + #expect(snapshot.devices.first?.metrics.first?.value == 2) + #expect(snapshot.devices.first?.metrics.last?.kind == "port-forward-reconciliation-failures") + #expect(snapshot.devices.first?.metrics.last?.value == 3) + #expect(snapshot.events.first?.kind == "queue-stall") + #expect(snapshot.events.first?.occurrences == 2) + + let recoveryEvent = event.mutableCopy() as! NSMutableDictionary + recoveryEvent["sequence"] = UInt64(2) + recoveryEvent["kind"] = "port-forward-recovered" + let validWithRecovery = valid.mutableCopy() as! NSMutableDictionary + validWithRecovery["events"] = [event, recoveryEvent] + service.setMachineDeviceTelemetryResponse(validWithRecovery) + let recovered = try await client.machineDeviceTelemetry("dev") + #expect(recovered.events.last?.kind == "port-forward-recovered") + + let unavailableWithValue: NSDictionary = [ + "kind": "receive-drops", + "unit": "count", + "availability": "unavailable", + "unavailableReason": "framework API unavailable", + "value": UInt64(0), + ] + let invalidDevice = device.mutableCopy() as! NSMutableDictionary + invalidDevice["metrics"] = [unavailableWithValue] + let invalidMetricShape = valid.mutableCopy() as! NSMutableDictionary + invalidMetricShape["devices"] = [invalidDevice] + + let wrongUnitMetric = metric.mutableCopy() as! NSMutableDictionary + wrongUnitMetric["unit"] = "bytes" + let wrongUnitDevice = device.mutableCopy() as! NSMutableDictionary + wrongUnitDevice["metrics"] = [wrongUnitMetric] + let invalidMetricUnit = valid.mutableCopy() as! NSMutableDictionary + invalidMetricUnit["devices"] = [wrongUnitDevice] + + let orphanEvent = event.mutableCopy() as! NSMutableDictionary + orphanEvent["deviceID"] = "virtio-storage-9" + let invalidEventAuthority = valid.mutableCopy() as! NSMutableDictionary + invalidEventAuthority["events"] = [orphanEvent] + + for malformed in [ + valid.adding("hostPath", "/private/opaque"), + valid.replacing("sampleSequence", with: true), + invalidMetricShape, + invalidMetricUnit, + invalidEventAuthority, + ] { + service.setMachineDeviceTelemetryResponse(malformed) + await #expect(throws: (any Error).self) { + _ = try await client.machineDeviceTelemetry("dev") + } + } + } + + @MainActor + @Test func machineSerialConsoleRequiresExactBoundedCursorEvidence() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + let generation = String(repeating: "a", count: 64) + service.setMachineSerialConsoleBatch([ + "schemaVersion": UInt16(1), + "machineID": "dev", + "generation": generation, + "startOffset": UInt64(0), + "nextOffset": UInt64(4), + "totalBytes": UInt64(4), + "snapshotRequired": true, + "inputAvailable": false, + "bytesBase64": Data("boot".utf8).base64EncodedString(), + ]) + let initial = try await client.machineSerialConsole(machineID: "dev", limit: 64) + #expect(initial.bytes == Data("boot".utf8)) + #expect(initial.snapshotRequired) + #expect(initial.cursor.generation == generation) + #expect(initial.cursor.offset == 4) + #expect(service.latestMachineSerialConsoleCursor?["offset"] as? UInt64 == 0) + + service.setMachineSerialConsoleBatch([ + "schemaVersion": UInt16(1), + "machineID": "dev", + "generation": generation, + "startOffset": UInt64(4), + "nextOffset": UInt64(10), + "totalBytes": UInt64(10), + "snapshotRequired": false, + "inputAvailable": true, + "bytesBase64": Data("ready\n".utf8).base64EncodedString(), + ]) + let appended = try await client.machineSerialConsole( + machineID: "dev", + cursor: initial.cursor, + limit: 64 + ) + #expect(appended.bytes == Data("ready\n".utf8)) + #expect(!appended.snapshotRequired) + #expect(appended.inputAvailable) + + let valid = service.machineSerialConsoleBatchResponse + for malformed in [ + valid.adding("hostPath", "/private/opaque"), + valid.replacing("bytesBase64", with: "not-base64"), + valid.replacing("startOffset", with: UInt64(3)), + valid.replacing("snapshotRequired", with: 0), + ] { + service.setMachineSerialConsoleBatch(malformed) + await #expect(throws: (any Error).self) { + _ = try await client.machineSerialConsole( + machineID: "dev", + cursor: initial.cursor, + limit: 64 + ) + } + } + + service.setMachineSerialConsoleBatch(nil) + let write = try await client.writeMachineSerialConsole( + machineID: "dev", + data: Data("recovery\n".utf8) + ) + #expect(write.ok) + #expect(service.latestMachineSerialConsoleInput == Data("recovery\n".utf8)) + await #expect(throws: (any Error).self) { + _ = try await client.writeMachineSerialConsole( + machineID: "dev", + data: Data(repeating: 1, count: 4 * 1_024 + 1) + ) + } + } + + @MainActor + @Test func machineImportAssessmentRequiresExactClosedEvidence() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let valid = FakeDorydService.importAssessmentRow() + service.setMachineImportAssessment(valid) + let assessment = try await client.machineAssessSnapshotImport( + from: "/tmp/dev.dorymachine" + ) + #expect(assessment.contentID == String(repeating: "a", count: 64)) + #expect(assessment.disposition == .ready) + #expect(assessment.portable) + + let unknown = valid.mutableCopy() as! NSMutableDictionary + unknown["unexpected"] = "claim" + service.setMachineImportAssessment(unknown) + await #expect(throws: (any Error).self) { + _ = try await client.machineAssessSnapshotImport(from: "/tmp/dev.dorymachine") + } + + let wrongWireType = valid.mutableCopy() as! NSMutableDictionary + wrongWireType["diskSizeBytes"] = "4096" + service.setMachineImportAssessment(wrongWireType) + await #expect(throws: (any Error).self) { + _ = try await client.machineAssessSnapshotImport(from: "/tmp/dev.dorymachine") + } + + let contradictory = valid.mutableCopy() as! NSMutableDictionary + contradictory["disposition"] = "requires-components" + service.setMachineImportAssessment(contradictory) + await #expect(throws: (any Error).self) { + _ = try await client.machineAssessSnapshotImport(from: "/tmp/dev.dorymachine") + } + } + + @MainActor + @Test func dockerGuestDataDiskUsageRequiresExactVersionedFilesystemRecord() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService(dockerGuestDataDiskUsageReplyDelay: 0.05) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let client = DorydClient(endpoint: listener.endpoint, timeout: 0.01) + let dataDriveID = try #require(UUID(uuidString: FakeDorydService.dockerDataDriveID)) + #expect(try await client.dockerGuestDataDiskUsage() == DorydDockerGuestDataDiskUsage( + engineSocketPath: service.socketPath, + dataDriveID: dataDriveID, + totalBytes: 128 * 1024 * 1024 * 1024, + usedBytes: 8 * 1024 * 1024 * 1024, + availableBytes: 120 * 1024 * 1024 * 1024 + )) + + let malformed: [NSDictionary] = [ + [:], + FakeDorydService.dockerGuestDataDiskUsageRow().adding("schema", UInt16(2)), + FakeDorydService.dockerGuestDataDiskUsageRow().adding("usedBytes", true), + FakeDorydService.dockerGuestDataDiskUsageRow().adding("usedBytes", -1), + FakeDorydService.dockerGuestDataDiskUsageRow().adding("usedBytes", 1.5), + FakeDorydService.dockerGuestDataDiskUsageRow().adding("schema", "1"), + FakeDorydService.dockerGuestDataDiskUsageRow().adding("engineSocketPath", "relative.sock"), + FakeDorydService.dockerGuestDataDiskUsageRow().adding("dataDriveID", "not-a-uuid"), + FakeDorydService.dockerGuestDataDiskUsageRow().adding( + "dataDriveID", + dataDriveID.uuidString + ), + FakeDorydService.dockerGuestDataDiskUsageRow().adding("totalBytes", UInt64(0)), + FakeDorydService.dockerGuestDataDiskUsageRow().adding("usedBytes", UInt64.max), + FakeDorydService.dockerGuestDataDiskUsageRow().adding("availableBytes", UInt64.max), + FakeDorydService.dockerGuestDataDiskUsageRow().adding( + "availableBytes", + UInt64(127 * 1024 * 1024 * 1024) + ), + FakeDorydService.dockerGuestDataDiskUsageRow().adding("unexpected", true), + ] + for response in malformed { + service.setDockerGuestDataDiskUsage(response) + await #expect(throws: DorydClientError.self) { + _ = try await client.dockerGuestDataDiskUsage() + } + } + } + + @MainActor + @Test func readsDoctorJSONAndIncidentsOverXPC() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let client = DorydClient(endpoint: listener.endpoint) + let version = try await client.protocolVersion() + let socketPath = try await client.dorySocketPath() + let engineStatus = try await client.engineStatus() + let started = try await client.engineStart() + let slept = try await client.engineSleep() + let woke = try await client.engineWake() + let dockerAgentInfo = try await client.dockerAgentInfo() + let dockerAgentPorts = try await client.dockerAgentPorts() + let dockerAgentTelemetry = try await client.dockerAgentTelemetry() + let dockerGuestDataDiskUsage = try await client.dockerGuestDataDiskUsage() + let stopped = try await client.engineStop() + let shareBookmark = Data([0x44, 0x4f, 0x52, 0x59]) + let createdMachine = try await client.machineCreate(DorydMachineConfiguration( + id: "dev", + kernelPath: "/tmp/kernel", + rootfsPath: "/tmp/rootfs", + memoryMB: 2048, + cpuCount: 2, + address: "192.168.215.40", + displayMode: .desktop, + shares: [ + DorydMachineShareConfiguration( + tag: "src", + hostPath: "/Users/me/src", + guestPath: "/workspace/src", + readOnly: true, + authorizationBookmark: shareBookmark + ), + ], + typedSettings: DorydMachineTypedSettings( + guestIdentityIntent: DoryVMGuestIdentityIntent( + account: DoryVMGuestAccountIntent( + username: "developer", + numericUserID: 1_000 + ), + desktop: DoryVMDesktopIdentityIntent( + distributionIdentifier: "ubuntu", + displayName: "Ubuntu", + version: "24.04", + desktopEnvironment: "GNOME" + ) + ), + clipboardPolicy: .legacyDesktop(.bidirectional), + runtimePreference: .accelerated, + graphicsPreference: .virglVenus, + networkMode: .sharedNAT, + portForwards: [ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + ] + ) + )) + let startOperationID = UUID(uuidString: "01234567-89ab-4cde-8f01-23456789abcd")! + let startedMachine = try await client.machineStart( + "dev", + operationID: startOperationID + ) + #expect( + service.latestMachineStartOperationID + == startOperationID.uuidString.lowercased() + ) + let pauseOperationID = UUID(uuidString: "12345678-9abc-4def-8012-3456789abcde")! + let pausedMachine = try await client.machinePause( + "dev", + operationID: pauseOperationID + ) + #expect( + service.latestMachinePauseOperationID + == pauseOperationID.uuidString.lowercased() + ) + let resumeOperationID = UUID(uuidString: "23456789-abcd-4ef0-8123-456789abcdef")! + let resumedMachine = try await client.machineResume( + "dev", + operationID: resumeOperationID + ) + #expect( + service.latestMachineResumeOperationID + == resumeOperationID.uuidString.lowercased() + ) + let restartedMachine = try await client.machineRestart("dev") + let machineStats = try await client.machineStats("dev") + let execResult = try await client.machineExec("dev", argv: ["/bin/sh", "-lc", "cargo --version"]) + let provisionedMachine = try await client.machineProvision("dev", recipe: "rust") + let desktopUpdateOperationID = UUID( + uuidString: "456789ab-cdef-4012-8345-6789abcdef01" + )! + let desktopUpdate = try await client.machineDesktopUpdate( + "dev", + operationID: desktopUpdateOperationID, + distro: "ubuntu", + version: "24.04+runtime.1", + distributionInstallationName: "ubuntu-installation", + runtimeInstallationName: "runtime-installation" + ) + let snapshot = try await client.machineSnapshot( + "dev", + note: "before", + createdISO: "2026-07-07T00:00:00Z", + snapshotID: "s1" + ) + let snapshots = try await client.machineSnapshots(machineID: "dev") + let clonedSnapshot = try await client.machineCloneSnapshot(machineID: "dev", snapshotID: "s1", newID: "dev-copy") + let restoredSnapshot = try await client.machineRestoreSnapshot(machineID: "dev", snapshotID: "s1") + let exportedSnapshot = try await client.machineExportSnapshot(machineID: "dev", snapshotID: "s1", to: "/tmp/dev.dorymachine") + let importedSnapshot = try await client.machineImportSnapshot(from: "/tmp/dev.dorymachine") let savedBackup = try await client.machineBackupSet(DorydMachineBackupSchedule( machineID: "dev", enabled: true, @@ -172,13 +1619,34 @@ struct DorydClientTests { let completedBackup = try await client.machineBackupRun(machineID: "dev") let removedBackup = try await client.machineBackupRemove(machineID: "dev") let deletedSnapshot = try await client.machineDeleteSnapshot(machineID: "dev", snapshotID: "s1") - let stoppedMachine = try await client.machineStop("dev") + let stopOperationID = UUID(uuidString: "3456789a-bcde-4f01-8234-56789abcdef0")! + let stoppedMachine = try await client.machineStop( + "dev", + operationID: stopOperationID + ) + #expect( + service.latestMachineStopOperationID + == stopOperationID.uuidString.lowercased() + ) + let refreshedKernel = try await client.machineRefreshManagedDesktopKernel( + "dev", + sourcePath: "/vm/.assets/dory-desktop-kernel-arm64", + sourceSHA256: String(repeating: "a", count: 64) + ) let updatedMachine = try await client.machineUpdate( "dev", memoryMB: 4096, cpuCount: 4, address: "192.168.215.41", - environment: ["BAR": "baz"] + typedSettings: DorydMachineTypedSettings( + guestIdentityIntent: DoryVMGuestIdentityIntent( + account: DoryVMGuestAccountIntent(username: "builder") + ), + clipboardPolicy: .legacyDesktop(.hostToGuest), + runtimePreference: .compatible, + graphicsPreference: .software, + networkMode: .sharedNAT + ) ) let machines = try await client.machineList() let deletedMachine = try await client.machineDelete("dev") @@ -232,10 +1700,32 @@ struct DorydClientTests { #expect(slept == DorydCommandResult(ok: true, message: "")) #expect(woke == DorydCommandResult(ok: true, message: "")) #expect(dockerAgentInfo.agentBuild == "docker-agent") + #expect(dockerAgentInfo.capabilities.map(\.id) == [ + "clock-sync", "exec", "exec-stdin", "ports-watch", "telemetry", + ]) #expect(dockerAgentPorts.ports == [DorydListenPort(protocol: "tcp", port: 8080)]) #expect(dockerAgentPorts.added == [DorydListenPort(protocol: "tcp", port: 8080)]) #expect(dockerAgentTelemetry.memTotalKB == 2048) + #expect(dockerGuestDataDiskUsage.usedBytes == 8 * 1024 * 1024 * 1024) #expect(stopped == DorydCommandResult(ok: true, message: "")) + #expect( + service.latestMachineDesktopUpdateOperationID + == desktopUpdateOperationID.uuidString.lowercased() + ) + #expect(desktopUpdate.operationID == desktopUpdateOperationID.uuidString.lowercased()) + #expect(refreshedKernel.id == "dev") + #expect( + service.latestManagedDesktopKernelRefreshRequest?["sourcePath"] as? String + == "/vm/.assets/dory-desktop-kernel-arm64" + ) + let createShares = try #require( + service.latestMachineCreateConfig?["shares"] as? [NSDictionary] + ) + #expect(createShares.first?["authorizationBookmark"] as? Data == shareBookmark) + let createForwards = try #require( + service.latestMachineCreateConfig?["portForwards"] as? NSArray + ) + #expect((createForwards.firstObject as? NSDictionary)?["hostPort"] as? Int == 8_080) #expect(createdMachine.state == "created") #expect(createdMachine.displayMode == .desktop) #expect(startedMachine.pid == 1234) @@ -243,11 +1733,19 @@ struct DorydClientTests { #expect(startedMachine.agentSocketPath == "/tmp/agent.sock") #expect(startedMachine.address == "192.168.215.40") #expect(startedMachine.configuredAddress == "192.168.215.40") + #expect(startedMachine.runtimeIdentity == .legacyCompatibility) #expect(startedMachine.shares == [ DorydMachineShareConfiguration(tag: "src", hostPath: "/Users/me/src", guestPath: "/workspace/src", readOnly: true), ]) - #expect(startedMachine.environment == ["FOO": "bar"]) + #expect(startedMachine.environment["DORY_GUEST_USER"] == "developer") + #expect(startedMachine.environment["DORY_DESKTOP_DISTRO"] == "ubuntu") #expect(startedMachine.displayMode == .desktop) + #expect(pausedMachine.state == "paused") + #expect(pausedMachine.pid == startedMachine.pid) + #expect(resumedMachine.state == "running") + #expect(resumedMachine.pid == startedMachine.pid) + #expect(restartedMachine.state == "running") + #expect(restartedMachine.pid == 1235) #expect(execResult.stdout == "cargo 1.0\n") #expect(execResult.exitCode == 0) #expect(machineStats.cpuPercent == 12.5) @@ -258,6 +1756,9 @@ struct DorydClientTests { #expect(provisionedMachine.verify.stdout == "cargo 1.0\n") #expect(snapshot.id == "s1") #expect(snapshot.machineID == "dev") + #expect(snapshot.runtimeIdentity == .legacyCompatibility) + #expect(snapshot.consistency == .coldStopped) + #expect(snapshot.guestQuiesceReceipt == nil) #expect(snapshots.map(\.id).contains("s1")) #expect(clonedSnapshot.id == "dev-copy") #expect(restoredSnapshot.id == "dev") @@ -274,10 +1775,14 @@ struct DorydClientTests { #expect(updatedMachine.memoryMB == 4096) #expect(updatedMachine.cpuCount == 4) #expect(updatedMachine.address == "192.168.215.41") - #expect(updatedMachine.environment == ["BAR": "baz"]) + #expect(updatedMachine.environment["DORY_GUEST_USER"] == "builder") + #expect(updatedMachine.environment["DORY_CLIPBOARD_POLICY"] == "host-to-guest") + #expect(updatedMachine.environment["DORY_DESKTOP_VMM"] == "compatible") + #expect(updatedMachine.environment["DORY_DESKTOP_GRAPHICS"] == "software") #expect(machines.map(\.id) == ["dev", "dev-copy"]) #expect(deletedMachine == DorydCommandResult(ok: true, message: "")) #expect(remoteInfo.agentBuild == "remote-agent") + #expect(remoteInfo.capabilities.map(\.id) == ["exec", "sync-push", "telemetry"]) #expect(pushStats == DorydPushStats(filesSent: 2, bytesSent: 30, filesDeleted: 1)) #expect(remoteStatus.telemetry?.memAvailableKB == 512) #expect(replacedRoutes == DorydCommandResult(ok: true, message: "")) @@ -303,22 +1808,867 @@ struct DorydClientTests { #expect(networkPlan.privilegedTCPForwards == [ DorydPrivilegedTCPForward(listenPort: 25, targetPort: 1025), ]) - #expect(networkPlan.requests.map(\.kind) == ["resolverFile"]) - #expect(repairedNetwork == DorydCommandResult(ok: true, message: "repaired dns")) - #expect(balloonPlan.host.pressure == "warning") - #expect(balloonPlan.applicableTargets.map(\.id) == ["docker"]) - #expect(reconciledBalloonPlan.host.pressure == "warning") - #expect(reconciledBalloonPlan.applicableTargets.map(\.id) == ["docker"]) - #expect(idleStatus.mode == "always-on") - #expect(idleHistory.map(\.state) == ["sleeping"]) - #expect(updatedIdlePolicy.policy?.sleepAfterMinutes == 30) - #expect(updatedIdleMode.mode == "manual") - #expect(health.results.map(\.id) == ["socket.exists", "machine.local"]) - #expect(report.results.map(\.id) == ["socket.exists"]) - #expect(report.results.first?.status == "pass") - #expect(incidents == [ - Incident(at: "2026-07-07T00:00:00Z", type: "engine.start", detail: "started") + #expect(networkPlan.requests.map(\.kind) == ["resolverFile"]) + #expect(repairedNetwork == DorydCommandResult(ok: true, message: "repaired dns")) + #expect(balloonPlan.host.pressure == "warning") + #expect(balloonPlan.applicableTargets.map(\.id) == ["docker"]) + #expect(reconciledBalloonPlan.host.pressure == "warning") + #expect(reconciledBalloonPlan.applicableTargets.map(\.id) == ["docker"]) + #expect(idleStatus.mode == "always-on") + #expect(idleHistory.map(\.state) == ["sleeping"]) + #expect(updatedIdlePolicy.policy?.sleepAfterMinutes == 30) + #expect(updatedIdleMode.mode == "manual") + #expect(health.results.map(\.id) == ["socket.exists", "machine.local"]) + #expect(report.results.map(\.id) == ["socket.exists"]) + #expect(report.results.first?.status == "pass") + #expect(incidents == [ + Incident(at: "2026-07-07T00:00:00Z", type: "engine.start", detail: "started") + ]) + } + + @Test func presentInvalidRuntimeIdentityFailsStatusAndSnapshotClosed() async throws { + let resolvedWithoutComponentsOrMedia = NSMutableDictionary( + dictionary: validResolvedRuntimeIdentity() + ) + resolvedWithoutComponentsOrMedia.removeObject(forKey: "components") + resolvedWithoutComponentsOrMedia.removeObject(forKey: "bootMedia") + let mixedQualification = NSMutableDictionary( + dictionary: validResolvedRuntimeIdentity() + ) + mixedQualification["runtimeQualification"] = [ + "qualificationIdentity": "runtime-qualification-1", + "qualificationReportSHA256": String(repeating: "3", count: 64), + "signingKeyID": "dory-runtime-1", + "manifestIdentity": "wrong-shape-field", + ] + let orphanProvenance = NSMutableDictionary( + dictionary: validResolvedRuntimeIdentity() + ) + orphanProvenance["bootMedia"] = [ + "kind": "installed-linux-boot-bundle", + "source": "user-provided", + "artifactSHA256": String(repeating: "6", count: 64), + "provenanceReceiptIdentity": "orphan-receipt", + ] + let unsupportedGraphics = NSMutableDictionary( + dictionary: validResolvedRuntimeIdentity() + ) + unsupportedGraphics["graphics"] = "automatic" + for identity in [ + [ + "schemaVersion": 2, + "mode": "legacy-compatibility", + "virtualHardwareABIVersion": 1, + ] as NSDictionary, + [ + "schemaVersion": 1, + "mode": "legacy-compatibility", + "virtualHardwareABIVersion": 1, + "components": [[ + "componentIdentifier": "dory-hv", + "buildIdentifier": "runtime-1", + "artifactSHA256": String(repeating: "a", count: 64), + ]], + ] as NSDictionary, + resolvedWithoutComponentsOrMedia, + mixedQualification, + orphanProvenance, + unsupportedGraphics, + ] { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService(runtimeIdentityOverride: identity) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + do { + _ = try await client.machineList() + Issue.record("present invalid status identity must fail closed") + } catch let error as DorydClientError { + #expect(error.description.contains("invalid machine list")) + } + + do { + _ = try await client.machineSnapshot( + "dev", + note: "invalid identity", + createdISO: "2026-07-07T00:00:00Z", + snapshotID: "invalid-identity" + ) + Issue.record("present invalid snapshot identity must fail closed") + } catch let error as DorydClientError { + #expect(error.description.contains("invalid doryd response")) + } + } + } + + @Test func machineRuntimeEvidenceSurfacesPlanGraphicsBackendAndGuestTools() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService(runtimeIdentityOverride: validResolvedRuntimeIdentity()) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let status = try #require( + (try await DorydClient(endpoint: listener.endpoint).machineList()).first + ) + #expect(status.runtimeGraphicsSelection?.isQualifiedAcceleration == true) + #expect(status.runtimeIdentity.authorizesRemovableUSBHotplug) + #expect(UsbPassthroughAvailability.attachSupported(for: status)) + let machine = AppStore.machine(fromDoryd: status) + #expect(machine.runtimeIdentity.graphics == "hardware-accelerated-3d") + #expect(machine.runtimeGraphicsSelection?.backend == "virgl-venus") + #expect(machine.agentProtocolVersion == 1) + #expect(machine.agentCapabilities.map(\.id) == [ + "clock-sync", "exec", "exec-stdin", "ports-watch", "snapshot-quiesce", "sync-push", + "telemetry", + ]) + #expect(machine.runtimeEvidence.map(\.label) == [ + "Supported", "Raw HV", "Qualified 3D", "Tools partially ready", ]) + #expect(machine.runtimeEvidence.first { $0.id == "authority" }?.detail + == "runtime-qualification-1") + + let missingFence = NSMutableDictionary( + dictionary: try #require(service.machineRuntimeGraphicsSelection("dev")) + ) + missingFence.removeObject(forKey: "guestProducerFenceProofSHA256") + service.setMachineRuntimeGraphicsSelection("dev", missingFence) + await #expect(throws: DorydClientError.self) { + _ = try await DorydClient(endpoint: listener.endpoint).machineList() + } + service.setMachineRuntimeGraphicsSelection( + "dev", + try #require(service.defaultRuntimeGraphicsSelection("dev")) + ) + + var replanning = machine + replanning.runtimeIdentity = DorydMachineRuntimeIdentity( + schemaVersion: 1, + mode: "requires-replanning", + virtualHardwareABIVersion: 1, + invalidationReason: "restored-snapshot" + ) + replanning.agentBuild = nil + replanning.agentProtocolVersion = nil + replanning.agentCapabilities = [] + #expect(replanning.runtimeEvidence.map(\.label) == [ + "Needs planning", "Tools unavailable", + ]) + + var legacyHandshake = machine + legacyHandshake.agentProtocolVersion = nil + legacyHandshake.agentCapabilities = [] + #expect(legacyHandshake.runtimeEvidence.last?.label == "Tools unavailable") + + var partialHandshake = machine + partialHandshake.agentCapabilities = [DorydAgentCapability(id: "exec", version: 1)] + #expect(partialHandshake.runtimeEvidence.last?.label == "Tools partially ready") + #expect(partialHandshake.runtimeEvidence.last?.detail.contains("clock-sync") == true) + + var oldQuiesceHandshake = machine + oldQuiesceHandshake.agentCapabilities = machine.agentCapabilities.map { + $0.id == "snapshot-quiesce" + ? DorydAgentCapability(id: $0.id, version: 1) : $0 + } + #expect(oldQuiesceHandshake.runtimeEvidence.last?.label == "Tools partially ready") + #expect(oldQuiesceHandshake.integrationHealthProjection.features.first { + $0.id == .snapshotQuiesce + }?.state == .updateRequired) + + var oldSyncHandshake = machine + oldSyncHandshake.agentCapabilities = machine.agentCapabilities.map { + $0.id == "sync-push" + ? DorydAgentCapability(id: $0.id, version: 1) : $0 + } + #expect(oldSyncHandshake.runtimeEvidence.last?.label == "Tools partially ready") + #expect(oldSyncHandshake.integrationHealthProjection.features.first { + $0.id == .fileTransferPush + }?.state == .updateRequired) + + var incompatibleHandshake = machine + incompatibleHandshake.agentProtocolVersion = 2 + #expect(incompatibleHandshake.runtimeEvidence.last?.label == "Tools incompatible") + } + + @MainActor + @Test func qualificationBootstrapGraphicsReceiptDoesNotInvalidateLegacyMachineList() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let selection: NSDictionary = [ + "schemaVersion": UInt16(1), + "operationID": "01234567-89ab-4cde-8f01-23456789abcd", + "resolvedPlanSHA256": String(repeating: "a", count: 64), + "planRevision": UInt64(1), + "accelerationLevel": "hardware-accelerated-3d", + "backend": "virgl-venus", + "rendererGeneration": UInt64(1), + "rendererWorkerReceiptSHA256": String(repeating: "7", count: 64), + "guestProducerFenceProofSHA256": String(repeating: "8", count: 64), + ] + service.setMachineRuntimeGraphicsSelection("dev", selection) + + let status = try #require( + (try await DorydClient(endpoint: listener.endpoint).machineList()).first + ) + #expect(status.runtimeIdentity.mode == "legacy-compatibility") + #expect(status.runtimeGraphicsSelection?.backend == "virgl-venus") + + let machine = AppStore.machine(fromDoryd: status) + #expect(machine.runtimeEvidence.first { $0.id == "authority" }?.label + == "Compatibility") + #expect(machine.runtimeEvidence.contains { $0.id == "graphics" } == false) + + let malformed = NSMutableDictionary(dictionary: selection) + malformed.removeObject(forKey: "guestProducerFenceProofSHA256") + service.setMachineRuntimeGraphicsSelection("dev", malformed) + await #expect(throws: DorydClientError.self) { + _ = try await DorydClient(endpoint: listener.endpoint).machineList() + } + } + + @Test func portableVZSoftwarePlanIsAcceptedWithoutAccelerationQualification() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService( + runtimeIdentityOverride: validPortableVZRuntimeIdentity() + ) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let status = try #require( + (try await DorydClient(endpoint: listener.endpoint).machineList()).first + ) + #expect(status.runtimeIdentity.backend == "apple-virtualization-framework") + #expect(status.runtimeIdentity.graphics == "software") + #expect(status.runtimeIdentity.runtimeQualification == nil) + #expect(status.runtimeIdentity.hostQualification == nil) + #expect(status.runtimeGraphicsSelection == nil) + + let machine = AppStore.machine(fromDoryd: status) + let graphics = try #require(machine.runtimeEvidence.first { $0.id == "graphics" }) + #expect(graphics.label == "Software graphics") + #expect(graphics.detail == "Plan-bound Virtualization.framework display") + #expect(graphics.tone == .standard) + } + + @Test func machineIntegrationHealthUsesExactPresentShapeAndRejectsContradictions() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService(runtimeIdentityOverride: validResolvedRuntimeIdentity()) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let health = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: true, + runtimeAuthority: .resolvedPlan, + desktopIntegrationsExpected: true, + clipboardTextExpected: true, + clipboardImageExpected: true, + sharedFoldersExpected: true, + qualifiedRuntimeFeatures: [ + .clipboardImage, .clipboardText, .displayResize, .gracefulShutdown, + .sharedFolderDiscovery, .sharedFolderMountStatus, + ], + agentBuild: "agent-test", + agentProtocolVersion: 1, + agentCapabilities: [ + .init(id: "clock-sync", version: 1), + .init(id: "exec", version: 1), + .init(id: "exec-stdin", version: 1), + .init(id: "lifecycle-receipt", version: 1), + .init(id: "ports-watch", version: 1), + .init(id: "snapshot-quiesce", version: 2), + .init(id: "sync-push", version: 2), + .init(id: "telemetry", version: 1), + ] + ) + let validDictionary = try integrationHealthDictionary(health) + service.setMachineIntegrationHealth("dev", validDictionary) + + let status = try #require((try await client.machineList()).first) + #expect(status.integrationHealth == health) + let machine = AppStore.machine(fromDoryd: status) + #expect(machine.integrationHealthProjection == health) + #expect(machine.runtimeEvidence.last?.label == "Tools ready") + + let inactive = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: false, + runtimeAuthority: .resolvedPlan, + desktopIntegrationsExpected: true, + clipboardTextExpected: true, + clipboardImageExpected: true, + sharedFoldersExpected: true, + qualifiedRuntimeFeatures: [], + agentBuild: "stale-agent-build", + agentProtocolVersion: 1, + agentCapabilities: [] + ) + service.setMachineState("dev", "paused") + service.setMachineIntegrationHealth( + "dev", + try integrationHealthDictionary(inactive) + ) + let paused = try #require((try await client.machineList()).first) + #expect(paused.integrationHealth?.state == .inactive) + + service.setMachineState("dev", "running") + + let malformed = NSMutableDictionary(dictionary: validDictionary) + malformed["unexpected"] = true + service.setMachineIntegrationHealth("dev", malformed) + do { + _ = try await client.machineList() + Issue.record("present integration health with unknown fields must fail closed") + } catch let error as DorydClientError { + #expect(error.description.contains("invalid machine list")) + } + + let truncated = NSMutableDictionary(dictionary: validDictionary) + let features = try #require(validDictionary["features"] as? [NSDictionary]) + truncated["features"] = Array(features.dropLast()) + service.setMachineIntegrationHealth("dev", truncated) + do { + _ = try await client.machineList() + Issue.record("truncated integration health must fail closed") + } catch let error as DorydClientError { + #expect(error.description.contains("invalid machine list")) + } + + let contradictory = NSMutableDictionary(dictionary: validDictionary) + contradictory["runtimeAuthority"] = "legacy-compatibility" + service.setMachineIntegrationHealth("dev", contradictory) + do { + _ = try await client.machineList() + Issue.record("integration health contradicting runtime authority must fail closed") + } catch let error as DorydClientError { + #expect(error.description.contains("invalid machine list")) + } + } + + @MainActor + @Test func machineListAcceptsRunningEFIInstallerWithoutGuestTools() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let health = DoryGuestIntegrationHealth.evaluate( + machineIsRunning: true, + runtimeAuthority: .legacyCompatibility, + desktopIntegrationsExpected: true, + clipboardTextExpected: true, + clipboardImageExpected: true, + sharedFoldersExpected: false, + qualifiedRuntimeFeatures: [], + agentBuild: "dory-vmm/efi", + agentProtocolVersion: nil, + agentCapabilities: [] + ) + #expect(health.state == .missingTools) + #expect(health.isValid) + service.setMachineEFIRuntime( + "dev", + integrationHealth: try integrationHealthDictionary(health) + ) + + let status = try #require( + (try await DorydClient(endpoint: listener.endpoint).machineList()).first + ) + #expect(status.bootMode == .efi) + #expect(status.installerMediaAttached) + #expect(status.agentBuild == "dory-vmm/efi") + #expect(status.agentProtocolVersion == nil) + #expect(status.integrationHealth?.state == .missingTools) + + let machine = AppStore.machine(fromDoryd: status) + #expect(machine.distro == "Custom Linux") + #expect(machine.displayMode == .desktop) + } + + @Test func machineCapabilityHandshakeRejectsMalformedPresentClaims() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let valid = try #require((try await client.machineList()).first) + #expect(valid.agentProtocolVersion == 1) + #expect(valid.agentCapabilities.count == 7) + + let malformed: [(Any?, Any?)] = [ + (nil, [["id": "exec", "version": 1] as NSDictionary]), + (true, nil), + (1, [["id": "exec", "version": 1, "unknown": "claim"] as NSDictionary]), + (1, [ + ["id": "exec", "version": 1] as NSDictionary, + ["id": "exec", "version": 1] as NSDictionary, + ]), + ] + for (protocolVersion, capabilities) in malformed { + service.setMachineAgentHandshake( + "dev", + protocolVersion: protocolVersion, + capabilities: capabilities + ) + do { + _ = try await client.machineList() + Issue.record("present malformed capability handshake must fail closed") + } catch let error as DorydClientError { + #expect(error.description.contains("invalid machine list")) + } + } + } + + @Test func machineSharesUseAbsentOnlyCompatibilityAndRejectMalformedClaims() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + let valid = try #require((try await client.machineList()).first) + #expect(valid.shares.map(\.tag) == ["src"]) + + service.setMachineShares("dev", nil) + #expect(try await client.machineList().first?.shares == []) + + let base: [String: Any] = [ + "tag": "src", + "hostPath": "/Users/me/src", + "guestPath": "/workspace/src", + "readOnly": true, + ] + let malformed: [Any] = [ + true, + [["tag": "src", "guestPath": "/workspace/src", "readOnly": true]], + [base.merging(["unexpected": true]) { _, new in new }], + [base.merging(["readOnly": "true"]) { _, new in new }], + [base.merging(["mode": "rw"]) { _, new in new }], + [base, base], + ] + for claim in malformed { + service.setMachineShares("dev", claim) + await #expect(throws: DorydClientError.self) { + _ = try await client.machineList() + } + } + } + + @Test func snapshotArtifactEvidenceIsAbsentOnlyForLegacyAndOtherwiseExact() async throws { + let malformedEvidence = [ + "schemaVersion": 1, + "rootfs": [ + "byteCount": 0, + "sha256": String(repeating: "a", count: 64), + ], + "kernel": [ + "byteCount": 1, + "sha256": String(repeating: "b", count: 64), + ], + ] as NSDictionary + for fixture in [ + (runtime: [ + "schemaVersion": 1, + "mode": "legacy-compatibility", + "virtualHardwareABIVersion": 1, + ] as NSDictionary, + artifacts: malformedEvidence), + (runtime: validResolvedRuntimeIdentity(), artifacts: nil), + ] as [(runtime: NSDictionary, artifacts: NSDictionary?)] { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService( + runtimeIdentityOverride: fixture.runtime, + artifactEvidenceOverride: fixture.artifacts + ) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + + do { + _ = try await client.machineSnapshot( + "dev", + note: "invalid artifacts", + createdISO: "2026-07-07T00:00:00Z", + snapshotID: "invalid-artifacts" + ) + Issue.record("invalid or missing non-legacy artifact evidence must fail closed") + } catch let error as DorydClientError { + #expect(error.description.contains("invalid doryd response")) + } + } + } + + @Test func snapshotConsistencyDefaultsOnlyWhenAbsentAndRejectsMalformedClaims() async throws { + let receipt = [ + "schemaVersion": 1, + "receiptID": String(repeating: "a", count: 32), + "agentBuild": "dory-agent/test", + "agentProtocolVersion": 1, + "capabilityVersion": 2, + ] as NSDictionary + let validService = FakeDorydService( + snapshotConsistencyOverride: "guest-quiesced", + snapshotQuiesceReceiptOverride: receipt + ) + let validListener = NSXPCListener.anonymous() + let validDelegate = FakeDorydListenerDelegate(service: validService) + validListener.delegate = validDelegate + validListener.resume() + defer { validListener.invalidate() } + let valid = try await DorydClient(endpoint: validListener.endpoint).machineSnapshot( + "dev", + note: "consistent", + createdISO: "2026-07-07T00:00:00Z", + snapshotID: "consistent" + ) + #expect(valid.consistency == .guestQuiesced) + #expect(valid.guestQuiesceReceipt?.agentBuild == "dory-agent/test") + + for fixture in [ + (consistency: 1 as Any, receipt: nil as NSDictionary?), + (consistency: "guest-quiesced" as Any, receipt: nil as NSDictionary?), + (consistency: "cold-stopped" as Any, receipt: receipt), + (consistency: "guest-quiesced" as Any, receipt: [ + "schemaVersion": "1", + "receiptID": String(repeating: "a", count: 32), + "agentBuild": "dory-agent/test", + "agentProtocolVersion": 1, + "capabilityVersion": 2, + ] as NSDictionary), + (consistency: "guest-quiesced" as Any, receipt: [ + "schemaVersion": 1, + "receiptID": String(repeating: "a", count: 32), + "agentBuild": "dory-agent/test", + "agentProtocolVersion": 1, + "capabilityVersion": 2, + "unknown": true, + ] as NSDictionary), + ] { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService( + snapshotConsistencyOverride: fixture.consistency, + snapshotQuiesceReceiptOverride: fixture.receipt + ) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + let client = DorydClient(endpoint: listener.endpoint) + do { + _ = try await client.machineSnapshot( + "dev", + note: "invalid consistency", + createdISO: "2026-07-07T00:00:00Z", + snapshotID: "invalid-consistency" + ) + Issue.record("present malformed snapshot consistency must fail closed") + } catch let error as DorydClientError { + #expect(error.description.contains("invalid doryd response")) + } + listener.invalidate() + } + } + + @Test func installedDesktopPayloadReceiptUsesAbsentOnlyLegacyCompatibilityAndRejectsMalformedClaims() async throws { + let digest = String(repeating: "a", count: 64) + let valid: [String: Any] = [ + "schemaVersion": 1, + "provenance": "verified-update-bundle", + "distributionIdentifier": "ubuntu", + "releaseVersion": "24.04+runtime.7", + "inputSHA256": digest, + "bundleSHA256": String(repeating: "b", count: 64), + "distributionComponentIdentifier": "desktop-ubuntu", + "distributionInstallationName": "ubuntu-installation", + "distributionCatalogSHA256": String(repeating: "c", count: 64), + "bundleAssetIdentifier": "dory-desktop-ubuntu-update-arm64.tar", + "runtimeComponentIdentifier": "linux-desktop", + "runtimeInstallationName": "runtime-installation", + "runtimeCatalogSHA256": String(repeating: "d", count: 64), + "kernelAssetIdentifier": "dory-desktop-kernel-arm64.lzfse", + "kernelSHA256": String(repeating: "e", count: 64), + ] + do { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService( + installedDesktopPayloadReceiptOverride: valid as NSDictionary + ) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + let status = try #require(try await client.machineList().first) + #expect(status.installedDesktopPayloadReceipt?.releaseVersion == "24.04+runtime.7") + #expect(status.installedDesktopPayloadReceipt?.bundleSHA256 == String(repeating: "b", count: 64)) + let snapshot = try await client.machineSnapshot( + "dev", + note: "receipt", + createdISO: "2026-07-07T00:00:00Z", + snapshotID: "receipt" + ) + #expect(snapshot.installedDesktopPayloadReceipt == status.installedDesktopPayloadReceipt) + } + + do { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + service.setMachineEnvironment("dev", [ + "DORY_DESKTOP_DISTRO": "ubuntu", + "DORY_DESKTOP_RELEASE_VERSION": "24.04+runtime.6", + "DORY_DESKTOP_INPUT_SHA256": digest, + ]) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let status = try #require( + try await DorydClient(endpoint: listener.endpoint).machineList().first + ) + #expect(status.installedDesktopPayloadReceipt?.provenance == "legacy-environment") + #expect(status.installedDesktopPayloadReceipt?.releaseVersion == "24.04+runtime.6") + #expect(status.installedDesktopPayloadReceipt?.bundleSHA256 == nil) + } + + for malformed in [ + [ + "schemaVersion": 2, + "provenance": "verified-update-bundle", + "distributionIdentifier": "ubuntu", + "releaseVersion": "24.04+runtime.7", + "inputSHA256": digest, + "bundleSHA256": String(repeating: "b", count: 64), + ], + [ + "schemaVersion": 1, + "provenance": "verified-update-bundle", + "distributionIdentifier": "ubuntu", + "releaseVersion": "24.04+runtime.7", + "inputSHA256": digest, + ], + valid.merging(["unknownEvidence": "must-reject"]) { _, new in new }, + valid.merging(["schemaVersion": "1"]) { _, new in new }, + valid.merging(["bundleSHA256": NSNumber(value: 7)]) { _, new in new }, + valid.merging(["distributionInstallationName": "../../outside-store"]) { _, new in new }, + ] as [[String: Any]] { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService( + installedDesktopPayloadReceiptOverride: malformed as NSDictionary + ) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + await #expect(throws: DorydClientError.self) { + _ = try await client.machineList() + } + await #expect(throws: DorydClientError.self) { + _ = try await client.machineSnapshot( + "dev", + note: "invalid receipt", + createdISO: "2026-07-07T00:00:00Z", + snapshotID: "invalid-receipt" + ) + } + } + } + + @Test func machineCloneReceiptRequiresExactShape() async throws { + let valid: NSDictionary = [ + "schemaVersion": UInt16(1), + "sourceMachineID": "source", + "sourceSnapshotID": "base", + "sourceRootfsSHA256": String(repeating: "a", count: 64), + "sourceRootfsByteCount": UInt64(4_096), + "storageMode": "apfs-copy-on-write", + "createdAtUnixMilliseconds": Int64(1_787_300_000_000), + ] + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + service.setMachineCloneReceipt("dev", valid) + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = DorydClient(endpoint: listener.endpoint) + let status = try #require(try await client.machineList().first) + #expect(status.cloneReceipt?.sourceMachineID == "source") + #expect(status.cloneReceipt?.sourceSnapshotID == "base") + #expect(status.cloneReceipt?.storageMode == "apfs-copy-on-write") + + let malformed = NSMutableDictionary(dictionary: valid) + malformed["unknown"] = true + service.setMachineCloneReceipt("dev", malformed) + await #expect(throws: DorydClientError.self) { + _ = try await client.machineList() + } + + let wrongType = NSMutableDictionary(dictionary: valid) + wrongType["sourceRootfsByteCount"] = true + service.setMachineCloneReceipt("dev", wrongType) + await #expect(throws: DorydClientError.self) { + _ = try await client.machineList() + } + } + + @Test func desktopUpdateSkipRequiresExactVerifiedActiveComponentProvenance() { + let receipt = DorydInstalledDesktopPayloadReceipt( + schemaVersion: 1, + provenance: "verified-update-bundle", + distributionIdentifier: "ubuntu", + releaseVersion: "24.04+runtime.7", + inputSHA256: String(repeating: "a", count: 64), + bundleSHA256: String(repeating: "b", count: 64), + distributionComponentIdentifier: "desktop-ubuntu", + distributionInstallationName: "ubuntu-installation", + distributionCatalogSHA256: String(repeating: "c", count: 64), + bundleAssetIdentifier: "dory-desktop-ubuntu-update-arm64.tar", + runtimeComponentIdentifier: "linux-desktop", + runtimeInstallationName: "runtime-installation", + runtimeCatalogSHA256: String(repeating: "d", count: 64), + kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse", + kernelSHA256: String(repeating: "e", count: 64) + ) + let matches: (DorydInstalledDesktopPayloadReceipt?) -> Bool = { candidate in + AppStore.desktopReceiptMatchesActiveComponents( + candidate, + distributionIdentifier: "ubuntu", + releaseVersion: "24.04+runtime.7", + distributionComponentIdentifier: "desktop-ubuntu", + distributionInstallationName: "ubuntu-installation", + distributionCatalogSHA256: String(repeating: "c", count: 64), + bundleAssetIdentifier: "dory-desktop-ubuntu-update-arm64.tar", + bundleSHA256: String(repeating: "b", count: 64), + runtimeInstallationName: "runtime-installation", + runtimeCatalogSHA256: String(repeating: "d", count: 64), + kernelAssetIdentifier: "dory-desktop-kernel-arm64.lzfse", + kernelSHA256: String(repeating: "e", count: 64) + ) + } + #expect(matches(receipt)) + var wrongDistro = receipt + wrongDistro.distributionIdentifier = "kali" + #expect(!matches(wrongDistro)) + var staleBundle = receipt + staleBundle.bundleSHA256 = String(repeating: "f", count: 64) + #expect(!matches(staleBundle)) + var legacy = receipt + legacy.provenance = "legacy-environment" + #expect(!matches(legacy)) + #expect( + AppStore.managedDesktopDistributionIdentifier( + configuredIdentifier: nil, + receipt: receipt + ) == "ubuntu" + ) + #expect( + AppStore.managedDesktopDistributionIdentifier( + configuredIdentifier: "kali", + receipt: receipt + ) == "kali" + ) + } + + private func validResolvedRuntimeIdentity() -> NSDictionary { + [ + "schemaVersion": 1, + "mode": "resolved-plan", + "virtualHardwareABIVersion": 1, + "definitionRevision": UInt64(1), + "definitionSHA256": String(repeating: "1", count: 64), + "planRevision": UInt64(1), + "planSHA256": String(repeating: "2", count: 64), + "backend": "dory-hypervisor", + "backendImplementationIdentifier": "dev.dory.raw-hv-linux", + "backendRuntimeBuildIdentifier": "runtime-1", + "supportTier": "supported", + "graphics": "hardware-accelerated-3d", + "removableUSBHotplug": true, + "selectionDisposition": "primary", + "runtimeQualification": [ + "qualificationIdentity": "runtime-qualification-1", + "qualificationReportSHA256": String(repeating: "3", count: 64), + "signingKeyID": "dory-runtime-1", + ], + "hostQualification": [ + "qualificationIdentity": "host-qualification-1", + "qualificationReportSHA256": String(repeating: "4", count: 64), + "qualifierIdentifier": "dory-host-qualifier", + ], + "components": [[ + "componentIdentifier": "dory-hv", + "buildIdentifier": "runtime-1", + "artifactSHA256": String(repeating: "5", count: 64), + ]], + "bootMedia": [ + "kind": "installed-linux-boot-bundle", + "source": "user-provided", + "artifactSHA256": String(repeating: "6", count: 64), + ], + ] as NSDictionary + } + + private func validPortableVZRuntimeIdentity() -> NSDictionary { + [ + "schemaVersion": 1, + "mode": "resolved-plan", + "virtualHardwareABIVersion": 1, + "definitionRevision": UInt64(1), + "definitionSHA256": String(repeating: "1", count: 64), + "planRevision": UInt64(1), + "planSHA256": String(repeating: "2", count: 64), + "backend": "apple-virtualization-framework", + "backendImplementationIdentifier": "dory.vz-linux.compatibility.v1", + "backendRuntimeBuildIdentifier": "sha256:" + String(repeating: "3", count: 64), + "supportTier": "supported", + "graphics": "software", + "removableUSBHotplug": false, + "selectionDisposition": "primary", + "components": [[ + "componentIdentifier": "dory-vmm", + "buildIdentifier": "sha256:" + String(repeating: "3", count: 64), + "artifactSHA256": String(repeating: "3", count: 64), + ]], + "bootMedia": [ + "kind": "installer-iso", + "source": "user-provided", + "artifactSHA256": String(repeating: "4", count: 64), + "resolverNamespace": "legacy-artifact", + "resolverIdentifier": "portable-installer", + "inspectionIdentity": "dory-iso-inspector:portable", + "inspectionReportSHA256": String(repeating: "5", count: 64), + ], + ] as NSDictionary + } + + private func integrationHealthDictionary( + _ health: DoryGuestIntegrationHealth + ) throws -> NSDictionary { + let data = try JSONEncoder().encode(health) + return try #require( + JSONSerialization.jsonObject(with: data, options: []) as? NSDictionary + ) } @MainActor @@ -393,11 +2743,112 @@ struct DorydClientTests { } @MainActor - @Test func appStoreKeepsDoryPreferenceOnDorydStartFailure() async throws { + @Test func appStoreKeepsDoryPreferenceOnDorydStartFailure() async throws { + let listener = NSXPCListener.anonymous() + let service = FakeDorydService() + service.setEngineStatus("stopped", detail: "stopped") + service.setEngineStartResult(ok: false, message: "doryd test failure") + let delegate = FakeDorydListenerDelegate(service: service) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let store = AppStore( + dorydClient: DorydClient(endpoint: listener.endpoint), + environment: [:] + ) + store.routeDockerCLI = false + store.enginePreference = .dory + + await store.connectBackend() + + #expect(service.engineStartCount == 1) + #expect(store.loadState == .engineOff) + #expect(store.sharedVMStatus == "doryd test failure") + #expect(store.runtimeKind == .disconnected) + #expect(!store.shimRunning) + } + + @MainActor + @Test func appStoreRecoversAnActiveDaemonFileTransferAfterReconnect() async throws { + let base = "/tmp/dory-transfer-recovery-\(getpid())-\(UInt32.random(in: 0.. (String, String)? in - guard let key = entry["key"] as? String, - let value = entry["value"] as? String else { return nil } - return (key, value) - } - ) - #expect(createEnvValues["APP_ENV"] == "dev") - #expect(createEnvValues["DORY_DESKTOP_DISTRO"] == "ubuntu") + #expect(config["env"] == nil) + let identity = try #require(config["guestIdentityIntent"] as? NSDictionary) + let desktop = try #require(identity["desktop"] as? NSDictionary) + #expect(desktop["distributionIdentifier"] as? String == "ubuntu") try await waitUntil { store.machines.first { $0.name == "vmdev" }?.status == .running @@ -761,6 +3465,127 @@ struct DorydClientTests { #expect(store.machineCreationLog.contains("cargo 1.0")) } + @MainActor + @Test func appStoreEditsTypedLeavesWithoutRewritingLegacyEnvironment() async throws { + let base = "/tmp/dory-typed-edit-\(getpid())-\(UInt32.random(in: 0.. (String, String)? in - guard let key = row["key"] as? String, let value = row["value"] as? String else { return nil } - return (key, value) - }) - #expect(env["ANTHROPIC_API_KEY"] == "sk-ant-host") - #expect(env["GH_TOKEN"] == "gh-explicit") - #expect(env["EMPTY_TOKEN"] == nil) + #expect(config["env"] == nil) + #expect(config["guestIdentityIntent"] == nil) } @MainActor - @Test func dorydMachineConfigurationRequiresKernelAndRootfsAndUsesSettingsDefaults() { + @Test func dorydMachineConfigurationRequiresKernelAndRootfsAndUsesSettingsDefaults() throws { #expect(AppStore.dorydMachineConfiguration( name: "vmdev", settings: .default, @@ -889,7 +3710,7 @@ struct DorydClientTests { rootfsPath: "/vm/rootfs.raw", memoryMB: 3072, cpuCount: 3, - environment: ["APP_ENV": "dev"] + typedSettings: DorydMachineTypedSettings(networkMode: .sharedNAT) )) let invalidResources = AppStore.dorydMachineConfiguration( @@ -902,38 +3723,146 @@ struct DorydClientTests { "DORYD_MACHINE_CPUS": "0", ] ) - #expect(invalidResources?.memoryMB == 0) - #expect(invalidResources?.cpuCount == 0) + #expect(invalidResources?.memoryMB == 0) + #expect(invalidResources?.cpuCount == 0) + + let malformedResources = AppStore.dorydMachineConfiguration( + name: "vmdev", + settings: .default, + environment: [ + "DORYD_GUEST_KERNEL": "/vm/Image", + "DORYD_GUEST_ROOTFS": "/vm/rootfs.raw", + "DORYD_MACHINE_MEMORY_MB": "invalid", + "DORYD_MACHINE_CPUS": "invalid", + ] + ) + #expect(malformedResources?.memoryMB == 0) + #expect(malformedResources?.cpuCount == 0) + + let customEFI = AppStore.dorydMachineConfiguration( + name: "omarchy", + settings: MachineSettings( + cpus: 4, + memoryMB: 4_096, + env: ["DORY_CUSTOM_LINUX": "1", "TOKEN": "must-not-cross"], + displayMode: .desktop, + bootMode: .efi, + installerISOPath: "/staged/omarchy.iso", + diskSizeGB: 64 + ), + environment: [:] + ) + let custom = try #require(customEFI) + #expect(custom.bootMode == .efi) + #expect(custom.installerISOPath == "/staged/omarchy.iso") + #expect(custom.typedSettings.isEmpty) + #expect(custom.xpcDictionary["env"] == nil) + #expect(custom.xpcDictionary["guestIdentityIntent"] == nil) + } + + @MainActor + @Test func dorydRecipeMappingCoversBuiltInRecipesAndRejectsCustomRecipes() { + #expect(AppStore.dorydRecipeID(for: DevRecipe.forID("node")!) == "node") + #expect(AppStore.dorydRecipeID(for: DevRecipe.forID("python")!) == "python-ml") + #expect(AppStore.dorydRecipeID(for: DevRecipe.forID("go")!) == "go") + #expect(AppStore.dorydRecipeID(for: DevRecipe.forID("java")!) == "java") + #expect(AppStore.dorydRecipeID(for: DevRecipe.forID("ruby")!) == "ruby") + #expect(AppStore.dorydRecipeID(for: DevRecipe.forID("rust")!) == "rust") + #expect(AppStore.dorydRecipeID(for: DevRecipe.forID("devops")!) == "devops") + #expect(AppStore.dorydRecipeID(for: DevRecipe(id: "custom-abc", display: "Custom", icon: "wrench", install: "true")) == nil) + } + + @MainActor + @Test func appStoreAutoRefreshDoesNotWakeDorydIdleSleep() async throws { + let base = "/tmp/daslp-\(getpid())-\(UInt32.random(in: 0.. NSDictionary { + Self.transferOperationRow(operationID: operationID, phase: phase) + } + + var latestMachineGuestExportRequest: NSDictionary? { + lock.lock(); defer { lock.unlock() } + return _latestMachineGuestExportRequest + } + + var machineGuestExportCancelCount: Int { + lock.lock(); defer { lock.unlock() } + return _machineGuestExportCancelCount + } + + var machineGuestExportDiscardCount: Int { + lock.lock(); defer { lock.unlock() } + return _machineGuestExportDiscardCount + } + + func setMachineGuestExportStartResponse(_ response: NSDictionary?) { + lock.lock(); defer { lock.unlock() } + _machineGuestExportStartResponseOverride = response + } + + func setMachineGuestExportOperationResponse(_ response: NSDictionary?) { + lock.lock(); defer { lock.unlock() } + _machineGuestExportOperationResponseOverride = response + } + + func setMachineGuestExportCurrentResponse(_ response: NSDictionary?) { + lock.lock(); defer { lock.unlock() } + _machineGuestExportCurrentResponseOverride = response + } + + func setMachineImportAssessment(_ response: NSDictionary?) { + lock.lock(); defer { lock.unlock() } + _machineImportAssessmentOverride = response + } + + func setMachineEventBatch(_ response: NSDictionary?) { + lock.lock(); defer { lock.unlock() } + _machineEventBatchOverride = response + } + + var machineEventQueryCount: Int { + lock.lock(); defer { lock.unlock() } + return _machineEventQueryCount + } + + var machineListCount: Int { + lock.lock(); defer { lock.unlock() } + return _machineListCount + } + + func machineGuestExportOperationResponse( + operationID: String, + phase: String + ) -> NSDictionary { + Self.guestExportOperationRow(operationID: operationID, phase: phase) + } + var engineStopCount: Int { + lock.lock(); defer { lock.unlock() } + return _engineStopCount + } + var engineWakeCount: Int { + lock.lock(); defer { lock.unlock() } + return _engineWakeCount + } + var engineSleepCount: Int { + lock.lock(); defer { lock.unlock() } + return _engineSleepCount + } + var engineDashboardSnapshotCount: Int { + lock.lock(); defer { lock.unlock() } + return _engineDashboardSnapshotCount + } + var machineStartCount: Int { + lock.lock(); defer { lock.unlock() } + return _machineStartCount + } + + var latestMachineStartOperationID: String? { + lock.lock(); defer { lock.unlock() } + return _latestMachineStartOperationID + } + + var latestMachineStopOperationID: String? { + lock.lock(); defer { lock.unlock() } + return _latestMachineStopOperationID + } + + var latestMachinePauseOperationID: String? { + lock.lock(); defer { lock.unlock() } + return _latestMachinePauseOperationID + } + + var latestMachineResumeOperationID: String? { + lock.lock(); defer { lock.unlock() } + return _latestMachineResumeOperationID + } + + var latestMachineDesktopUpdateOperationID: String? { + lock.lock(); defer { lock.unlock() } + return _latestMachineDesktopUpdateOperationID + } + + func setMachineDesktopUpdateOperationIDResponse(_ value: Any) { + lock.lock() + _machineDesktopUpdateOperationIDResponseOverride = value + lock.unlock() + } + + func setMachineEnvironment(_ machineID: String, _ environment: [String: String]) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID] else { return } + machines[machineID] = Self.machineRow( + id: machineID, + state: current["state"] as? String ?? "stopped", + pid: (current["pid"] as? NSNumber)?.int32Value, + agentBuild: current["agentBuild"] as? String, + handoffFDCount: (current["handoffFDCount"] as? NSNumber)?.intValue ?? 0, + memoryMB: Self.uint64(current["memoryMB"]) ?? 2_048, + cpuCount: Self.int(current["cpuCount"]) ?? 2, + address: current["address"] as? String, + displayMode: current["displayMode"] as? String ?? "headless", + shares: Self.shareRows(current["shares"]), + environment: environment.sorted { $0.key < $1.key }.map { + ["key": $0.key, "value": $0.value] as NSDictionary + } + ) + } + + func setMachineTypedSettings(_ machineID: String, _ typedSettings: NSDictionary) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + current["typedSettings"] = typedSettings + current.removeObject(forKey: "env") + current["displayMode"] = "desktop" + machines[machineID] = current.copy() as? NSDictionary + } + + func setMachineShares(_ machineID: String, _ shares: Any?) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + if let shares { + current["shares"] = shares + } else { + current.removeObject(forKey: "shares") + } + machines[machineID] = current.copy() as? NSDictionary + } + + func setMachineAgentHandshake( + _ machineID: String, + protocolVersion: Any?, + capabilities: Any? + ) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + current.removeObject(forKey: "agentProtocolVersion") + current.removeObject(forKey: "agentCapabilities") + if let protocolVersion { + current["agentProtocolVersion"] = protocolVersion + } + if let capabilities { + current["agentCapabilities"] = capabilities + } + machines[machineID] = current + } + + func setMachineIntegrationHealth(_ machineID: String, _ health: Any?) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + if let health { + current["integrationHealth"] = health + } else { + current.removeObject(forKey: "integrationHealth") + } + machines[machineID] = current.copy() as? NSDictionary + } + + func setMachineEFIRuntime( + _ machineID: String, + integrationHealth: NSDictionary + ) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + current["bootMode"] = "efi" + current["installerMediaAttached"] = true + current["displayMode"] = "desktop" + current["shares"] = [] + current["agentBuild"] = "dory-vmm/efi" + current.removeObject(forKey: "agentProtocolVersion") + current.removeObject(forKey: "agentCapabilities") + current.removeObject(forKey: "agentSocketPath") + current.removeObject(forKey: "dockerdSocketPath") + current.removeObject(forKey: "shellSocketPath") + current["integrationHealth"] = integrationHealth + machines[machineID] = current.copy() as? NSDictionary + } + + func setMachineState(_ machineID: String, _ state: String) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + current["state"] = state + machines[machineID] = current.copy() as? NSDictionary + } + + func setMachineSavedState(_ machineID: String, _ savedState: Any?) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + if let savedState { + current["savedState"] = savedState + } else { + current.removeObject(forKey: "savedState") + } + machines[machineID] = current.copy() as? NSDictionary + } + + func setMachineCloneReceipt(_ machineID: String, _ receipt: Any?) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + if let receipt { + current["cloneReceipt"] = receipt + } else { + current.removeObject(forKey: "cloneReceipt") + } + machines[machineID] = current.copy() as? NSDictionary + } + + func setMachineFailure( + _ machineID: String, + _ failure: Any?, + activeOperation: Any? = nil + ) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + if let failure { + current["failure"] = failure + } else { + current.removeObject(forKey: "failure") + } + if let activeOperation { + current["activeOperation"] = activeOperation + } else { + current.removeObject(forKey: "activeOperation") + } + machines[machineID] = current.copy() as? NSDictionary + } + + func setMachineFlightRecorderSummary(_ machineID: String, _ summary: Any?) { + lock.lock() + defer { lock.unlock() } + guard let current = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + if let summary { + current["flightRecorder"] = summary + } else { + current.removeObject(forKey: "flightRecorder") + } + machines[machineID] = current.copy() as? NSDictionary + } + + var machineStopCount: Int { lock.lock(); defer { lock.unlock() } - return _engineStopCount + return _machineStopCount } - var engineWakeCount: Int { + var machinePauseCount: Int { lock.lock(); defer { lock.unlock() } - return _engineWakeCount + return _machinePauseCount } - var engineSleepCount: Int { + var machineSuspendCount: Int { lock.lock(); defer { lock.unlock() } - return _engineSleepCount + return _machineSuspendCount } - var machineStartCount: Int { + var machineResumeCount: Int { lock.lock(); defer { lock.unlock() } - return _machineStartCount + return _machineResumeCount } - var machineStopCount: Int { + var machineRestartCount: Int { lock.lock(); defer { lock.unlock() } - return _machineStopCount + return _machineRestartCount } var machineDeleteCount: Int { lock.lock(); defer { lock.unlock() } @@ -1856,6 +5219,10 @@ private final class FakeDorydService: NSObject, DorydControlXPC { lock.lock(); defer { lock.unlock() } return _latestMachineUpdateConfig } + var latestManagedDesktopKernelRefreshRequest: NSDictionary? { + lock.lock(); defer { lock.unlock() } + return _latestManagedDesktopKernelRefreshRequest + } var latestMachineProvisionRecipe: String? { lock.lock(); defer { lock.unlock() } return _latestMachineProvisionRecipe @@ -1871,10 +5238,71 @@ private final class FakeDorydService: NSObject, DorydControlXPC { init( socketPath: String = "/tmp/doryd-test.sock", - engineShutdownReplyDelay: TimeInterval = 0 + engineShutdownReplyDelay: TimeInterval = 0, + machineShutdownReplyDelay: TimeInterval = 0, + dockerGuestDataDiskUsageReplyDelay: TimeInterval = 0, + runtimeIdentityOverride: NSDictionary? = nil, + artifactEvidenceOverride: NSDictionary? = nil, + installedDesktopPayloadReceiptOverride: NSDictionary? = nil, + snapshotConsistencyOverride: Any? = nil, + snapshotQuiesceReceiptOverride: NSDictionary? = nil ) { self.socketPath = socketPath self.engineShutdownReplyDelay = engineShutdownReplyDelay + self.machineShutdownReplyDelay = machineShutdownReplyDelay + self.dockerGuestDataDiskUsageReplyDelay = dockerGuestDataDiskUsageReplyDelay + self._dockerGuestDataDiskUsage = Self.dockerGuestDataDiskUsageRow( + engineSocketPath: socketPath + ) + self.runtimeIdentityOverride = runtimeIdentityOverride + self.artifactEvidenceOverride = artifactEvidenceOverride + self.installedDesktopPayloadReceiptOverride = installedDesktopPayloadReceiptOverride + self.snapshotConsistencyOverride = snapshotConsistencyOverride + self.snapshotQuiesceReceiptOverride = snapshotQuiesceReceiptOverride + if let existing = machines["dev"]?.mutableCopy() as? NSMutableDictionary { + if let runtimeIdentityOverride { + existing["runtimeIdentity"] = runtimeIdentityOverride + if let graphicsSelection = Self.runtimeGraphicsSelection( + for: runtimeIdentityOverride + ) { + existing["runtimeGraphicsSelection"] = graphicsSelection + } + } + if let installedDesktopPayloadReceiptOverride { + existing["installedDesktopPayloadReceipt"] = + installedDesktopPayloadReceiptOverride + } + machines["dev"] = existing.copy() as? NSDictionary + } + } + + func machineRuntimeGraphicsSelection(_ machineID: String) -> NSDictionary? { + lock.lock(); defer { lock.unlock() } + return machines[machineID]?["runtimeGraphicsSelection"] as? NSDictionary + } + + func defaultRuntimeGraphicsSelection(_ machineID: String) -> NSDictionary? { + lock.lock(); defer { lock.unlock() } + guard let identity = machines[machineID]?["runtimeIdentity"] as? NSDictionary else { + return nil + } + return Self.runtimeGraphicsSelection(for: identity) + } + + func setMachineRuntimeGraphicsSelection( + _ machineID: String, + _ selection: NSDictionary? + ) { + lock.lock(); defer { lock.unlock() } + guard let row = machines[machineID]?.mutableCopy() as? NSMutableDictionary else { + return + } + if let selection { + row["runtimeGraphicsSelection"] = selection + } else { + row.removeObject(forKey: "runtimeGraphicsSelection") + } + machines[machineID] = row.copy() as? NSDictionary } func setEngineStatus(_ state: String, detail: String = "ok") { @@ -1884,6 +5312,54 @@ private final class FakeDorydService: NSObject, DorydControlXPC { lock.unlock() } + func setDashboardSnapshot(_ snapshot: [String: Data]) { + lock.lock() + _engineDashboardSnapshot = snapshot + lock.unlock() + } + + func setDockerGuestDataDiskUsage(_ usage: NSDictionary) { + lock.lock() + _dockerGuestDataDiskUsage = usage + lock.unlock() + } + + func setMachineFlightRecorderBatch(_ response: NSDictionary?) { + lock.lock() + _machineFlightRecorderBatchOverride = response + lock.unlock() + } + + func setMachineDeviceTelemetryResponse(_ response: NSDictionary?) { + lock.lock() + _machineDeviceTelemetryResponseOverride = response + lock.unlock() + } + + func setMachineSerialConsoleBatch(_ response: NSDictionary?) { + lock.lock() + _machineSerialConsoleBatchOverride = response + lock.unlock() + } + + var machineSerialConsoleBatchResponse: NSDictionary { + lock.lock() + defer { lock.unlock() } + return _machineSerialConsoleBatchOverride ?? [:] + } + + var latestMachineSerialConsoleCursor: NSDictionary? { + lock.lock() + defer { lock.unlock() } + return _latestMachineSerialConsoleCursor + } + + var latestMachineSerialConsoleInput: Data? { + lock.lock() + defer { lock.unlock() } + return _latestMachineSerialConsoleInput + } + func setEngineStartResult(ok: Bool, message: String = "") { lock.lock() _engineStartOK = ok @@ -1946,6 +5422,18 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(state, detail) } + func engineDashboardSnapshot(reply: @escaping (NSDictionary, String) -> Void) { + lock.lock() + _engineDashboardSnapshotCount += 1 + let snapshot = _engineDashboardSnapshot + lock.unlock() + guard let snapshot else { + reply([:], "dashboard snapshot unavailable in fake service") + return + } + reply(snapshot.mapValues { $0 as NSData } as NSDictionary, "") + } + func engineStart(reply: @escaping (Bool, String) -> Void) { lock.lock() _engineStartCount += 1 @@ -2001,6 +5489,30 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(dockerTelemetry(), "") } + func dockerGuestDataDiskUsage(reply: @escaping (NSDictionary, String) -> Void) { + if dockerGuestDataDiskUsageReplyDelay > 0 { + Thread.sleep(forTimeInterval: dockerGuestDataDiskUsageReplyDelay) + } + lock.lock() + let usage = _dockerGuestDataDiskUsage + lock.unlock() + reply(usage, "") + } + + static func dockerGuestDataDiskUsageRow( + engineSocketPath: String = "/tmp/doryd-test.sock", + dataDriveID: String = dockerDataDriveID + ) -> NSDictionary { + [ + "schema": UInt16(1), + "engineSocketPath": engineSocketPath, + "dataDriveID": dataDriveID, + "totalBytes": UInt64(128 * 1024 * 1024 * 1024), + "usedBytes": UInt64(8 * 1024 * 1024 * 1024), + "availableBytes": UInt64(120 * 1024 * 1024 * 1024), + ] + } + func machineCreate(_ config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) { let id = config["id"] as? String ?? "" let row = Self.machineRow( @@ -2010,8 +5522,8 @@ private final class FakeDorydService: NSObject, DorydControlXPC { cpuCount: Self.int(config["cpuCount"]) ?? 2, address: config["address"] as? String, displayMode: config["displayMode"] as? String ?? "headless", - shares: Self.shareRows(config["shares"]), - environment: Self.environmentRows(config["env"]) + shares: Self.machineStatusShareRows(config["shares"]), + environment: Self.typedEnvironment(config, baseline: []) ) lock.lock() _machineCreateCount += 1 @@ -2043,7 +5555,21 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, row, "") } + func machineStart( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _latestMachineStartOperationID = operationID + lock.unlock() + machineStart(machineID, reply: reply) + } + func machineStop(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { + if machineShutdownReplyDelay > 0 { + Thread.sleep(forTimeInterval: machineShutdownReplyDelay) + } lock.lock() let current = machines[machineID] let row = Self.machineRow( @@ -2062,6 +5588,125 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, row, "") } + func machineStop( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _latestMachineStopOperationID = operationID + lock.unlock() + machineStop(machineID, reply: reply) + } + + func machinePause(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { + lock.lock() + let current = machines[machineID] + let row = Self.machineRow( + id: machineID, + state: "paused", + pid: (current?["pid"] as? NSNumber)?.int32Value ?? 1234, + agentBuild: current?["agentBuild"] as? String, + handoffFDCount: Self.int(current?["handoffFDCount"]) ?? 0, + memoryMB: Self.uint64(current?["memoryMB"]) ?? 2048, + cpuCount: Self.int(current?["cpuCount"]) ?? 2, + address: current?["address"] as? String, + displayMode: current?["displayMode"] as? String ?? "headless", + shares: Self.shareRows(current?["shares"]), + environment: Self.environmentRows(current?["env"]) + ) + _machinePauseCount += 1 + machines[machineID] = row + lock.unlock() + reply(true, row, "") + } + + func machinePause( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _latestMachinePauseOperationID = operationID + lock.unlock() + machinePause(machineID, reply: reply) + } + + func machineSuspend(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { + lock.lock() + let current = machines[machineID] + let row = Self.machineRow( + id: machineID, + state: "suspended", + memoryMB: Self.uint64(current?["memoryMB"]) ?? 2048, + cpuCount: Self.int(current?["cpuCount"]) ?? 2, + address: current?["address"] as? String, + displayMode: current?["displayMode"] as? String ?? "headless", + shares: Self.shareRows(current?["shares"]), + environment: Self.environmentRows(current?["env"]), + savedState: Self.savedStateRow + ) + _machineSuspendCount += 1 + machines[machineID] = row + lock.unlock() + reply(true, row, "") + } + + func machineResume(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { + lock.lock() + let current = machines[machineID] + let row = Self.machineRow( + id: machineID, + state: "running", + pid: (current?["pid"] as? NSNumber)?.int32Value ?? 1234, + agentBuild: current?["agentBuild"] as? String, + handoffFDCount: Self.int(current?["handoffFDCount"]) ?? 0, + memoryMB: Self.uint64(current?["memoryMB"]) ?? 2048, + cpuCount: Self.int(current?["cpuCount"]) ?? 2, + address: current?["address"] as? String, + displayMode: current?["displayMode"] as? String ?? "headless", + shares: Self.shareRows(current?["shares"]), + environment: Self.environmentRows(current?["env"]) + ) + _machineResumeCount += 1 + machines[machineID] = row + lock.unlock() + reply(true, row, "") + } + + func machineResume( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _latestMachineResumeOperationID = operationID + lock.unlock() + machineResume(machineID, reply: reply) + } + + func machineRestart(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { + lock.lock() + let current = machines[machineID] + let row = Self.machineRow( + id: machineID, + state: "running", + pid: ((current?["pid"] as? NSNumber)?.int32Value ?? 1234) + 1, + agentBuild: current?["agentBuild"] as? String, + handoffFDCount: Self.int(current?["handoffFDCount"]) ?? 0, + memoryMB: Self.uint64(current?["memoryMB"]) ?? 2048, + cpuCount: Self.int(current?["cpuCount"]) ?? 2, + address: current?["address"] as? String, + displayMode: current?["displayMode"] as? String ?? "headless", + shares: Self.shareRows(current?["shares"]), + environment: Self.environmentRows(current?["env"]) + ) + _machineRestartCount += 1 + machines[machineID] = row + lock.unlock() + reply(true, row, "") + } + func machineUpdate(_ machineID: String, config: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) { lock.lock() _machineUpdateCount += 1 @@ -2079,7 +5724,10 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ?? 2 let address = config["address"] == nil ? current["address"] as? String : config["address"] as? String let shares = config["shares"] == nil ? Self.shareRows(current["shares"]) : Self.shareRows(config["shares"]) - let environment = config["env"] == nil ? Self.environmentRows(current["env"]) : Self.environmentRows(config["env"]) + let environment = Self.typedEnvironment( + config, + baseline: Self.environmentRows(current["env"]) + ) let state = current["state"] as? String ?? "stopped" let row = Self.machineRow( id: machineID, @@ -2092,30 +5740,189 @@ private final class FakeDorydService: NSObject, DorydControlXPC { address: address, displayMode: current["displayMode"] as? String ?? "headless", shares: shares, - environment: environment + environment: environment, + displayPresentation: current["displayPresentation"] as? NSDictionary + ) + machines[machineID] = row + lock.unlock() + reply(true, row, "") + } + + func machineRefreshManagedDesktopKernel( + _ machineID: String, + request: NSDictionary, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _latestManagedDesktopKernelRefreshRequest = request + let current = machines[machineID] ?? Self.machineRow( + id: machineID, + state: "stopped" ) + lock.unlock() + reply(true, current, "") + } + + func machineDisplayPresentationSet( + _ machineID: String, + presentation: NSDictionary, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + let current = machines[machineID] ?? Self.machineRow(id: machineID, state: "stopped") + let row = NSMutableDictionary(dictionary: current) + row["displayPresentation"] = presentation machines[machineID] = row lock.unlock() reply(true, row, "") } - func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) { + func machineDelete(_ machineID: String, reply: @escaping (Bool, String) -> Void) { + if machineShutdownReplyDelay > 0 { + Thread.sleep(forTimeInterval: machineShutdownReplyDelay) + } + lock.lock() + _machineDeleteCount += 1 + let ok = _machineDeleteOK + let message = _machineDeleteMessage + if ok { + machines.removeValue(forKey: machineID) + } + lock.unlock() + reply(ok, message) + } + + func machineList(reply: @escaping (NSArray, String) -> Void) { + lock.lock() + _machineListCount += 1 + let rows = machines.keys.sorted().compactMap { machines[$0] } + lock.unlock() + reply(rows as NSArray, "") + } + + func machineEvents( + _ afterSequence: UInt64, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _machineEventQueryCount += 1 + let row = _machineEventBatchOverride ?? [ + "schemaVersion": UInt16(1), + "headSequence": afterSequence, + "snapshotRequired": afterSequence == 0, + "events": [] as [NSDictionary], + ] + lock.unlock() + reply(true, row, "") + } + + func machineFlightRecorder( + _ machineID: String, + afterSequence: UInt64, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + let row = _machineFlightRecorderBatchOverride ?? [ + "schemaVersion": UInt16(1), + "machineID": machineID, + "headSequence": UInt64(0), + "snapshotRequired": afterSequence > 0, + "events": [] as [NSDictionary], + ] + lock.unlock() + reply(true, row, "") + } + + func machineDeviceTelemetry( + _ machineID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + let row = _machineDeviceTelemetryResponseOverride ?? [:] + lock.unlock() + reply(true, row, "") + } + + func machineUSBAttach( + _ machineID: String, + busID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { lock.lock() - _machineDeleteCount += 1 - let ok = _machineDeleteOK - let message = _machineDeleteMessage - if ok { - machines.removeValue(forKey: machineID) - } + let row = _machineUSBAttachResponseOverride ?? [ + "machineID": machineID, + "busID": busID, + "port": 4, + "vsockPort": UInt32(1_025), + "deviceID": UInt32(0x0003_0002), + "speed": UInt32(3), + ] lock.unlock() - reply(ok, message) + reply(true, row, "") } - func machineList(reply: @escaping (NSArray, String) -> Void) { + func hostUSBDevices(reply: @escaping (Bool, NSArray, String) -> Void) { lock.lock() - let rows = machines.keys.sorted().compactMap { machines[$0] } + let rows = _hostUSBDevicesResponseOverride ?? [ + [ + "busID": "3-2", + "vendorID": 0x05ac, + "productID": 0x12a8, + "vendorName": "Example Vendor", + "productName": "Example Device", + "deviceClass": 3, + "speed": 4, + ], + ] lock.unlock() - reply(rows as NSArray, "") + reply(true, rows, "") + } + + func machineUSBDetach( + _ machineID: String, + busID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + let row = _machineUSBDetachResponseOverride ?? [ + "machineID": machineID, + "busID": busID, + ] + lock.unlock() + reply(true, row, "") + } + + func machineSerialConsoleRead( + _ machineID: String, + cursor: NSDictionary, + limit: UInt32, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _latestMachineSerialConsoleCursor = cursor + let row = _machineSerialConsoleBatchOverride ?? [ + "schemaVersion": UInt16(1), + "machineID": machineID, + "startOffset": UInt64(0), + "nextOffset": UInt64(0), + "totalBytes": UInt64(0), + "snapshotRequired": false, + "inputAvailable": false, + "bytesBase64": "", + ] + lock.unlock() + reply(true, row, "") + } + + func machineSerialConsoleWrite( + _ machineID: String, + data: NSData, + reply: @escaping (Bool, String) -> Void + ) { + lock.lock() + _latestMachineSerialConsoleInput = data as Data + lock.unlock() + reply(true, "") } func machineStats(_ machineID: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { @@ -2141,6 +5948,184 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, Self.execRow(stdout: "cargo 1.0\n"), "") } + func machineTransfer( + _ machineID: String, + request: NSDictionary, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _latestMachineTransferRequest = request + let override = _machineTransferResponseOverride + lock.unlock() + if let override { + reply(true, override, "") + return + } + let transferID = String(repeating: "a", count: 32) + reply(true, [ + "schema": UInt16(1), + "transferID": transferID, + "guestDestination": "/home/developer/Downloads/Dory Transfer " + transferID, + "filesSent": UInt64(1), + "bytesSent": UInt64(5), + ], "") + } + + func machineTransferStart( + _ machineID: String, + request: NSDictionary, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _latestMachineTransferStartRequest = request + let override = _machineTransferOperationResponseOverride + lock.unlock() + reply( + true, + override ?? Self.transferOperationRow( + operationID: String(repeating: "b", count: 32), + machineID: machineID, + phase: "preparing" + ), + "" + ) + } + + func machineTransferStatus( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + let override = _machineTransferOperationResponseOverride + lock.unlock() + reply( + true, + override ?? Self.transferOperationRow( + operationID: operationID, + machineID: machineID, + phase: "completed" + ), + "" + ) + } + + func machineTransferCurrent( + _ machineID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + _ = machineID + lock.lock() + let override = _machineTransferCurrentResponseOverride + lock.unlock() + reply( + true, + override ?? ["schema": UInt16(1), "active": false], + "" + ) + } + + func machineTransferCancel( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + let cancelled = Self.transferOperationRow( + operationID: operationID, + machineID: machineID, + phase: "cancelled" + ) + lock.lock() + _machineTransferCancelCount += 1 + _machineTransferOperationResponseOverride = cancelled + lock.unlock() + reply( + true, + cancelled, + "" + ) + } + + func machineGuestExportStart( + _ machineID: String, + request: NSDictionary, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + _latestMachineGuestExportRequest = request + let override = _machineGuestExportStartResponseOverride + lock.unlock() + reply( + true, + override ?? Self.guestExportOperationRow( + operationID: String(repeating: "d", count: 32), + machineID: machineID, + phase: "preparing" + ), + "" + ) + } + + func machineGuestExportCurrent( + _ machineID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + _ = machineID + lock.lock() + let override = _machineGuestExportCurrentResponseOverride + lock.unlock() + reply(true, override ?? ["schema": UInt16(1), "active": false], "") + } + + func machineGuestExportStatus( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + let override = _machineGuestExportOperationResponseOverride + lock.unlock() + reply( + true, + override ?? Self.guestExportOperationRow( + operationID: operationID, + machineID: machineID, + phase: "completed" + ), + "" + ) + } + + func machineGuestExportCancel( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + let cancelled = Self.guestExportOperationRow( + operationID: operationID, + machineID: machineID, + phase: "cancelled" + ) + lock.lock() + _machineGuestExportCancelCount += 1 + _machineGuestExportOperationResponseOverride = cancelled + lock.unlock() + reply(true, cancelled, "") + } + + func machineGuestExportDiscard( + _ machineID: String, + operationID: String, + reply: @escaping (Bool, String) -> Void + ) { + _ = machineID + _ = operationID + lock.lock() + _machineGuestExportDiscardCount += 1 + lock.unlock() + reply(true, "") + } + func machineProvision(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) { let recipe = request["recipe"] as? String ?? "rust" lock.lock() @@ -2160,15 +6145,61 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ] as NSDictionary, "") } + func machineDesktopUpdate( + _ machineID: String, + request: NSDictionary, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + let current = machines[machineID] ?? Self.machineRow(id: machineID, state: "stopped") + _latestMachineDesktopUpdateOperationID = request["operationID"] as? String + let operationIDResponse = _machineDesktopUpdateOperationIDResponseOverride + ?? request["operationID"] + lock.unlock() + let response = NSMutableDictionary(dictionary: [ + "machineID": machineID, + "distro": request["distro"] as? String ?? "ubuntu", + "version": request["version"] as? String ?? "test", + "inputSHA256": String(repeating: "1", count: 64), + "bundleSHA256": String(repeating: "2", count: 64), + "snapshotID": "du-test", + "restoredRunningState": false, + "status": current, + ] as NSDictionary) + if let operationIDResponse { + response["operationID"] = operationIDResponse + } + reply(true, response, "") + } + func machineSnapshot(_ machineID: String, request: NSDictionary, reply: @escaping (Bool, NSDictionary, String) -> Void) { let id = request["snapshotID"] as? String ?? "s\(UUID().uuidString.prefix(8).lowercased())" - let row = Self.snapshotRow( + let baseRow = Self.snapshotRow( id: id, machineID: machineID, note: request["note"] as? String ?? "", createdISO: request["createdISO"] as? String ?? "2026-07-07T00:00:00Z" ) lock.lock() + let mutable = baseRow.mutableCopy() as? NSMutableDictionary + ?? NSMutableDictionary(dictionary: baseRow) + if let runtimeIdentityOverride { + mutable["runtimeIdentity"] = runtimeIdentityOverride + } + if let artifactEvidenceOverride { + mutable["artifactEvidence"] = artifactEvidenceOverride + } + if let installedDesktopPayloadReceiptOverride { + mutable["installedDesktopPayloadReceipt"] = + installedDesktopPayloadReceiptOverride + } + if let snapshotConsistencyOverride { + mutable["consistency"] = snapshotConsistencyOverride + } + if let snapshotQuiesceReceiptOverride { + mutable["guestQuiesceReceipt"] = snapshotQuiesceReceiptOverride + } + let row = mutable.copy() as? NSDictionary ?? baseRow _machineSnapshotCount += 1 snapshots[machineID, default: []].insert(row, at: 0) lock.unlock() @@ -2232,6 +6263,16 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, "") } + func machineAssessSnapshotImport( + _ path: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + lock.lock() + let row = _machineImportAssessmentOverride ?? Self.importAssessmentRow() + lock.unlock() + reply(true, row, "") + } + func machineImportSnapshot(_ path: String, reply: @escaping (Bool, NSDictionary, String) -> Void) { let row = Self.snapshotRow( id: "imported", @@ -2245,6 +6286,18 @@ private final class FakeDorydService: NSObject, DorydControlXPC { reply(true, row, "") } + func machineImportSnapshot( + _ path: String, + expectedContentID: String, + reply: @escaping (Bool, NSDictionary, String) -> Void + ) { + guard expectedContentID == String(repeating: "a", count: 64) else { + reply(false, [:], "machine bundle changed after import assessment") + return + } + machineImportSnapshot(path, reply: reply) + } + func machineBackupSchedules(reply: @escaping (NSArray, String) -> Void) { lock.lock() let rows = backupStatuses.keys.sorted().compactMap { backupStatuses[$0] } @@ -2548,6 +6601,13 @@ private final class FakeDorydService: NSObject, DorydControlXPC { "kernel": "Linux docker", "agentBuild": "docker-agent", "uptimeSeconds": 11, + "capabilities": [ + ["id": "clock-sync", "version": 1] as NSDictionary, + ["id": "exec", "version": 1] as NSDictionary, + ["id": "exec-stdin", "version": 1] as NSDictionary, + ["id": "ports-watch", "version": 1] as NSDictionary, + ["id": "telemetry", "version": 1] as NSDictionary, + ], ] } @@ -2566,6 +6626,11 @@ private final class FakeDorydService: NSObject, DorydControlXPC { "kernel": "Linux test", "agentBuild": "remote-agent", "uptimeSeconds": 9, + "capabilities": [ + ["id": "exec", "version": 1] as NSDictionary, + ["id": "sync-push", "version": 1] as NSDictionary, + ["id": "telemetry", "version": 1] as NSDictionary, + ], ] } @@ -2583,13 +6648,25 @@ private final class FakeDorydService: NSObject, DorydControlXPC { state: String, pid: Int32? = nil, agentBuild: String? = nil, + agentProtocolVersion: UInt32? = 1, + agentCapabilities: [NSDictionary] = [ + ["id": "clock-sync", "version": 1] as NSDictionary, + ["id": "exec", "version": 1] as NSDictionary, + ["id": "exec-stdin", "version": 1] as NSDictionary, + ["id": "ports-watch", "version": 1] as NSDictionary, + ["id": "snapshot-quiesce", "version": 2] as NSDictionary, + ["id": "sync-push", "version": 2] as NSDictionary, + ["id": "telemetry", "version": 1] as NSDictionary, + ], handoffFDCount: Int = 0, memoryMB: UInt64 = 2048, cpuCount: Int = 2, address: String? = nil, displayMode: String = "headless", shares: [NSDictionary] = [], - environment: [NSDictionary] = [] + environment: [NSDictionary] = [], + savedState: NSDictionary? = nil, + displayPresentation: NSDictionary? = nil ) -> NSDictionary { var row: [String: Any] = [ "id": id, @@ -2599,10 +6676,20 @@ private final class FakeDorydService: NSObject, DorydControlXPC { "memoryMB": memoryMB, "cpuCount": cpuCount, "displayMode": displayMode, + "flightRecorder": [ + "headSequence": UInt64(0), + "available": true, + ] as NSDictionary, ] if let pid { row["pid"] = pid } if let agentBuild { row["agentBuild"] = agentBuild + if let agentProtocolVersion { + row["agentProtocolVersion"] = agentProtocolVersion + if !agentCapabilities.isEmpty { + row["agentCapabilities"] = agentCapabilities + } + } row["handoffSocketPath"] = "/tmp/handoff.sock" row["agentSocketPath"] = "/tmp/agent.sock" row["dockerdSocketPath"] = "/tmp/dockerd.sock" @@ -2614,9 +6701,63 @@ private final class FakeDorydService: NSObject, DorydControlXPC { } row["shares"] = shares row["env"] = environment + if let savedState { + row["savedState"] = savedState + } + if let displayPresentation { + row["displayPresentation"] = displayPresentation + } return row as NSDictionary } + private static func runtimeGraphicsSelection( + for runtimeIdentity: NSDictionary + ) -> NSDictionary? { + guard runtimeIdentity["mode"] as? String == "resolved-plan", + runtimeIdentity["backend"] as? String == "dory-hypervisor", + let planSHA256 = runtimeIdentity["planSHA256"] as? String, + let planRevision = runtimeIdentity["planRevision"], + let graphics = runtimeIdentity["graphics"] as? String else { + return nil + } + let backend: String + switch graphics { + case "software": + backend = "software" + case "host-accelerated-display": + backend = "virgl" + case "hardware-accelerated-3d": + backend = "virgl-venus" + default: + return nil + } + var selection: [String: Any] = [ + "schemaVersion": UInt16(1), + "operationID": "01234567-89ab-4cde-8f01-23456789abcd", + "resolvedPlanSHA256": planSHA256, + "planRevision": planRevision, + "accelerationLevel": graphics, + "backend": backend, + ] + if graphics != "software" { + selection["rendererGeneration"] = UInt64(1) + selection["rendererWorkerReceiptSHA256"] = String(repeating: "7", count: 64) + selection["guestProducerFenceProofSHA256"] = String(repeating: "8", count: 64) + } + return selection as NSDictionary + } + + private static let savedStateRow: NSDictionary = [ + "schemaVersion": 1, + "backend": "apple-virtualization-framework", + "stateFileSHA256": String(repeating: "a", count: 64), + "stateFileByteCount": UInt64(4096), + "hostHardwareModel": "Mac16,1", + "hostOperatingSystemBuild": "25G90", + "createdAtUnixMilliseconds": Int64(1_787_318_400_000), + "portable": false, + ] + private static func shareRows(_ value: Any?) -> [NSDictionary] { if let rows = value as? [NSDictionary] { return rows @@ -2627,6 +6768,26 @@ private final class FakeDorydService: NSObject, DorydControlXPC { return [] } + /// Machine-create requests carry host authorization bookmarks. Daemon status deliberately + /// projects only non-secret share identity, so the fake must enforce the same XPC boundary. + private static func machineStatusShareRows(_ value: Any?) -> [NSDictionary] { + shareRows(value).compactMap { row in + guard let tag = row["tag"] as? String, + let hostPath = row["hostPath"] as? String, + let guestPath = row["guestPath"] as? String, + let readOnly = row["readOnly"] as? NSNumber, + CFGetTypeID(readOnly) == CFBooleanGetTypeID() else { + return nil + } + return [ + "tag": tag, + "hostPath": hostPath, + "guestPath": guestPath, + "readOnly": readOnly, + ] as NSDictionary + } + } + private static func environmentRows(_ value: Any?) -> [NSDictionary] { if let rows = value as? [NSDictionary] { return rows @@ -2637,6 +6798,71 @@ private final class FakeDorydService: NSObject, DorydControlXPC { return [] } + private static func typedEnvironment( + _ config: NSDictionary, + baseline: [NSDictionary] + ) -> [NSDictionary] { + var environment = Dictionary(uniqueKeysWithValues: baseline.compactMap { + row -> (String, String)? in + guard let key = row["key"] as? String, + let value = row["value"] as? String else { return nil } + return (key, value) + }) + if let identity = config["guestIdentityIntent"] as? NSDictionary { + if let account = identity["account"] as? NSDictionary { + apply(account["username"], key: "DORY_GUEST_USER", to: &environment) + let uid = (account["numericUserID"] as? NSNumber)?.stringValue + ?? (account["numericUserID"] as? UInt32).map(String.init) + if account["numericUserID"] != nil { + apply(uid ?? NSNull(), key: "DORY_GUEST_UID", to: &environment) + } + } + if let desktop = identity["desktop"] as? NSDictionary { + apply( + desktop["distributionIdentifier"], + key: "DORY_DESKTOP_DISTRO", + to: &environment + ) + apply(desktop["displayName"], key: "DORY_DESKTOP_NAME", to: &environment) + apply(desktop["version"], key: "DORY_DESKTOP_VERSION", to: &environment) + apply( + desktop["desktopEnvironment"], + key: "DORY_DESKTOP_ENVIRONMENT", + to: &environment + ) + } + } + if let clipboard = config["clipboardPolicy"] as? NSDictionary { + apply(clipboard["text"], key: "DORY_CLIPBOARD_POLICY", to: &environment) + } + apply( + config["desktopRuntimePreference"], + key: "DORY_DESKTOP_VMM", + to: &environment + ) + apply( + config["desktopGraphicsPreference"], + key: "DORY_DESKTOP_GRAPHICS", + to: &environment + ) + return environment.sorted { $0.key < $1.key }.map { key, value in + ["key": key, "value": value] as NSDictionary + } + } + + private static func apply( + _ raw: Any?, + key: String, + to environment: inout [String: String] + ) { + guard let raw else { return } + if raw is NSNull { + environment.removeValue(forKey: key) + } else if let value = raw as? String { + environment[key] = value + } + } + private static func uint64(_ value: Any?) -> UInt64? { if let number = value as? NSNumber { return number.uint64Value } return value as? UInt64 @@ -2647,6 +6873,68 @@ private final class FakeDorydService: NSObject, DorydControlXPC { return value as? Int } + private static func transferOperationRow( + operationID: String, + machineID: String = "dev", + phase: String + ) -> NSDictionary { + var row: [String: Any] = [ + "schema": UInt16(1), + "operationID": operationID, + "machineID": machineID, + "phase": phase, + "filesTotal": UInt64(phase == "completed" ? 1 : 0), + "filesCompleted": UInt64(phase == "completed" ? 1 : 0), + "bytesTotal": UInt64(phase == "completed" ? 5 : 0), + "bytesCompleted": UInt64(phase == "completed" ? 5 : 0), + ] + if phase == "completed" { + let destination = "/home/developer/Downloads/Dory Transfer " + operationID + row["guestDestination"] = destination + row["result"] = [ + "schema": UInt16(1), + "transferID": operationID, + "guestDestination": destination, + "filesSent": UInt64(1), + "bytesSent": UInt64(5), + ] as NSDictionary + } + return row as NSDictionary + } + + private static func guestExportOperationRow( + operationID: String, + machineID: String = "dev", + phase: String + ) -> NSDictionary { + var row: [String: Any] = [ + "schema": UInt16(1), + "operationID": operationID, + "machineID": machineID, + "phase": phase, + "filesTotal": UInt64(phase == "completed" ? 1 : 0), + "filesCompleted": UInt64(phase == "completed" ? 1 : 0), + "bytesTotal": UInt64(phase == "completed" ? 12 : 0), + "bytesCompleted": UInt64(phase == "completed" ? 12 : 0), + ] + if phase == "completed" { + let root = DoryMachineFileTransferStager.defaultStagingDirectory + .appendingPathComponent( + "export-\(getpid())-\(operationID)", + isDirectory: true + ).path + row["result"] = [ + "schema": UInt16(1), + "exportID": operationID, + "privateStagingRoot": root, + "filesReceived": UInt64(1), + "directoriesReceived": UInt64(1), + "bytesReceived": UInt64(12), + ] as NSDictionary + } + return row as NSDictionary + } + private static func execRow(stdout: String = "", stderr: String = "", exitCode: Int32 = 0) -> NSDictionary { [ "exitCode": exitCode, @@ -2658,6 +6946,56 @@ private final class FakeDorydService: NSObject, DorydControlXPC { ] as NSDictionary } + static func machineEventBatchRow(sequence: UInt64 = 5) -> NSDictionary { + [ + "schemaVersion": UInt16(1), + "headSequence": sequence, + "snapshotRequired": false, + "events": [[ + "schemaVersion": UInt16(1), + "sequence": sequence, + "observedAtUnixMilliseconds": Int64(1_000), + "machineID": "dev", + "kind": "updated", + "status": [ + "schemaVersion": UInt16(1), + "machineID": "dev", + "configurationRevision": String(repeating: "a", count: 64), + "observedRevision": String(repeating: "b", count: 64), + "state": "running", + "hasFailure": false, + "memoryMB": UInt64(2_048), + "cpuCount": 2, + "displayMode": "headless", + "bootMode": "linux-kernel", + "installerMediaAttached": false, + "shareCount": 0, + "integrationHealth": "missing-tools", + "runtimeMode": "legacy-compatibility", + "virtualHardwareABIVersion": UInt16(1), + ] as NSDictionary, + ] as NSDictionary] as [NSDictionary], + ] + } + + static func importAssessmentRow() -> NSDictionary { + [ + "schemaVersion": 1, + "contentID": String(repeating: "a", count: 64), + "sourceMachineID": "dev", + "sourceSnapshotID": "imported", + "architecture": "arm64", + "bootMode": "linux-kernel", + "diskSizeBytes": 4_096, + "virtualHardwareABIVersion": 1, + "sourceRuntimeMode": "legacy-compatibility", + "portable": true, + "disposition": "ready", + "issues": [] as [String], + "components": [] as [NSDictionary], + ] + } + private static func snapshotRow(id: String, machineID: String, note: String, createdISO: String) -> NSDictionary { [ "id": id, @@ -2736,13 +7074,72 @@ private final class FakeDorydService: NSObject, DorydControlXPC { } } +private func makeManagedDesktopAssetFixture( + prefix: String +) throws -> (assets: DesktopMachineAssets, directoryPath: String) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + let kernel = directory.appendingPathComponent("dory-desktop-kernel-arm64") + let rootfs = directory.appendingPathComponent("dory-desktop-rootfs-arm64.ext4") + try Data("managed-desktop-kernel".utf8).write(to: kernel) + try Data("managed-desktop-rootfs".utf8).write(to: rootfs) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: kernel.path + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: rootfs.path + ) + return ( + DesktopMachineAssets(kernelPath: kernel.path, rootfsPath: rootfs.path), + directory.path + ) +} + +private extension NSDictionary { + func adding(_ key: String, _ value: Any) -> NSDictionary { + var copy = stringKeyedCopy + copy[key] = value + return copy as NSDictionary + } + + func replacing(_ key: String, with value: Any) -> NSDictionary { + adding(key, value) + } + + func removing(_ key: String) -> NSDictionary { + var copy = stringKeyedCopy + copy.removeValue(forKey: key) + return copy as NSDictionary + } + + var stringKeyedCopy: [String: Any] { + var copy: [String: Any] = [:] + for (key, value) in self { + if let key = key as? String { + copy[key] = value + } + } + return copy + } +} + @MainActor -private func waitUntil(_ condition: @escaping @MainActor () -> Bool) async throws { +private func waitUntil( + _ label: String = "condition", + _ condition: @escaping @MainActor () -> Bool +) async throws { for _ in 0..<80 { if condition() { return } try await Task.sleep(for: .milliseconds(50)) } - #expect(condition()) + #expect(condition(), "Timed out waiting for \(label)") } private final class FakeDorydListenerDelegate: NSObject, NSXPCListenerDelegate { diff --git a/DoryTests/DorydLaunchAgentTests.swift b/DoryTests/DorydLaunchAgentTests.swift index f94e88a6..9ae33eb2 100644 --- a/DoryTests/DorydLaunchAgentTests.swift +++ b/DoryTests/DorydLaunchAgentTests.swift @@ -237,6 +237,12 @@ struct DorydLaunchAgentTests { #expect(plist.contains("\(helpersURL.path)")) #expect(plist.contains("DORYD_RESOURCES_DIR")) #expect(plist.contains("\(contentsURL.appendingPathComponent("Resources").path)")) + #expect(plist.contains("DORYD_STATE_DIR")) + #expect(plist.contains("\(DorydLaunchAgent.runtimeDirectory.appendingPathComponent("docker").path)")) + #expect(plist.contains("DORYD_MACHINE_RUNTIME_DIR")) + #expect(plist.contains("\(DorydLaunchAgent.runtimeDirectory.appendingPathComponent("m").path)")) + #expect(plist.contains("DORYD_SHARE_HOME")) + #expect(plist.contains("1")) #expect(plist.contains("DORYD_HOST_CLI")) #expect(plist.contains("1")) #expect(plist.contains("DORYD_AMD64")) @@ -266,6 +272,8 @@ struct DorydLaunchAgentTests { #expect(DorydLaunchAgent.Configuration.hostScaledCPUCount(activeProcessorCount: 2) == 2) #expect(DorydLaunchAgent.Configuration.hostScaledMemoryMB(physicalMemory: 16 * 1024 * 1024 * 1024) == 8192) #expect(DorydLaunchAgent.Configuration.hostScaledMemoryMB(physicalMemory: 8 * 1024 * 1024 * 1024) == 4096) + #expect(DorydLaunchAgent.Configuration.hostScaledMemoryMB(physicalMemory: 128 * 1024 * 1024 * 1024) == 62 * 1024) + #expect(DorydLaunchAgent.Configuration(memoryMB: 64 * 1024).memoryMB == 62 * 1024) } @Test func userEngineResourceLimitsReserveCapacityForMacOS() { @@ -275,6 +283,8 @@ struct DorydLaunchAgentTests { == AppStore.EngineResourceLimits(maximumCPUCount: 8, maximumMemoryMB: 4 * 1024)) #expect(AppStore.engineResourceLimits(activeProcessorCount: 2, physicalMemory: 4 * 1024 * 1024 * 1024) == AppStore.EngineResourceLimits(maximumCPUCount: 2, maximumMemoryMB: 2 * 1024)) + #expect(AppStore.engineResourceLimits(activeProcessorCount: 16, physicalMemory: 128 * 1024 * 1024 * 1024) + == AppStore.EngineResourceLimits(maximumCPUCount: 16, maximumMemoryMB: 62 * 1024)) } @Test func ensureCurrentRestartsWhenLaunchAgentEnvironmentChanges() async throws { @@ -446,6 +456,55 @@ struct DorydLaunchAgentTests { #expect(plist.contains("0")) } + @Test func qualificationBootstrapIsExplicitAndOffByDefault() throws { + func environment(_ configuration: DorydLaunchAgent.Configuration) throws -> [String: String] { + let plist = DorydLaunchAgent.launchAgentPlist( + program: "/Applications/Dory.app/Contents/Helpers/doryd", + helpersDirectory: URL(fileURLWithPath: "/Applications/Dory.app/Contents/Helpers"), + configuration: configuration + ) + let data = try #require(plist.data(using: .utf8)) + let root = try #require( + try PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ) as? [String: Any] + ) + return try #require(root["EnvironmentVariables"] as? [String: String]) + } + + #expect( + try environment(DorydLaunchAgent.Configuration())[ + "DORYD_VM_QUALIFICATION_BOOTSTRAP" + ] == "0" + ) + #expect( + try environment(DorydLaunchAgent.Configuration( + vmQualificationBootstrapEnabled: true + ))["DORYD_VM_QUALIFICATION_BOOTSTRAP"] == "1" + ) + } + + @Test func launchAgentBindsRawHVToNestedRunnerApplication() throws { + let plist = DorydLaunchAgent.launchAgentPlist( + program: "/Applications/Dory.app/Contents/Helpers/doryd", + helpersDirectory: URL(fileURLWithPath: "/Applications/Dory.app/Contents/Helpers") + ) + let data = try #require(plist.data(using: .utf8)) + let root = try #require( + try PropertyListSerialization.propertyList(from: data, options: [], format: nil) + as? [String: Any] + ) + let environment = try #require(root["EnvironmentVariables"] as? [String: String]) + + #expect( + environment["DORYD_HV_HELPER"] + == "/Applications/Dory.app/Contents/Helpers/DoryHVRunner.app/Contents/MacOS/dory-hv" + ) + #expect(environment["DORYD_HV_HELPER"] != "/Applications/Dory.app/Contents/Helpers/dory-hv") + } + @Test func launchAgentCanDisableDaemonOwnedDomains() throws { let plist = DorydLaunchAgent.launchAgentPlist( program: "/Applications/Dory.app/Contents/Helpers/doryd", @@ -506,9 +565,11 @@ struct DorydLaunchAgentTests { } @Test func launchAgentEngineChoicesAreOptInByDefault() throws { + let runtimeDirectory = URL(fileURLWithPath: "/private/var/folders/test/T/dev.dory.doryd") let plist = DorydLaunchAgent.launchAgentPlist( program: "/Applications/Dory.app/Contents/Helpers/doryd", - helpersDirectory: URL(fileURLWithPath: "/Applications/Dory.app/Contents/Helpers") + helpersDirectory: URL(fileURLWithPath: "/Applications/Dory.app/Contents/Helpers"), + runtimeDirectory: runtimeDirectory ) let data = try #require(plist.data(using: .utf8)) let root = try #require( @@ -518,11 +579,39 @@ struct DorydLaunchAgentTests { #expect(environment["DORYD_AMD64"] == "0") #expect(environment["DORYD_GPU"] == "off") + #expect(environment["DORYD_STATE_DIR"] == runtimeDirectory.appendingPathComponent("docker").path) + #expect(environment["DORYD_MACHINE_RUNTIME_DIR"] == runtimeDirectory.appendingPathComponent("m").path) + #expect(environment["DORYD_SHARE_HOME"] == "1") #expect( environment["DORYD_BRIDGE_SUBNET"] == DoryIPv4BridgeNetwork.defaultCIDR ) } + @Test func runtimeDirectoryIsScopedBeneathThePrivateDarwinDirectoryAndFitsMachineSockets() { + let darwinTemporaryDirectory = URL( + fileURLWithPath: "/private/var/folders/8f/l7zyp8_15jl68g9stnzw7lvw0000gn/T", + isDirectory: true + ) + let runtimeDirectory = DorydLaunchAgent.runtimeDirectory( + temporaryDirectory: darwinTemporaryDirectory + ) + + #expect( + runtimeDirectory.path + == darwinTemporaryDirectory.appendingPathComponent("d").standardizedFileURL.path + ) + + // MachineManager uses a 96-bit (24-hex-character) namespace token. console.sock is the + // longest current per-machine endpoint, so proving it fits also covers handoff, agent, + // shell, control, Docker, and USB sockets. sockaddr_un reserves one byte for the NUL. + let longestMachineSocket = runtimeDirectory + .appendingPathComponent("m", isDirectory: true) + .appendingPathComponent(String(repeating: "a", count: 24), isDirectory: true) + .appendingPathComponent("console.sock") + .path + #expect(longestMachineSocket.utf8.count <= 103) + } + @Test func launchAgentDoesNotOwnRuntimeModePolicy() { let plist = DorydLaunchAgent.launchAgentPlist( program: "/Applications/Dory.app/Contents/Helpers/doryd", diff --git a/DoryTests/KubernetesProvisionerImageTests.swift b/DoryTests/KubernetesProvisionerImageTests.swift index 91a2ead1..f2854355 100644 --- a/DoryTests/KubernetesProvisionerImageTests.swift +++ b/DoryTests/KubernetesProvisionerImageTests.swift @@ -24,17 +24,15 @@ struct KubernetesProvisionerImageTests { #expect(labels[KubernetesProvisioner.imageLabel] == image) } - @Test func fexCreateRequestMountsOnlyTheReadOnlyWrapper() throws { + @Test func fexCreateRequestDelegatesNestedRuntimeMountsToEngineAdmission() throws { let image = KubeVersionCatalog.latest.image let root = try jsonObject(image: image, amd64Emulation: true) let host = try #require(root["HostConfig"] as? [String: Any]) let labels = try #require(root["Labels"] as? [String: String]) let entrypoint = try #require(root["Entrypoint"] as? [String]) - let binds = try #require(host["Binds"] as? [String]) #expect(entrypoint == ["/bin/sh", "-ec"]) - #expect(binds == ["/usr/local/bin/dory-runc:/usr/local/bin/dory-runc:ro"]) - #expect(!binds.joined().contains("runc.real")) + #expect(host["Binds"] == nil) #expect(labels[KubernetesProvisioner.emulationLabel] == "fex") } @@ -45,8 +43,8 @@ struct KubernetesProvisionerImageTests { #expect(script.contains("test -x /usr/lib/dory/fex/FEX")) #expect(script.contains("test -x /usr/lib/dory/fex/FEXServer")) - #expect(script.contains("install -m 0755 /bin/runc /usr/local/bin/runc.real")) - #expect(script.contains("cmp -s /bin/runc /usr/local/bin/runc.real")) + #expect(script.contains("test -x /usr/local/bin/runc.real")) + #expect(!script.contains("install -m 0755 /bin/runc")) #expect(script.contains("BinaryName = \"/usr/local/bin/dory-runc\"")) #expect(!script.contains("platforms = [")) #expect(script.contains("runtime_config_tmp=")) diff --git a/DoryTests/LSUIElementBuildSettingTests.swift b/DoryTests/LSUIElementBuildSettingTests.swift index 052d13d6..b4e6341d 100644 --- a/DoryTests/LSUIElementBuildSettingTests.swift +++ b/DoryTests/LSUIElementBuildSettingTests.swift @@ -62,9 +62,11 @@ struct LSUIElementBuildSettingTests { @Test func appBuildPrunesStaleBundledHelpersBeforeSigning() throws { let text = try pbxproj() #expect(text.contains("Prune Stale Bundled Helpers")) - #expect(text.contains("find \\\"$HELPERS\\\" -maxdepth 1 -type f -exec rm -f {} +")) + #expect(text.contains("find \\\"$HELPERS\\\" -depth -delete")) + #expect(text.contains("refusing to prune unexpected helper path")) #expect(text.contains("$(TARGET_BUILD_DIR)/$(WRAPPER_NAME)/Contents/Helpers")) #expect(!text.contains("$(TARGET_BUILD_DIR)/$(WRAPPER_NAME)/Contents/Helpers/dory-vm")) + #expect(text.components(separatedBy: "ENABLE_USER_SCRIPT_SANDBOXING = NO;").count - 1 >= 2) } @Test func buildAndPublicTestRunnerScrubTransientXcodeProducts() throws { diff --git a/DoryTests/MachineEnvImportTests.swift b/DoryTests/MachineEnvImportTests.swift deleted file mode 100644 index 6ea08ecb..00000000 --- a/DoryTests/MachineEnvImportTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Testing -@testable import Dory - -struct MachineEnvImportTests { - @Test func defaultsContainAnthropicOnly() { - #expect(MachineEnvImport.defaultNames == ["ANTHROPIC_API_KEY"]) - #expect(MachineEnvImport.optionalExtras == ["OPENAI_API_KEY", "GH_TOKEN", "HF_TOKEN"]) - } - - @Test func normalizeUppercasesAndDedupes() { - let result = MachineEnvImport.normalize(["GH_TOKEN", "gh_token", " ", "ANTHROPIC_API_KEY"]) - #expect(result == ["GH_TOKEN", "ANTHROPIC_API_KEY"]) - } - - @Test func normalizeUppercasesAndTrims() { - #expect(MachineEnvImport.normalize([" openai_api_key "]) == ["OPENAI_API_KEY"]) - } - - @Test func parseSplitsOnCommasNewlinesAndSpaces() { - let result = MachineEnvImport.parse("GH_TOKEN, HF_TOKEN\nOPENAI_API_KEY foo_bar") - #expect(result == ["GH_TOKEN", "HF_TOKEN", "OPENAI_API_KEY", "FOO_BAR"]) - } - - @Test func serializeRoundTrips() { - #expect(MachineEnvImport.serialize(["HF_TOKEN", "ANTHROPIC_API_KEY"]) == "HF_TOKEN,ANTHROPIC_API_KEY") - } - - @Test func dropsInvalidEnvNames() { - #expect(MachineEnvImport.normalize(["FOO=BAR", "1PATH", "GH_TOKEN", "A B"]) == ["GH_TOKEN"]) - #expect(MachineEnvImport.parse("FOO=BAR, GH_TOKEN") == ["GH_TOKEN"]) - } - - @Test func probeCommandEmitsSentinelPerName() { - let command = MachineEnvImport.probeCommand(for: ["ANTHROPIC_API_KEY", "GH_TOKEN"]) - #expect(command.contains("@@DORYENV@@ANTHROPIC_API_KEY=%s@@DORYENV@@")) - #expect(command.contains("@@DORYENV@@GH_TOKEN=%s@@DORYENV@@")) - #expect(command.contains("\"${ANTHROPIC_API_KEY:-}\"")) - #expect(command.contains("\"${GH_TOKEN:-}\"")) - } - - @Test func parseProbeOutputExtractsNonEmptyVars() { - let output = "noise@@DORYENV@@ANTHROPIC_API_KEY=sk-ant-123@@DORYENV@@@@DORYENV@@GH_TOKEN=@@DORYENV@@tail" - let vars = MachineEnvImport.parseProbeOutput(output) - #expect(vars["ANTHROPIC_API_KEY"] == "sk-ant-123") - #expect(vars["GH_TOKEN"] == nil) - } - - @Test func parseProbeOutputIgnoresMalformed() { - #expect(MachineEnvImport.parseProbeOutput("no sentinels here").isEmpty) - #expect(MachineEnvImport.parseProbeOutput("@@DORYENV@@BROKEN_NO_EQ@@DORYENV@@").isEmpty) - } -} diff --git a/DoryTests/ManagedSettingsTests.swift b/DoryTests/ManagedSettingsTests.swift index 98eae778..bea79a34 100644 --- a/DoryTests/ManagedSettingsTests.swift +++ b/DoryTests/ManagedSettingsTests.swift @@ -26,7 +26,6 @@ struct ManagedSettingsTests { keepPinnedProjectsAwake: false, showWakeNotifications: false ) - store.machineEnvAllowList = ["PATH", "GITHUB_TOKEN"] store.engineCPUCount = 4 store.engineMemoryMB = 6144 @@ -49,7 +48,7 @@ struct ManagedSettingsTests { #expect(profile.fileSharing.defaultPolicy == "safe-scoped") #expect(profile.fileSharing.scopedMountsRequiredForSandboxes) #expect(profile.fileSharing.credentialStoresHidden) - #expect(profile.fileSharing.machineEnvAllowList == ["PATH", "GITHUB_TOKEN"]) + #expect(profile.fileSharing.machineEnvAllowList.isEmpty) #expect(profile.telemetry.mode == "none") let json = store.managedSettingsJSON() diff --git a/DoryTests/MigrationImportTransactionTests.swift b/DoryTests/MigrationImportTransactionTests.swift index ba74dd06..10976739 100644 --- a/DoryTests/MigrationImportTransactionTests.swift +++ b/DoryTests/MigrationImportTransactionTests.swift @@ -69,6 +69,77 @@ struct MigrationImportTransactionTests: StrictInventoryTestCase { #expect(fixture.target.snapshotValue.networks.isEmpty) } + @Test func authoritativeTargetDiskUsageDriftFailsBeforeTargetWrites() async throws { + let fixture = makeFixture() + let gibibyte: Int64 = 1_024 * 1_024 * 1_024 + fixture.target.targetStorageUsageSequence = [ + MigrationTargetStorageUsage( + totalBytes: 126 * gibibyte, + usedBytes: 8 * gibibyte, + availableBytes: 116 * gibibyte + ), + MigrationTargetStorageUsage( + totalBytes: 126 * gibibyte, + usedBytes: 8 * gibibyte + 4_096, + availableBytes: 116 * gibibyte - 4_096 + ), + ] + let prepared = try await collect(fixture) + let home = try temporaryHome() + defer { try? FileManager.default.removeItem(atPath: home) } + let store = try DoryOperationJournalStore(home: home) + + await #expect(throws: MigrationImportTransactionError.planDrift) { + _ = try await MigrationImportTransaction.openStagingSession( + prepared: prepared, + environment: environment(fixture, store: store, home: home) + ) + } + + #expect(fixture.target.targetStorageUsageProbeCount == 2) + #expect(fixture.target.createdVolumes.isEmpty) + #expect(fixture.target.createdNetworkRequests.isEmpty) + #expect(fixture.target.createdContainers.isEmpty) + #expect(fixture.target.snapshotValue.images.isEmpty) + let record = try store.read(prepared.identity.id) + #expect(record.state.phase == .quiescing) + #expect(record.state.status == .failed) + } + + @Test func reducedLiveGuestCeilingFailsRevalidationBeforeTargetWrites() async throws { + let fixture = makeFixture() + let gibibyte: Int64 = 1_024 * 1_024 * 1_024 + fixture.target.targetStorageUsageSequence = [ + MigrationTargetStorageUsage( + totalBytes: 126 * gibibyte, + usedBytes: 8 * gibibyte, + availableBytes: 116 * gibibyte + ), + MigrationTargetStorageUsage( + totalBytes: 126 * gibibyte, + usedBytes: 8 * gibibyte, + availableBytes: 111 * gibibyte + ), + ] + let prepared = try await collect(fixture) + let home = try temporaryHome() + defer { try? FileManager.default.removeItem(atPath: home) } + let store = try DoryOperationJournalStore(home: home) + + await #expect(throws: MigrationImportTransactionError.planDrift) { + _ = try await MigrationImportTransaction.openStagingSession( + prepared: prepared, + environment: environment(fixture, store: store, home: home) + ) + } + + #expect(fixture.target.targetStorageUsageProbeCount == 2) + #expect(fixture.target.createdVolumes.isEmpty) + #expect(fixture.target.createdNetworkRequests.isEmpty) + #expect(fixture.target.createdContainers.isEmpty) + #expect(fixture.target.snapshotValue.images.isEmpty) + } + @Test func reducedHostCapacityFailsTerminallyBeforeTargetWrites() async throws { let fixture = makeFixture() let prepared = try await collect(fixture) diff --git a/DoryTests/MigrationStrictInventoryNetworkTestSupport.swift b/DoryTests/MigrationStrictInventoryNetworkTestSupport.swift index 5cd9ad77..5e0a8643 100644 --- a/DoryTests/MigrationStrictInventoryNetworkTestSupport.swift +++ b/DoryTests/MigrationStrictInventoryNetworkTestSupport.swift @@ -22,7 +22,16 @@ extension StrictMigrationRuntime { guard method == "GET" else { return nil } if path == "/version" { return response(version) } if path == "/info" { return response(info) } - if path.hasPrefix("/system/df") { return response(systemDiskUsage) } + if path.hasPrefix("/system/df") { + systemDiskUsageRequestCount += 1 + guard let response = response(systemDiskUsage) else { return nil } + return HTTPResponse( + statusCode: systemDiskUsageStatusCode, + reason: systemDiskUsageReason, + headers: response.headers, + body: response.body + ) + } if path.hasPrefix("/containers/"), path.hasSuffix("/json") { let id = String(path.dropFirst("/containers/".count).dropLast("/json".count)) return response(containerInspections[id]) diff --git a/DoryTests/MigrationStrictInventoryTestSupport.swift b/DoryTests/MigrationStrictInventoryTestSupport.swift index 331200bd..724199a9 100644 --- a/DoryTests/MigrationStrictInventoryTestSupport.swift +++ b/DoryTests/MigrationStrictInventoryTestSupport.swift @@ -25,7 +25,7 @@ extension StrictInventoryTestCase { ] } - func makeFixture() -> StrictInventoryFixture { + func makeFixture(targetKind: RuntimeKind = .docker) -> StrictInventoryFixture { let source = StrictMigrationRuntime( identifier: "unix:///orbstack.sock", daemonID: "orbstack-daemon", @@ -34,7 +34,8 @@ extension StrictInventoryTestCase { let target = StrictMigrationRuntime( identifier: "unix:///dory.sock", daemonID: "dory-daemon", - product: "Dory" + product: "Dory", + kind: targetKind ) configureSource(source) target.snapshotValue = RuntimeSnapshot(engineVersion: "27.5.1") @@ -187,6 +188,7 @@ extension StrictInventoryTestCase { "ImageUsage": ["TotalSize": images], "VolumeUsage": [ "TotalSize": volumes.values.reduce(Int64(0), +), + "TotalCount": volumeItems.count, "Items": volumeItems ], "ContainerUsage": ["TotalSize": containers], @@ -274,6 +276,13 @@ final class StrictMigrationRuntime: ContainerRuntime { var snapshotValue = RuntimeSnapshot(engineVersion: "27.5.1") var writableSizes: [String: Int64] = [:] + var targetStorageUsage: MigrationTargetStorageUsage? + var targetStorageUsageSequence: [MigrationTargetStorageUsage?] = [] + var targetStorageUsageProbeCount = 0 + var failTargetStorageUsage = false + var systemDiskUsageRequestCount = 0 + var systemDiskUsageStatusCode = 200 + var systemDiskUsageReason = "OK" var version: [String: Any] var info: [String: Any] var systemDiskUsage: [String: Any]? @@ -328,6 +337,14 @@ final class StrictMigrationRuntime: ContainerRuntime { func snapshot() async throws -> RuntimeSnapshot { snapshotValue } func migrationSnapshot() async throws -> RuntimeSnapshot { snapshotValue } func migrationContainerWritableSizes() async throws -> [String: Int64] { writableSizes } + func migrationTargetStorageUsage() async throws -> MigrationTargetStorageUsage? { + targetStorageUsageProbeCount += 1 + if failTargetStorageUsage { throw TestMutationFailure.targetStorageUsage } + if !targetStorageUsageSequence.isEmpty { + return targetStorageUsageSequence.removeFirst() + } + return targetStorageUsage + } func stop(containerID: String) async throws {} func restart(containerID: String) async throws {} func logs(containerID: String) async throws -> [LogLine] { [] } @@ -414,5 +431,9 @@ final class StrictMigrationRuntime: ContainerRuntime { snapshotValue.images[index].additionalReferences = Array(references.dropFirst()) } - enum TestMutationFailure: Error { case injected, imageReferenced } + enum TestMutationFailure: Error { + case injected + case imageReferenced + case targetStorageUsage + } } diff --git a/DoryTests/MigrationStrictInventoryTests.swift b/DoryTests/MigrationStrictInventoryTests.swift index a3329ef2..6c192128 100644 --- a/DoryTests/MigrationStrictInventoryTests.swift +++ b/DoryTests/MigrationStrictInventoryTests.swift @@ -202,13 +202,132 @@ struct MigrationStrictInventoryTests: StrictInventoryTestCase { let fixture = makeFixture() fixture.target.systemDiskUsage = nil await #expect(throws: MigrationStrictInventoryError.incomplete( - "target Docker storage usage is unavailable" + "target Docker storage usage request did not return a response" )) { _ = try await collect(fixture) } } } + @Test func targetDockerUsageDiagnosticsPreserveHTTPAndParserFailures() async { + do { + let fixture = makeFixture() + fixture.target.systemDiskUsageStatusCode = 503 + fixture.target.systemDiskUsageReason = "Service Unavailable" + + await #expect(throws: MigrationStrictInventoryError.incomplete( + "target Docker storage usage request returned HTTP 503" + )) { + _ = try await collect(fixture) + } + } + do { + let fixture = makeFixture() + fixture.target.systemDiskUsage = [ + "ImageUsage": ["TotalSize": -1], + "VolumeUsage": ["TotalSize": 0], + "ContainerUsage": ["TotalSize": 0], + "BuildCacheUsage": ["TotalSize": 0], + ] + + await #expect(throws: MigrationStrictInventoryError.incomplete( + "target Docker storage usage response is invalid: " + + "invalidTotalUsage(\"ImageUsage.TotalSize is invalid\")" + )) { + _ = try await collect(fixture) + } + } + } + + @Test func doryTargetUsesAuthoritativeGuestDiskUsageWhenDockerUsageIsUnavailable() async throws { + let fixture = makeFixture() + let gibibyte: Int64 = 1_024 * 1_024 * 1_024 + fixture.target.systemDiskUsage = nil + fixture.target.targetStorageUsage = MigrationTargetStorageUsage( + totalBytes: 126 * gibibyte, + usedBytes: 8 * gibibyte, + availableBytes: 116 * gibibyte + ) + + let prepared = try await collect(fixture) + + #expect(prepared.capacity.targetDockerBytes == 8 * gibibyte) + #expect(fixture.target.targetStorageUsageProbeCount == 1) + #expect(fixture.target.systemDiskUsageRequestCount == 0) + } + + @Test func authoritativeGuestUsageCarriesTheEffectiveLiveCeilingIntoThePlan() async throws { + let fixture = makeFixture() + let gibibyte: Int64 = 1_024 * 1_024 * 1_024 + fixture.target.targetStorageUsage = MigrationTargetStorageUsage( + totalBytes: 126 * gibibyte, + usedBytes: 8 * gibibyte, + availableBytes: 92 * gibibyte + ) + + let prepared = try await collect(fixture) + + #expect(prepared.capacity.targetDockerBytes == 8 * gibibyte) + #expect(prepared.capacity.engineUsableBytes == 100 * gibibyte) + } + + @Test func zeroLiveGuestCapacityFailsClosedInsteadOfInventingEmptyUsage() async { + let fixture = makeFixture() + let gibibyte: Int64 = 1_024 * 1_024 * 1_024 + fixture.target.targetStorageUsage = MigrationTargetStorageUsage( + totalBytes: 126 * gibibyte, + usedBytes: 0, + availableBytes: 0 + ) + + await #expect(throws: MigrationStrictInventoryError.incomplete( + "authoritative target data-disk usage is internally inconsistent" + )) { + _ = try await collect(fixture) + } + #expect(fixture.target.systemDiskUsageRequestCount == 0) + } + + @Test func sharedVMTargetNeverDowngradesToDockerObjectUsage() async { + let fixture = makeFixture(targetKind: .sharedVM) + + await #expect(throws: MigrationStrictInventoryError.incomplete( + "Dory's shared-VM target did not provide authoritative guest data-disk usage" + )) { + _ = try await collect(fixture) + } + + #expect(fixture.target.targetStorageUsageProbeCount == 1) + #expect(fixture.target.systemDiskUsageRequestCount == 0) + } + + @Test func failedAuthoritativeGuestDiskProbeNeverFallsBackToDockerUsage() async { + let fixture = makeFixture() + fixture.target.failTargetStorageUsage = true + + await #expect(throws: MigrationStrictInventoryError.self) { + _ = try await collect(fixture) + } + + #expect(fixture.target.targetStorageUsageProbeCount == 1) + #expect(fixture.target.systemDiskUsageRequestCount == 0) + } + + @Test func authoritativeGuestDiskUsageMustMatchTheSelectedEngineCapacity() async { + let fixture = makeFixture() + fixture.target.targetStorageUsage = MigrationTargetStorageUsage( + totalBytes: 16 * 1_024 * 1_024 * 1_024, + usedBytes: 1, + availableBytes: 15 * 1_024 * 1_024 * 1_024 + ) + + await #expect(throws: MigrationStrictInventoryError.incomplete( + "authoritative target data-disk capacity does not match Dory's selected disk" + )) { + _ = try await collect(fixture) + } + } + @Test func volumeUsageMustExactlyMatchTheSnapshot() async { let fixture = makeFixture() fixture.source.systemDiskUsage = dockerUsage(volumes: [ diff --git a/DoryTests/MigrationTests.swift b/DoryTests/MigrationTests.swift index 8d0a9cef..b7641722 100644 --- a/DoryTests/MigrationTests.swift +++ b/DoryTests/MigrationTests.swift @@ -235,7 +235,7 @@ final class MigrationPreflightRuntime: ContainerRuntime { if useCurrentVolumeUsageShape { guard path == "/system/df?type=volume&verbose=1" else { return nil } return HTTPResponse(statusCode: 200, reason: "OK", headers: [:], body: Data(#""" - {"VolumeUsage":{"TotalSize":\#(reportedVolumeSize),"Items":[ + {"VolumeUsage":{"ActiveCount":1,"TotalCount":1,"TotalSize":\#(reportedVolumeSize),"Items":[ {"Name":"\#(reportedVolumeName)","UsageData":{"Size":\#(reportedVolumeSize),"RefCount":1}} ]}} """#.utf8)) diff --git a/DoryTests/NewMachineSettingsTests.swift b/DoryTests/NewMachineSettingsTests.swift index 4905a442..c05955dd 100644 --- a/DoryTests/NewMachineSettingsTests.swift +++ b/DoryTests/NewMachineSettingsTests.swift @@ -1,21 +1,126 @@ import Darwin +import DoryOperations import Testing @testable import Dory struct NewMachineSettingsTests { + @Test func portForwardDraftsRequireExactConflictFreeBindings() throws { + let rows = [ + MachinePortForwardDraft( + name: "web", + hostPort: "8080", + guestPort: "80" + ), + MachinePortForwardDraft( + name: "dns", + transport: .udp, + hostPort: "5353", + guestPort: "53", + exposure: .lan + ), + ] + let resolved = try #require( + MachinePortForwardDraft.resolved(rows, networkMode: .sharedNAT) + ) + #expect(resolved.map(\.id) == ["web", "dns"]) + #expect(resolved[1].transport == .udp) + #expect(MachinePortForwardDraft.resolved(rows, networkMode: .isolated) == nil) + + var duplicate = rows + duplicate[1].transport = .tcp + duplicate[1].hostPort = "8080" + duplicate[1].exposure = .loopback + #expect(MachinePortForwardDraft.resolved(duplicate, networkMode: .sharedNAT) == nil) + + var privileged = rows + privileged[0].hostPort = "443" + #expect(MachinePortForwardDraft.resolved(privileged, networkMode: .sharedNAT) == nil) + } + + @Test func desktopDefaultsScaleForBrowserWorkloadsWithoutConsumingTheHost() { + let eightGB = NewMachineSheet.recommendedDesktopResources( + activeProcessorCount: 8, + physicalMemory: 8 * 1_073_741_824 + ) + #expect(eightGB.cpus == 4) + #expect(eightGB.memoryGB == 4) + + let sixteenGB = NewMachineSheet.recommendedDesktopResources( + activeProcessorCount: 12, + physicalMemory: 16 * 1_073_741_824 + ) + #expect(sixteenGB.cpus == 6) + #expect(sixteenGB.memoryGB == 6) + + let largerHost = NewMachineSheet.recommendedDesktopResources( + activeProcessorCount: 32, + physicalMemory: 64 * 1_073_741_824 + ) + #expect(largerHost.cpus == 8) + #expect(largerHost.memoryGB == 8) + } + + @Test func customISOInstallationUsesABalancedResourceDefault() { + #expect(DoryInstallerMachinePolicy.defaultCPUCount == 4) + #expect(DoryInstallerMachinePolicy.defaultMemoryMB == 4_096) + } + + @Test func customISOHomeShareUsesAnExplicitManualVirtioFSTag() { + let mount = NewMachineSheet.sharedHomeMount( + home: "/Users/tester", + displayMode: .desktop, + customISOInstall: true, + guestUsername: "installer-user" + ) + + #expect(mount.host == "/Users/tester") + #expect(mount.guest == "/mnt/dory-mac-home") + #expect(mount.shareTag == "mac-home") + } + + @Test func managedDesktopHomeShareKeepsTheProvisionedUserPath() { + let mount = NewMachineSheet.sharedHomeMount( + home: "/Users/tester", + displayMode: .desktop, + customISOInstall: false, + guestUsername: "dory-user" + ) + + #expect(mount.guest == "/home/dory-user/Mac") + #expect(mount.shareTag == nil) + } + @Test func collectsResourcesRegardlessOfDisclosure() { let s = NewMachineSheet.buildSettings(cpus: 4, memoryGB: 8, mounts: [MountPair(host: "/Users/u/p", guest: "/Users/u/p")], - address: "192.168.215.40") + address: "192.168.215.40", + portForwards: [ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + ]) #expect(s.cpus == 4) #expect(s.memoryMB == 8 * 1024) #expect(s.mounts.count == 1) #expect(s.address == "192.168.215.40") #expect(s.displayMode == .desktop) - #expect(s.env["DORY_GUEST_USER"] == "dory") - #expect(s.env["DORY_GUEST_UID"] == String(getuid())) - #expect(s.env["DORY_DESKTOP_DISTRO"] == "debian") - #expect(s.env["DORY_DESKTOP_VERSION"] == "13") + #expect(s.env.isEmpty) + #expect(s.virtualMachineSettings?.guestIdentityIntent.account?.username == "dory") + #expect(s.virtualMachineSettings?.guestIdentityIntent.account?.numericUserID == UInt32(getuid())) + #expect(s.virtualMachineSettings?.guestIdentityIntent.desktop?.distributionIdentifier == "debian") + #expect(s.virtualMachineSettings?.guestIdentityIntent.desktop?.version == "13") + #expect(s.virtualMachineSettings?.clipboardPolicy == .legacyDesktop(.bidirectional)) + #expect(s.virtualMachineSettings?.runtimePreference == .accelerated) + #expect(s.virtualMachineSettings?.graphicsPreference == .virglVenus) + #expect(s.virtualMachineSettings?.networkMode == .sharedNAT) + #expect(s.virtualMachineSettings?.portForwards == [ + DoryVMPortForward(id: "web", hostPort: 8_080, guestPort: 80), + ]) + #expect(s.virtualMachineSettings?.audioConfiguration == DoryVMAudioConfiguration( + inputEnabled: true, + outputEnabled: true + )) + #expect(s.virtualMachineSettings?.cameraConfiguration + == DoryVMCameraConfiguration(enabled: true)) + #expect(s.virtualMachineSettings?.intelApplicationTranslationEnabled == nil) #expect(s.ports.isEmpty) } @@ -30,23 +135,131 @@ struct NewMachineSettingsTests { guestUID: 1_001 ) - #expect(settings.env["DORY_DESKTOP_DISTRO"] == "kali") - #expect(settings.env["DORY_DESKTOP_NAME"] == "Kali Linux") - #expect(settings.env["DORY_DESKTOP_VERSION"] == "Rolling") - #expect(settings.env["DORY_DESKTOP_ENVIRONMENT"] == "Xfce") - #expect(settings.env["DORY_GUEST_USER"] == "analyst") - #expect(settings.env["DORY_GUEST_UID"] == "1001") + #expect(settings.env.isEmpty) + #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.distributionIdentifier == "kali") + #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.displayName == "Kali Linux") + #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.version == "Rolling") + #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.desktopEnvironment == "Xfce") + #expect(settings.virtualMachineSettings?.guestIdentityIntent.account?.username == "analyst") + #expect(settings.virtualMachineSettings?.guestIdentityIntent.account?.numericUserID == 1_001) + } + + @Test func recordsUbuntuAsTheCanonicalGnomeDesktop() { + let settings = NewMachineSheet.buildSettings( + cpus: 4, + memoryGB: 8, + mounts: [], + displayMode: .desktop, + desktopDistro: .ubuntu, + guestUsername: "developer", + guestUID: 1_002 + ) + + #expect(settings.env.isEmpty) + #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.distributionIdentifier == "ubuntu") + #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.displayName == "Ubuntu") + #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.version == "24.04 LTS") + #expect(settings.virtualMachineSettings?.guestIdentityIntent.desktop?.desktopEnvironment == "GNOME") } - @Test func headlessServersDoNotCarryDesktopMetadata() { + @Test func headlessServersCarryOnlyTypedNetworkIntent() { let settings = NewMachineSheet.buildSettings( + cpus: 2, + memoryGB: 2, + mounts: [], + displayMode: .headless, + networkMode: .disconnected + ) + + #expect(settings.env.isEmpty) + #expect(settings.virtualMachineSettings?.guestIdentityIntent == .unspecified) + #expect(settings.virtualMachineSettings?.clipboardPolicy == nil) + #expect(settings.virtualMachineSettings?.runtimePreference == nil) + #expect(settings.virtualMachineSettings?.graphicsPreference == nil) + #expect(settings.virtualMachineSettings?.networkMode == .disconnected) + #expect(settings.virtualMachineSettings?.audioConfiguration == nil) + #expect(settings.virtualMachineSettings?.cameraConfiguration == nil) + } + + @Test func desktopAudioDirectionsAreCollectedIndependently() { + let settings = NewMachineSheet.buildSettings( + cpus: 4, + memoryGB: 4, + mounts: [], + audioInputEnabled: false, + audioOutputEnabled: true + ) + + #expect(settings.virtualMachineSettings?.audioConfiguration == DoryVMAudioConfiguration( + inputEnabled: false, + outputEnabled: true + )) + } + + @Test func desktopCameraChoiceIsExplicit() { + let settings = NewMachineSheet.buildSettings( + cpus: 4, + memoryGB: 4, + mounts: [], + cameraEnabled: false + ) + + #expect(settings.virtualMachineSettings?.cameraConfiguration + == DoryVMCameraConfiguration(enabled: false)) + } + + @Test func desktopGPUChoiceIsExplicitAndNeverSilentlyFallsBack() { + let accelerated = NewMachineSheet.buildSettings( + cpus: 4, + memoryGB: 4, + mounts: [] + ) + #expect(accelerated.virtualMachineSettings?.runtimePreference == .accelerated) + #expect(accelerated.virtualMachineSettings?.graphicsPreference == .virglVenus) + + let compatible = NewMachineSheet.buildSettings( + cpus: 4, + memoryGB: 4, + mounts: [], + gpuAccelerationEnabled: false + ) + #expect(compatible.virtualMachineSettings?.runtimePreference == .compatible) + #expect(compatible.virtualMachineSettings?.graphicsPreference == .software) + } + + @Test func newMachinesDoNotRequestUnsupportedIntelApplicationTranslation() { + let desktop = NewMachineSheet.buildSettings( + cpus: 4, + memoryGB: 4, + mounts: [] + ) + #expect(desktop.env.isEmpty) + #expect(desktop.virtualMachineSettings?.intelApplicationTranslationEnabled == nil) + + let headless = NewMachineSheet.buildSettings( cpus: 2, memoryGB: 2, mounts: [], displayMode: .headless ) + #expect(headless.virtualMachineSettings?.intelApplicationTranslationEnabled == nil) + } - #expect(settings.env["DORY_DESKTOP_DISTRO"] == nil) - #expect(settings.env["DORY_GUEST_USER"] == nil) + @Test func editingPreservesAnOlderDaemonsAbsentAudioClaimUntilTheUserChangesIt() { + #expect(MachineAudioSettingsPolicy.editedConfiguration( + existing: nil, + inputEnabled: true, + outputEnabled: true + ) == nil) + #expect(MachineAudioSettingsPolicy.editedConfiguration( + existing: nil, + inputEnabled: false, + outputEnabled: true + ) == DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: true)) + #expect(MachineAudioSettingsPolicy.editedConfiguration( + existing: DoryVMAudioConfiguration(inputEnabled: false, outputEnabled: true), + inputEnabled: true, + outputEnabled: true + ) == DoryVMAudioConfiguration(inputEnabled: true, outputEnabled: true)) } } diff --git a/DoryTests/ReleaseGuestKernelGateContractTests.swift b/DoryTests/ReleaseGuestKernelGateContractTests.swift new file mode 100644 index 00000000..467b27d1 --- /dev/null +++ b/DoryTests/ReleaseGuestKernelGateContractTests.swift @@ -0,0 +1,28 @@ +import Foundation +import Testing + +struct ReleaseGuestKernelGateContractTests { + @Test func releasePackagingGateRejectsUnqualifiedKernelSets() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let testScript = repositoryRoot + .appendingPathComponent("guest/kernel/test-release-package-gate.sh") + let output = Pipe() + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = [testScript.path] + process.standardOutput = output + process.standardError = output + + try process.run() + process.waitUntilExit() + let result = String( + data: output.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + + #expect(process.terminationStatus == 0, "\(result)") + #expect(result.contains("release guest-kernel gate tests passed")) + } +} diff --git a/DoryTests/ReviewFixTests.swift b/DoryTests/ReviewFixTests.swift index 2ad56a8a..36be17a3 100644 --- a/DoryTests/ReviewFixTests.swift +++ b/DoryTests/ReviewFixTests.swift @@ -1,5 +1,6 @@ import Testing import Foundation +import DoryOperations @testable import Dory @MainActor @@ -25,12 +26,70 @@ struct ReviewFixTests { #expect(!first.migrationSourceIdentifier.contains("/Users/example")) } - @Test func dockerRuntimeClonePreservesConfiguredMigrationIdleTimeout() { - let runtime = DockerEngineRuntime(socketPath: "/tmp/dory-timeout-source.sock") + @Test func dockerRuntimeClonePreservesConfiguredMigrationControls() async throws { + let expectedUsage = MigrationTargetStorageUsage( + totalBytes: 126, + usedBytes: 8, + availableBytes: 116 + ) + let runtime = DockerEngineRuntime( + socketPath: "/tmp/dory-timeout-source.sock", + migrationTargetStorageUsageProbe: { expectedUsage } + ) .withOperationIdleTimeout(42) #expect(runtime.operationIdleTimeout == 42) #expect(runtime.socketPath == "/tmp/dory-timeout-source.sock") + #expect(try await runtime.migrationTargetStorageUsage() == expectedUsage) + } + + @Test func doryMigrationDiskProbeBindsSocketAndSelectedDriveIdentity() throws { + let home = "/tmp/dory-migration-drive-\(getpid())-\(UInt32.random(in: 0.. **Status note (2026-08-22):** This document remains the product parity bar, but its narrative +> “Current state” cells are a historical snapshot and must not be used as release support claims. +> Implementation, product reachability, qualification, and support are now tracked separately in +> [`docs/linux-capability-and-qualification-matrix.md`](docs/linux-capability-and-qualification-matrix.md). +> The controlling design and sequencing are +> [`docs/linux-virtual-workspace-architecture.md`](docs/linux-virtual-workspace-architecture.md) and +> [`docs/linux-virtual-workspace-delivery-plan.md`](docs/linux-virtual-workspace-delivery-plan.md). + +This document defines Dory's release bar for a Parallels-class Linux experience on Apple Silicon. +The comparison target is the Linux feature set in Parallels Desktop 26, not its Windows-only +features. A capability is not considered shipped because code or a package exists: it must pass an +automated test in the signed release candidate and a live test on physical supported Macs. + +Primary references: + +- [Parallels Tools overview](https://docs.parallels.com/landing/pdfm-ug/parallels-desktop-for-mac-26-users-guide/advanced-topics/installing-and-updating-parallels-tools/parallels-tools-overview) +- [Parallels Apple-silicon limitations](https://kb.parallels.com/en/128914) +- [Parallels Linux OpenGL support](https://kb.parallels.com/124138) +- [Parallels Virtio GPU and VirGL](https://kb.parallels.com/en/128518) +- [Apple GUI Linux VM reference](https://developer.apple.com/documentation/virtualization/running-gui-linux-in-a-virtual-machine-on-a-mac) +- [Apple shared directories](https://developer.apple.com/documentation/virtualization/shared-directories) +- [Apple clipboard sharing](https://developer.apple.com/documentation/virtualization/clipboard-sharing) +- [Apple VM audio](https://developer.apple.com/documentation/virtualization/audio) +- [Apple Intel-binary translation in Linux](https://developer.apple.com/documentation/virtualization/running-intel-binaries-in-linux-vms) + +## Non-negotiable release contract + +| Area | Required behavior | Current state | +|---|---|---| +| Official desktops | Ubuntu uses Canonical GNOME and Yaru; Debian and Kali use complete, distro-native Xfce sessions | Ubuntu's same-commit image and signed-app candidate passed the physical-Mac live gate, including restart, persistence, guest-tools update, corrupt-update rejection, rollback, and post-rollback qualification; Debian and Kali still need the same candidate-bound run | +| Applications | Each managed desktop includes a browser, terminal, files, settings, editor, archive viewer, image viewer, PDF viewer, package sources, and can install and launch more apps | Ubuntu's signed candidate mapped and rendered Firefox, Files, Settings, Calculator, and Terminal correctly; Debian and Kali plus the broader app set still need candidate-bound evidence | +| Custom Linux | Create an ARM64 Linux VM from a user-selected ISO through EFI, with persistent NVRAM and install media lifecycle | The signed app securely stages protected user-selected media, fingerprints the exact bytes, reports architecture compatibility separately from runtime qualification, imports accepted media into private daemon-managed storage, creates a thin disk, and boots through persistent EFI. Desktop ISO machines use balanced 4-vCPU/4-GB resource defaults rather than treating a CPU count as a compatibility workaround. A private bidirectional serial console gives every EFI boot durable diagnostics and recovery input. The EFI root disk is now native NVMe with fsync semantics; Dory-owned direct-kernel guests retain their VirtIO-block contract. Ubuntu 24.04.3 ARM64 completed installation and booted its persistent desktop after ISO ejection, but a later whole-guest stall during a Chromium snap installation keeps the exact runtime unqualified | +| Native and Intel apps | Native ARM64 apps work normally; supported x86_64 Linux applications use Apple's Linux translation runtime with guided setup and clear compatibility reporting | Missing for desktop machines | +| Display | Retina rendering, dynamic resolution during resize, full screen, correct scaling, cursor integration, and multi-display support | Single-display Retina, resize, native full-screen participation, and system-key capture exist; full qualification and multi-display are missing | +| Graphics | Hardware-accelerated Linux graphics meeting Parallels' advertised OpenGL level, with a software fallback and an application compatibility suite | The isolated dual VirGL2/Venus renderer, real packaged-worker bootstrap receipt, fresh exact live comparison, and candidate-bound crash circuit breaker are implemented. A renderer failure stops the uncertain GPU generation safely and makes the next automatic plan select its declared software recovery level; a hardware-only request stays an error. Hardware 3D remains unqualified until the release-signed candidate passes the physical Mesa VirGL desktop and Venus/Zed sustained-application gates | +| Clipboard | Bidirectional text and image clipboard with an explicit off/host-to-guest/guest-to-host/bidirectional policy | Binary-safe host/guest transport, native shortcuts, focus synchronization, Wayland/X11 adapters, and policy UI are implemented; all-distro live and exact-candidate qualification remain | +| Drag and drop | Bidirectional file drag and drop between Finder and the Linux desktop with conflict, cancellation, and progress handling | Missing | +| Shared folders | Add/remove read-only or read-write Mac folders, stable guest paths, permissions, large-file tests, file watching, and safe runtime updates | Scoped VirtioFS shares exist; runtime mutation and complete compatibility qualification are missing | +| Audio | Speaker output and microphone input, device/permission handling, mute, reconnect, and host sleep/wake recovery | Output and microphone devices plus signing/privacy declarations added; exact-candidate capture, controls, and recovery qualification pending | +| Devices | USB storage plus qualified physical USB attachment/detachment and remembered routing; camera and removable-media behavior is explicit | Host discovery only; passthrough missing | +| Networking | NAT/shared networking, bridged networking, host-only networking, stable addressing, DNS/VPN behavior, port forwarding, and offline recovery | Shared/NAT, host-only, and disconnected profiles now have exact backend-neutral contracts; new resolved plans bind a stable locally administered MAC and exact MTU across gvproxy, VZ, raw-HV, and source-preserving LAN forwarding. Qualified bridged networking and the signed-candidate stress matrix remain open. | +| Lifecycle | Graceful stop, pause/resume, durable suspend-to-disk, host sleep/wake, crash recovery, and resource reconfiguration | Managed guests shut down through the Dory agent; arbitrary EFI guests now fall back to Virtualization.framework's native virtual power-button request so filesystems can flush without guest tools. Pause foundations and disk persistence exist; saved machine state is missing | +| Data safety | Consistent snapshots, restore, clone, linked clone, export/import, and corruption/interruption recovery | Snapshot, restore, clone, and export/import include EFI machine identity/NVRAM with transactional rollback and bundle integrity checks; linked clones and live-state consistency need work | +| Guest tools | Versioned Dory guest tools update automatically and provide clipboard, resize, shares, time sync, drag/drop, telemetry, and compatibility health | Agent and Dory-owned integration files, including clipboard adapters, ship in one offline, deterministic desktop-update transaction. The signed package list is provenance and a compatibility preflight, not permission to run a hidden distribution upgrade; drag/drop and a unified compatibility-health surface remain missing | +| Updates | Existing desktops receive tested guest-tools and integration updates in place with rollback, while normal distro package and application updates remain available inside the guest; recreating the VM is never the upgrade path | Durable daemon journals, retained last-good snapshots, automatic failure/interruption rollback, and UI activation exist. The Ubuntu signed candidate passed the schema-v2 offline guest-tools update, corrupt-bundle rejection, last-good rollback, and post-rollback requalification; distro package-update UX and the remaining distros still need release evidence | +| Release provenance | Desktop kernel and every rootfs are built and verified from the release commit, signed into the component catalog, then boot-tested from that exact catalog | Same-commit build plus signed-helper physical-Mac boot gate added; the workflow must still pass before release | + +## Current installer-media evidence + +- Ubuntu 24.04.4 Desktop ARM64 (`c2610520bf582976839a1724c669e1cfed0547427be5a0ad12d457b92b46ffbe`) is architecture-compatible but runtime-known-unstable on Mac14,10 with macOS build 26A5406e and Dory's retired `vz-efi-virtio-blk-v1` profile. This exact media/host/profile tuple panicked or froze at one, two, and six vCPUs, so Dory blocks that tuple instead of presenting a resource-count workaround. It has not yet been qualified with the materially different native-NVMe profile. +- Ubuntu 24.04.3 Desktop ARM64 (`cdbf0f83ab4f7d46be767e73c59b5cbca9743dd5fb887142c96f4b2df38fa5ad`) also froze during installation with the retired VirtIO-block EFI profile. With `vz-efi-nvme-fsync-v1`, its 6.14.0-27 installer completed `curtin_install`, post-install configuration, and unattended package updates against a 64-GB NVMe disk. After ejecting the ISO, the installed system cold-booted, reached GNOME, retained Chrome, and browsed HTTPS over the VirtIO network. A later whole-guest stall was observed while App Center installed the Chromium snap, so this exact native-NVMe tuple remains unqualified pending a reproduced kernel/runtime diagnosis and sustained application stress pass. +- The user-supplied Omarchy 4.0.0 ISO (`9224fab3720560f771969a99a499e5f7e0f8e2d6a0681d872d52f05fb5003da4`) is x86_64 EFI-only. Dory rejects it before disk allocation or ISO staging with: `This ISO is Intel x86_64-only. Apple Silicon requires an arm64 EFI ISO.` This is the same whole-guest architecture limit documented for hardware-virtualized Linux on Apple silicon; native Omarchy qualification requires upstream ARM64 EFI media. + +## Qualification matrix + +Every supported managed desktop and every custom-ISO path must pass the following on each supported +macOS major version and qualified Apple-silicon generation: + +1. Cold boot, login, shutdown, restart, host sleep/wake, pause/resume, and durable suspend/restore. +2. DHCP, DNS, IPv4/IPv6, VPN route changes, host-only connectivity, bridged connectivity, and port forwarding. +3. Browser HTTPS navigation, package update, GUI package installation, application launch, file open/save, and reboot persistence. +4. Window resize, full screen, Retina scaling, cursor capture/release, keyboard shortcuts, clipboard text/image, and drag/drop in both directions. +5. Speaker playback, microphone recording, shared-folder read/write and read-only enforcement, USB attach/detach, and removable media. +6. OpenGL compatibility, accelerated rendering correctness, software fallback, and representative developer/creative GUI workloads. +7. Snapshot, restore, clone, export/import, interrupted-operation recovery, and low-disk behavior. +8. Native ARM64 and supported x86_64 application execution with architecture and failure diagnostics. +9. Upgrade from every supported prior Dory desktop image without losing user accounts, applications, + settings, shared-folder configuration, or workload data; failed updates must roll back cleanly. + +Evidence must identify the release commit, app digest, component catalog digest, desktop image digest, +Mac model, macOS version, guest distribution, guest package manifest, and pass/fail result. + +## Platform-equivalent scope + +The contract does not require a capability that Parallels itself marks unavailable for Linux on +Apple Silicon, such as nested virtualization or booting a complete Intel Linux distribution through +hardware virtualization. It does require supported Intel Linux *applications* inside ARM Linux. +Windows-only Coherence is not a Linux parity requirement. A beta-only host API does not count as a +shipping capability; Dory must either provide a qualified fallback or mark that host version +unsupported for the parity release. + +## Release rule + +Dory must not describe its Linux experience as Parallels-equivalent while any required row is +missing or only preview. The next public desktop release may ship when every required capability is +either `PASS` in candidate-bound evidence or explicitly outside Parallels' own Apple-silicon Linux +contract. diff --git a/Packages/ContainerizationEngine/DoryFSWorker.entitlements b/Packages/ContainerizationEngine/DoryFSWorker.entitlements new file mode 100644 index 00000000..6631ffa6 --- /dev/null +++ b/Packages/ContainerizationEngine/DoryFSWorker.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/ContainerizationEngine/DoryRendererWorker.entitlements b/Packages/ContainerizationEngine/DoryRendererWorker.entitlements new file mode 100644 index 00000000..6737bc7d --- /dev/null +++ b/Packages/ContainerizationEngine/DoryRendererWorker.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + 864H636QW4.dory-renderer + + + diff --git a/Packages/ContainerizationEngine/Package.swift b/Packages/ContainerizationEngine/Package.swift index 4ecd0cb4..f7d57600 100644 --- a/Packages/ContainerizationEngine/Package.swift +++ b/Packages/ContainerizationEngine/Package.swift @@ -8,7 +8,30 @@ let package = Package( name: "ContainerizationEngine", platforms: [.macOS(.v15)], products: [ + .library(name: "DoryFSWorkerContracts", targets: ["DoryFSWorkerContracts"]), + .library(name: "DoryFSWorkerServiceCore", targets: ["DoryFSWorkerServiceCore"]), + .library( + name: "DoryRendererWorkerContracts", + targets: ["DoryRendererWorkerContracts"] + ), + .library( + name: "DoryRendererWorkerServiceCore", + targets: ["DoryRendererWorkerServiceCore"] + ), + .library( + name: "DoryRendererWorkerMetalTransport", + targets: ["DoryRendererWorkerMetalTransport"] + ), + .library( + name: "DoryRendererWorkerVirglBackend", + targets: ["DoryRendererWorkerVirglBackend"] + ), + .library(name: "DoryHV", targets: ["DoryHV"]), .executable(name: "dory-hv", targets: ["dory-hv"]), + .executable( + name: "dory-renderer-worker", + targets: ["DoryRendererWorkerXPCService"] + ), ], dependencies: [ // Keep the guest control wire protocol in one implementation. DoryCore embeds the Rust @@ -17,6 +40,61 @@ let package = Package( .package(path: "../../dory-core-swift"), ], targets: [ + // Foundation-only wire contracts shared by the VMM-side broker and signed XPC service. + // SwiftPM builds the protocol leaf and executable test surface; Dory.xcodeproj owns the + // actual sandboxed XPC bundle, nested embed, and inside-out signing graph. + .target(name: "DoryFSWorkerContracts"), + // Path-free worker-side authority acquisition. Keep this leaf independent of the VMM, + // Hypervisor.framework, IOKit, and the daemon so a signed service can embed it directly. + .target( + name: "DoryFSWorkerServiceCore", + dependencies: ["DoryFSWorkerContracts"], + linkerSettings: [.linkedFramework("CoreServices")] + ), + // Path-free binary authority shared by the VMM and the signed renderer service. The + // contract intentionally has no dependency on Hypervisor.framework, AppKit, Metal, or the + // foreign renderer ABI. + .target( + name: "DoryRendererWorkerContracts", + dependencies: [ + .product( + name: "DoryRendererWorkerWireContracts", + package: "dory-core-swift" + ), + ] + ), + // Metal/XPC transport is deliberately outside the Foundation/CryptoKit authority package + // consumed by doryd. Only the signed runner and renderer service depend on this leaf. + .target( + name: "DoryRendererWorkerMetalTransport", + dependencies: ["DoryRendererWorkerContracts"], + linkerSettings: [.linkedFramework("Metal")] + ), + .target( + name: "DoryRendererWorkerServiceCore", + dependencies: [ + "DoryGuestMemoryShim", + "DoryRendererWorkerContracts", + "DoryRendererWorkerMetalTransport", + ] + ), + .target( + name: "DoryVirglRendererShim" + ), + .target( + name: "DoryRendererWorkerVirglBackend", + dependencies: [ + "DoryRendererWorkerContracts", + "DoryRendererWorkerServiceCore", + "DoryVirglRendererShim", + ], + linkerSettings: [ + .linkedFramework("Metal"), + ] + ), + .target( + name: "DoryGuestMemoryShim" + ), .target( name: "DoryHVUSBShim", linkerSettings: [ @@ -27,8 +105,13 @@ let package = Package( .target( name: "DoryHV", dependencies: [ + "DoryFSWorkerContracts", + "DoryGuestMemoryShim", + "DoryRendererWorkerContracts", + "DoryRendererWorkerMetalTransport", "DoryHVUSBShim", .product(name: "DoryCore", package: "dory-core-swift"), + .product(name: "DoryVMContracts", package: "dory-core-swift"), ], linkerSettings: [ .linkedFramework("Hypervisor"), @@ -40,16 +123,73 @@ let package = Package( .executableTarget( name: "dory-hv", dependencies: [ + "DoryFSWorkerContracts", "DoryHV", + "DoryRendererWorkerContracts", .product(name: "DoryCore", package: "dory-core-swift"), + .product(name: "DorydKit", package: "dory-core-swift"), + .product(name: "DoryOperations", package: "dory-core-swift"), + .product(name: "DoryVMContracts", package: "dory-core-swift"), + .product(name: "DoryVMMKit", package: "dory-core-swift"), + ], + linkerSettings: [ + .linkedFramework("AppKit"), + .linkedFramework("AVFAudio"), + .linkedFramework("AVFoundation"), + ] + ), + .executableTarget( + name: "DoryRendererWorkerXPCService", + dependencies: [ + "DoryRendererWorkerContracts", + "DoryRendererWorkerMetalTransport", + "DoryRendererWorkerServiceCore", + "DoryRendererWorkerVirglBackend", + ] + ), + .testTarget( + name: "DoryFSWorkerServiceCoreTests", + dependencies: [ + "DoryFSWorkerContracts", + "DoryFSWorkerServiceCore", + ] + ), + .testTarget( + name: "DoryRendererWorkerContractsTests", + dependencies: [ + "DoryRendererWorkerContracts", + "DoryRendererWorkerMetalTransport", + ] + ), + .testTarget( + name: "DoryRendererWorkerServiceCoreTests", + dependencies: [ + "DoryGuestMemoryShim", + "DoryRendererWorkerContracts", + "DoryRendererWorkerServiceCore", + ] + ), + .testTarget( + name: "DoryRendererWorkerVirglBackendTests", + dependencies: [ + "DoryRendererWorkerContracts", + "DoryRendererWorkerServiceCore", + "DoryRendererWorkerVirglBackend", + "DoryVirglRendererShim", ] ), .testTarget( name: "DoryHVTests", dependencies: [ + "DoryFSWorkerContracts", + "DoryFSWorkerServiceCore", + "DoryRendererWorkerContracts", "DoryHV", + "DoryVirglRendererShim", "dory-hv", .product(name: "DoryCore", package: "dory-core-swift"), + .product(name: "DoryOperations", package: "dory-core-swift"), + .product(name: "DoryVMContracts", package: "dory-core-swift"), ] ), ] diff --git a/Packages/ContainerizationEngine/README.md b/Packages/ContainerizationEngine/README.md index 06f5d3de..8320a765 100644 --- a/Packages/ContainerizationEngine/README.md +++ b/Packages/ContainerizationEngine/README.md @@ -12,7 +12,7 @@ The full process, storage, networking, and trust-boundary contract is documented - Arm64 and x86_64 raw-HV boot/device implementations. Public 0.4 releases remain Apple-silicon only until an Intel candidate passes dedicated physical qualification. -- Virtio block, network, vsock, rng, balloon, GPU-preview, and VirtioFS devices. +- Virtio block, network, vsock, rng, balloon, VirGL/Venus GPU, and VirtioFS devices. - A copyless guest networking path through the provenance-pinned `gvproxy` helper. - Host-share coherence, bounded FSEvents batching, queue/backpressure telemetry, and recovery. - Published-port, SSH-agent, host-AI, and guest-control bridges. diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerBootstrap.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerBootstrap.swift new file mode 100644 index 00000000..a523101b --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerBootstrap.swift @@ -0,0 +1,1081 @@ +import Foundation + +/// Validation failures for the one-workspace worker bootstrap. The bootstrap is an authority +/// envelope rather than a persistence format, so version 1 rejects every unknown flag, reserved +/// byte, non-canonical ordering, and trailing byte. +public enum DoryFSWorkerBootstrapError: Error, Equatable, Sendable { + case invalidWorkspaceIdentity + case invalidPinnedRootIdentity(field: String) + case invalidWorkerLimits(field: String) + case invalidShareResourceLimits(field: String) + case incompatibleShareLimit(field: String) + case invalidShareCount(limit: Int, actual: Int) + case duplicateShareCapabilityID + case invalidRootDescriptorIndex(limit: Int, actual: Int) + case duplicateRootDescriptorIndex + case invalidComponent(String) + case tooManyComponents(limit: Int, actual: Int) + case componentBytesTooLarge(limit: Int, actual: Int) + case duplicateComponent(String) + case overlappingComponent(String) + case bootstrapTooLarge(limit: Int, actual: Int) + case shortBootstrap(minimum: Int, actual: Int) + case invalidBootstrapMagic + case unsupportedBootstrapVersion(UInt16) + case bootstrapLengthMismatch(declared: UInt32, actual: Int) + case truncatedField(String) + case invalidShareRecordLength(UInt32) + case shareRecordLengthMismatch(declared: UInt32, consumed: Int) + case invalidShareFlags(UInt16) + case nonzeroReservedField + case nonCanonicalEncoding + case invalidReceiptShareCount(limit: Int, actual: Int) +} + +/// Daemon-owned identity of the one workspace authorized in a worker process. The all-zero UUID is +/// permanently reserved so an uninitialized launch envelope can never be mistaken for authority. +public struct DoryFSWorkerWorkspaceID: Hashable, Sendable { + public let rawValue: UUID + + public init(rawValue: UUID) throws { + guard rawValue != Self.zeroUUID else { + throw DoryFSWorkerBootstrapError.invalidWorkspaceIdentity + } + self.rawValue = rawValue + } + + public static func random() -> Self { + while true { + if let value = try? Self(rawValue: UUID()) { return value } + } + } + + private static let zeroUUID = UUID( + uuidString: "00000000-0000-0000-0000-000000000000" + )! +} + +/// Descriptor identity sealed by the runner before bootstrap and compared by the worker after XPC +/// transfers the already-open directory descriptor. Generation may legitimately be zero on +/// filesystems that do not expose one; device and inode may not be zero sentinels. +public struct DoryFSPinnedRootIdentity: Equatable, Sendable { + public let device: UInt64 + public let inode: UInt64 + public let generation: UInt64 + + public init(device: UInt64, inode: UInt64, generation: UInt64) throws { + guard device != 0 else { + throw DoryFSWorkerBootstrapError.invalidPinnedRootIdentity(field: "device") + } + guard inode != 0 else { + throw DoryFSWorkerBootstrapError.invalidPinnedRootIdentity(field: "inode") + } + self.device = device + self.inode = inode + self.generation = generation + } +} + +/// Guest-visible ownership policy. UID and GID zero are valid and deliberately have no sentinel +/// meaning: a Linux root-owned view is a normal policy choice, not missing configuration. +public struct DoryFSGuestIdentityPolicy: Equatable, Sendable { + public let uid: UInt32 + public let gid: UInt32 + + public init(uid: UInt32, gid: UInt32) { + self.uid = uid + self.gid = gid + } +} + +/// Host-edit guarantees explicitly granted for one descriptor capability. Internal bootstrap/log +/// mounts opt out; generic guests can request cache invalidation without claiming Linux watcher +/// parity; only guests that prove the Dory event service receive watcher nudges. +public enum DoryFSShareCoherencePolicy: UInt16, Equatable, Sendable { + case disabled = 0 + case invalidationOnly = 1 + case invalidationAndWatcherNudge = 2 +} + +/// Per-share ceilings enforced in addition to the workspace-wide worker limits. These values +/// cover admission memory and every long-lived HostFS resource class, including descriptor +/// headroom that must remain unavailable to guest work. +public struct DoryFSShareResourceLimits: Equatable, Sendable { + public static let absoluteMaximumInFlightRequests = 4_096 + public static let absoluteMaximumAggregateBytes = 8 * 1_024 * 1_024 * 1_024 + public static let absoluteMaximumLiveNonRootNodes = 1_048_576 + public static let absoluteMaximumFileHandles = 262_144 + public static let absoluteMaximumDirectoryHandles = 65_536 + public static let absoluteMaximumDirectoryCursorEntries = 4_194_304 + public static let absoluteMaximumDirectoryCursorNameBytes = 512 * 1_024 * 1_024 + public static let absoluteMaximumAdvisoryLockOwners = 65_536 + public static let absoluteMaximumPendingBlockingLocks = 65_536 + public static let absoluteMaximumReservedFileDescriptorHeadroom = 65_536 + + public let maximumInFlightRequests: Int + public let maximumAggregateRequestBytes: Int + public let maximumAggregateResponseBytes: Int + public let maximumLiveNonRootNodes: Int + public let maximumFileHandles: Int + public let maximumDirectoryHandles: Int + public let maximumDirectoryCursorEntries: Int + public let maximumDirectoryCursorNameBytes: Int + public let maximumAdvisoryLockOwners: Int + public let maximumPendingBlockingLocks: Int + public let reservedFileDescriptorHeadroom: Int + + public init( + maximumInFlightRequests: Int, + maximumAggregateRequestBytes: Int, + maximumAggregateResponseBytes: Int, + maximumLiveNonRootNodes: Int, + maximumFileHandles: Int, + maximumDirectoryHandles: Int, + maximumDirectoryCursorEntries: Int, + maximumDirectoryCursorNameBytes: Int, + maximumAdvisoryLockOwners: Int, + maximumPendingBlockingLocks: Int, + reservedFileDescriptorHeadroom: Int + ) throws { + try Self.require( + maximumInFlightRequests, + atMost: Self.absoluteMaximumInFlightRequests, + field: "maximumInFlightRequests" + ) + try Self.require( + maximumAggregateRequestBytes, + atMost: Self.absoluteMaximumAggregateBytes, + field: "maximumAggregateRequestBytes" + ) + try Self.require( + maximumAggregateResponseBytes, + atMost: Self.absoluteMaximumAggregateBytes, + field: "maximumAggregateResponseBytes" + ) + try Self.require( + maximumLiveNonRootNodes, + atMost: Self.absoluteMaximumLiveNonRootNodes, + field: "maximumLiveNonRootNodes" + ) + try Self.require( + maximumFileHandles, + atMost: Self.absoluteMaximumFileHandles, + field: "maximumFileHandles" + ) + try Self.require( + maximumDirectoryHandles, + atMost: Self.absoluteMaximumDirectoryHandles, + field: "maximumDirectoryHandles" + ) + try Self.require( + maximumDirectoryCursorEntries, + atMost: Self.absoluteMaximumDirectoryCursorEntries, + field: "maximumDirectoryCursorEntries" + ) + try Self.require( + maximumDirectoryCursorNameBytes, + atMost: Self.absoluteMaximumDirectoryCursorNameBytes, + field: "maximumDirectoryCursorNameBytes" + ) + try Self.require( + maximumAdvisoryLockOwners, + atMost: Self.absoluteMaximumAdvisoryLockOwners, + field: "maximumAdvisoryLockOwners" + ) + try Self.require( + maximumPendingBlockingLocks, + atMost: Self.absoluteMaximumPendingBlockingLocks, + field: "maximumPendingBlockingLocks" + ) + try Self.require( + reservedFileDescriptorHeadroom, + atMost: Self.absoluteMaximumReservedFileDescriptorHeadroom, + field: "reservedFileDescriptorHeadroom" + ) + self.maximumInFlightRequests = maximumInFlightRequests + self.maximumAggregateRequestBytes = maximumAggregateRequestBytes + self.maximumAggregateResponseBytes = maximumAggregateResponseBytes + self.maximumLiveNonRootNodes = maximumLiveNonRootNodes + self.maximumFileHandles = maximumFileHandles + self.maximumDirectoryHandles = maximumDirectoryHandles + self.maximumDirectoryCursorEntries = maximumDirectoryCursorEntries + self.maximumDirectoryCursorNameBytes = maximumDirectoryCursorNameBytes + self.maximumAdvisoryLockOwners = maximumAdvisoryLockOwners + self.maximumPendingBlockingLocks = maximumPendingBlockingLocks + self.reservedFileDescriptorHeadroom = reservedFileDescriptorHeadroom + } + + /// Matches the current production HostFS ceilings while leaving explicit non-guest descriptor + /// capacity for the worker channel, pinned roots, event streams, and supervisor plumbing. + public static let production: Self = try! Self( + maximumInFlightRequests: 32, + maximumAggregateRequestBytes: 8 * (40 + 1 * 1_024 * 1_024), + maximumAggregateResponseBytes: 8 * (16 + 1 * 1_024 * 1_024), + maximumLiveNonRootNodes: 65_536, + maximumFileHandles: 16_384, + maximumDirectoryHandles: 4_096, + maximumDirectoryCursorEntries: 262_144, + maximumDirectoryCursorNameBytes: 32 * 1_024 * 1_024, + maximumAdvisoryLockOwners: 4_096, + maximumPendingBlockingLocks: 1_024, + reservedFileDescriptorHeadroom: 256 + ) + + private static func require(_ value: Int, atMost limit: Int, field: String) throws { + guard value > 0, value <= limit else { + throw DoryFSWorkerBootstrapError.invalidShareResourceLimits(field: field) + } + } +} + +/// Complete immutable authority for one share. There is intentionally no host path or guest mount +/// path. `rootDescriptorIndex` binds this record to one descriptor in the XPC bootstrap array; the +/// worker duplicates that descriptor, verifies `expectedRootIdentity`, and labels subsequent +/// traffic solely by `capabilityID`. +public struct DoryFSShareBootstrapAuthority: Equatable, Sendable { + public let capabilityID: DoryFSShareCapabilityID + public let expectedRootIdentity: DoryFSPinnedRootIdentity + public let readOnly: Bool + public let coherencePolicy: DoryFSShareCoherencePolicy + public let guestIdentity: DoryFSGuestIdentityPolicy + public let resourceLimits: DoryFSShareResourceLimits + public let rootDescriptorIndex: UInt16 + public let hiddenComponents: [String] + public let rootHiddenComponents: [String] + + public init( + capabilityID: DoryFSShareCapabilityID, + expectedRootIdentity: DoryFSPinnedRootIdentity, + readOnly: Bool, + coherencePolicy: DoryFSShareCoherencePolicy = .disabled, + guestIdentity: DoryFSGuestIdentityPolicy, + resourceLimits: DoryFSShareResourceLimits, + rootDescriptorIndex: UInt16, + hiddenComponents: [String] = [], + rootHiddenComponents: [String] = [] + ) throws { + guard Int(rootDescriptorIndex) < DoryFSWorkerBootstrapCodec.maximumShares else { + throw DoryFSWorkerBootstrapError.invalidRootDescriptorIndex( + limit: DoryFSWorkerBootstrapCodec.maximumShares, + actual: Int(rootDescriptorIndex) + ) + } + let hidden = try Self.canonicalComponents(hiddenComponents) + let rootHidden = try Self.canonicalComponents(rootHiddenComponents) + let hiddenSet = Set(hidden) + if let overlap = rootHidden.first(where: hiddenSet.contains) { + throw DoryFSWorkerBootstrapError.overlappingComponent(overlap) + } + let encodedComponentBytes = Self.encodedByteCount(hidden) + + Self.encodedByteCount(rootHidden) + guard encodedComponentBytes <= DoryFSWorkerBootstrapCodec.maximumComponentBytesPerShare else { + throw DoryFSWorkerBootstrapError.componentBytesTooLarge( + limit: DoryFSWorkerBootstrapCodec.maximumComponentBytesPerShare, + actual: encodedComponentBytes + ) + } + self.capabilityID = capabilityID + self.expectedRootIdentity = expectedRootIdentity + self.readOnly = readOnly + self.coherencePolicy = coherencePolicy + self.guestIdentity = guestIdentity + self.resourceLimits = resourceLimits + self.rootDescriptorIndex = rootDescriptorIndex + self.hiddenComponents = hidden + self.rootHiddenComponents = rootHidden + } + + private static func canonicalComponents(_ source: [String]) throws -> [String] { + guard source.count <= DoryFSWorkerBootstrapCodec.maximumComponentsPerList else { + throw DoryFSWorkerBootstrapError.tooManyComponents( + limit: DoryFSWorkerBootstrapCodec.maximumComponentsPerList, + actual: source.count + ) + } + var seen = Set() + var result = [String]() + result.reserveCapacity(source.count) + for component in source { + guard !component.isEmpty, + component != ".", + component != "..", + !component.contains("/"), + !component.utf8.contains(0), + component.utf8.count <= DoryFSWorkerBootstrapCodec.maximumComponentBytes else { + throw DoryFSWorkerBootstrapError.invalidComponent(component) + } + // HostFS compares hidden names case-insensitively. Normalize at the authority boundary + // so visually equivalent/case-variant entries have one exact wire spelling. + let canonical = component + .precomposedStringWithCanonicalMapping + .lowercased() + .precomposedStringWithCanonicalMapping + guard !canonical.isEmpty, + canonical.utf8.count <= DoryFSWorkerBootstrapCodec.maximumComponentBytes else { + throw DoryFSWorkerBootstrapError.invalidComponent(component) + } + guard seen.insert(canonical).inserted else { + throw DoryFSWorkerBootstrapError.duplicateComponent(canonical) + } + result.append(canonical) + } + return result.sorted(by: Self.utf8Precedes) + } + + private static func utf8Precedes(_ lhs: String, _ rhs: String) -> Bool { + lhs.utf8.lexicographicallyPrecedes(rhs.utf8) + } + + private static func encodedByteCount(_ values: [String]) -> Int { + values.reduce(into: 0) { total, value in + total += 2 + value.utf8.count + } + } +} + +/// A complete, immutable one-workspace launch envelope. Construction canonicalizes share order; +/// binary decoding additionally requires that the received bytes were already canonical. +public struct DoryFSWorkerBootstrap: Equatable, Sendable { + public let workspaceID: DoryFSWorkerWorkspaceID + public let generation: DoryFSWorkerGeneration + public let workerLimits: DoryFSWorkerLimits + public let shares: [DoryFSShareBootstrapAuthority] + + public init( + workspaceID: DoryFSWorkerWorkspaceID, + generation: DoryFSWorkerGeneration, + workerLimits: DoryFSWorkerLimits, + shares: [DoryFSShareBootstrapAuthority] + ) throws { + guard (1...DoryFSWorkerBootstrapCodec.maximumShares).contains(shares.count) else { + throw DoryFSWorkerBootstrapError.invalidShareCount( + limit: DoryFSWorkerBootstrapCodec.maximumShares, + actual: shares.count + ) + } + try DoryFSWorkerBootstrapCodec.validate(workerLimits: workerLimits) + var capabilities = Set() + var descriptorIndices = Set() + for share in shares { + guard capabilities.insert(share.capabilityID).inserted else { + throw DoryFSWorkerBootstrapError.duplicateShareCapabilityID + } + guard descriptorIndices.insert(share.rootDescriptorIndex).inserted else { + throw DoryFSWorkerBootstrapError.duplicateRootDescriptorIndex + } + guard Int(share.rootDescriptorIndex) < shares.count else { + throw DoryFSWorkerBootstrapError.invalidRootDescriptorIndex( + limit: shares.count, + actual: Int(share.rootDescriptorIndex) + ) + } + guard share.resourceLimits.maximumInFlightRequests + <= workerLimits.maximumInFlightRequests else { + throw DoryFSWorkerBootstrapError.incompatibleShareLimit( + field: "maximumInFlightRequests" + ) + } + guard share.resourceLimits.maximumAggregateRequestBytes + <= workerLimits.maximumAggregateRequestBytes else { + throw DoryFSWorkerBootstrapError.incompatibleShareLimit( + field: "maximumAggregateRequestBytes" + ) + } + guard share.resourceLimits.maximumAggregateResponseBytes + <= workerLimits.maximumAggregateResponseBytes else { + throw DoryFSWorkerBootstrapError.incompatibleShareLimit( + field: "maximumAggregateResponseBytes" + ) + } + } + // Reject an oversized authority set before the encoder allocates its exact wire envelope. + _ = try DoryFSWorkerBootstrapCodec.encodedBootstrapByteCount(shares: shares) + self.workspaceID = workspaceID + self.generation = generation + self.workerLimits = workerLimits + self.shares = shares.sorted { + Self.uuidBytes($0.capabilityID.rawValue) + .lexicographicallyPrecedes(Self.uuidBytes($1.capabilityID.rawValue)) + } + } + + private static func uuidBytes(_ value: UUID) -> [UInt8] { + let raw = value.uuid + return [ + raw.0, raw.1, raw.2, raw.3, raw.4, raw.5, raw.6, raw.7, + raw.8, raw.9, raw.10, raw.11, raw.12, raw.13, raw.14, raw.15, + ] + } +} + +/// Exact receipt returned only after the worker has accepted every share authority. Process IDs +/// are deliberately absent: they are supervisor telemetry, not authorization or receipt identity. +public struct DoryFSWorkerBootstrapReceipt: Equatable, Sendable { + public let workspaceID: DoryFSWorkerWorkspaceID + public let generation: DoryFSWorkerGeneration + public let acceptedShareCount: UInt16 + + public init( + workspaceID: DoryFSWorkerWorkspaceID, + generation: DoryFSWorkerGeneration, + acceptedShareCount: UInt16 + ) throws { + guard (1...DoryFSWorkerBootstrapCodec.maximumShares).contains(Int(acceptedShareCount)) else { + throw DoryFSWorkerBootstrapError.invalidReceiptShareCount( + limit: DoryFSWorkerBootstrapCodec.maximumShares, + actual: Int(acceptedShareCount) + ) + } + self.workspaceID = workspaceID + self.generation = generation + self.acceptedShareCount = acceptedShareCount + } + + public init(accepting bootstrap: DoryFSWorkerBootstrap) { + self.workspaceID = bootstrap.workspaceID + self.generation = bootstrap.generation + self.acceptedShareCount = UInt16(bootstrap.shares.count) + } +} + +/// Exact little-endian version-3 bootstrap and receipt codec. Version 3 makes each descriptor's +/// host-edit guarantee explicit instead of silently treating every mount as watcher-capable. +/// Descriptor-only authority introduced in version 2 remains unchanged. This remains +/// intentionally independent +/// of `Codable`, property lists, keyed archives, and Swift object layout. +public enum DoryFSWorkerBootstrapCodec { + public static let version: UInt16 = 3 + public static let bootstrapHeaderByteCount = 88 + public static let shareRecordHeaderByteCount = 128 + public static let receiptByteCount = 40 + public static let maximumShares = 64 + public static let maximumComponentsPerList = 256 + public static let maximumComponentBytes = 255 + public static let maximumComponentBytesPerShare = 128 * 1_024 + public static let absoluteMaximumBootstrapBytes = 4 * 1_024 * 1_024 + + private static let bootstrapMagic: [UInt8] = [0x44, 0x46, 0x53, 0x42] // DFSB + private static let receiptMagic: [UInt8] = [0x44, 0x46, 0x53, 0x52] // DFSR + private static let absoluteMaximumOperationNanoseconds: UInt64 = 3_600_000_000_000 + + public static func encode(_ bootstrap: DoryFSWorkerBootstrap) throws -> Data { + try validate(workerLimits: bootstrap.workerLimits) + let encodedByteCount = try encodedBootstrapByteCount(shares: bootstrap.shares) + var writer = BootstrapWriter(reservingCapacity: encodedByteCount) + writer.append(bootstrapMagic) + writer.append(version) + writer.append(UInt16(0)) // flags + let totalLengthOffset = writer.count + writer.append(UInt32(0)) + writer.append(UInt16(bootstrap.shares.count)) + writer.append(UInt16(0)) // reserved + writer.append(bootstrap.workspaceID.rawValue) + writer.append(bootstrap.generation.rawValue) + append(bootstrap.workerLimits, to: &writer) + precondition(writer.count == bootstrapHeaderByteCount) + for share in bootstrap.shares { + try append(share, to: &writer) + } + precondition(writer.count == encodedByteCount) + writer.replaceUInt32(at: totalLengthOffset, with: UInt32(writer.count)) + return writer.data + } + + public static func decode(_ data: Data) throws -> DoryFSWorkerBootstrap { + guard data.count <= absoluteMaximumBootstrapBytes else { + throw DoryFSWorkerBootstrapError.bootstrapTooLarge( + limit: absoluteMaximumBootstrapBytes, + actual: data.count + ) + } + guard data.count >= bootstrapHeaderByteCount else { + throw DoryFSWorkerBootstrapError.shortBootstrap( + minimum: bootstrapHeaderByteCount, + actual: data.count + ) + } + var reader = BootstrapReader(data: data) + guard try reader.readBytes(count: 4, field: "magic") == bootstrapMagic else { + throw DoryFSWorkerBootstrapError.invalidBootstrapMagic + } + let decodedVersion = try reader.readUInt16(field: "version") + guard decodedVersion == version else { + throw DoryFSWorkerBootstrapError.unsupportedBootstrapVersion(decodedVersion) + } + guard try reader.readUInt16(field: "flags") == 0 else { + throw DoryFSWorkerBootstrapError.nonzeroReservedField + } + let declaredLength = try reader.readUInt32(field: "totalLength") + guard Int(declaredLength) == data.count else { + throw DoryFSWorkerBootstrapError.bootstrapLengthMismatch( + declared: declaredLength, + actual: data.count + ) + } + let shareCount = Int(try reader.readUInt16(field: "shareCount")) + guard (1...maximumShares).contains(shareCount) else { + throw DoryFSWorkerBootstrapError.invalidShareCount( + limit: maximumShares, + actual: shareCount + ) + } + guard try reader.readUInt16(field: "reserved") == 0 else { + throw DoryFSWorkerBootstrapError.nonzeroReservedField + } + let workspaceID = try DoryFSWorkerWorkspaceID( + rawValue: reader.readUUID(field: "workspaceID") + ) + let generation = try DoryFSWorkerGeneration( + rawValue: reader.readUInt64(field: "generation") + ) + let workerLimits = try readWorkerLimits(from: &reader) + var shares = [DoryFSShareBootstrapAuthority]() + shares.reserveCapacity(shareCount) + for index in 0.. Data { + var writer = BootstrapWriter() + writer.append(receiptMagic) + writer.append(version) + writer.append(UInt16(0)) // flags + writer.append(UInt32(receiptByteCount)) + writer.append(receipt.acceptedShareCount) + writer.append(UInt16(0)) // reserved + writer.append(receipt.workspaceID.rawValue) + writer.append(receipt.generation.rawValue) + precondition(writer.count == receiptByteCount) + return writer.data + } + + public static func decodeReceipt(_ data: Data) throws -> DoryFSWorkerBootstrapReceipt { + guard data.count >= receiptByteCount else { + throw DoryFSWorkerBootstrapError.shortBootstrap( + minimum: receiptByteCount, + actual: data.count + ) + } + guard data.count <= receiptByteCount else { + throw DoryFSWorkerBootstrapError.bootstrapLengthMismatch( + declared: UInt32(receiptByteCount), + actual: data.count + ) + } + var reader = BootstrapReader(data: data) + guard try reader.readBytes(count: 4, field: "receiptMagic") == receiptMagic else { + throw DoryFSWorkerBootstrapError.invalidBootstrapMagic + } + let decodedVersion = try reader.readUInt16(field: "receiptVersion") + guard decodedVersion == version else { + throw DoryFSWorkerBootstrapError.unsupportedBootstrapVersion(decodedVersion) + } + guard try reader.readUInt16(field: "receiptFlags") == 0 else { + throw DoryFSWorkerBootstrapError.nonzeroReservedField + } + let declaredLength = try reader.readUInt32(field: "receiptLength") + guard declaredLength == UInt32(receiptByteCount) else { + throw DoryFSWorkerBootstrapError.bootstrapLengthMismatch( + declared: declaredLength, + actual: data.count + ) + } + let shareCount = try reader.readUInt16(field: "receiptShareCount") + guard try reader.readUInt16(field: "receiptReserved") == 0 else { + throw DoryFSWorkerBootstrapError.nonzeroReservedField + } + let receipt = try DoryFSWorkerBootstrapReceipt( + workspaceID: DoryFSWorkerWorkspaceID( + rawValue: reader.readUUID(field: "receiptWorkspaceID") + ), + generation: DoryFSWorkerGeneration( + rawValue: reader.readUInt64(field: "receiptGeneration") + ), + acceptedShareCount: shareCount + ) + guard reader.isAtEnd, encode(receipt) == data else { + throw DoryFSWorkerBootstrapError.nonCanonicalEncoding + } + return receipt + } + + static func validate(workerLimits: DoryFSWorkerLimits) throws { + guard workerLimits.maximumRequestBytes <= Int(UInt32.max) else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits( + field: "maximumRequestBytes" + ) + } + guard workerLimits.maximumResponseBytes <= Int(UInt32.max) else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits( + field: "maximumResponseBytes" + ) + } + guard workerLimits.maximumFrameBytes <= Int(UInt32.max) else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits(field: "maximumFrameBytes") + } + guard workerLimits.maximumInFlightRequests + <= DoryFSShareResourceLimits.absoluteMaximumInFlightRequests else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits( + field: "maximumInFlightRequests" + ) + } + guard workerLimits.maximumAggregateRequestBytes + <= DoryFSShareResourceLimits.absoluteMaximumAggregateBytes else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits( + field: "maximumAggregateRequestBytes" + ) + } + guard workerLimits.maximumAggregateResponseBytes + <= DoryFSShareResourceLimits.absoluteMaximumAggregateBytes else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits( + field: "maximumAggregateResponseBytes" + ) + } + guard workerLimits.maximumOperationNanoseconds + <= absoluteMaximumOperationNanoseconds else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits( + field: "maximumOperationNanoseconds" + ) + } + guard workerLimits.maximumDrainNanoseconds + <= absoluteMaximumOperationNanoseconds else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits( + field: "maximumDrainNanoseconds" + ) + } + } + + fileprivate static func encodedBootstrapByteCount( + shares: [DoryFSShareBootstrapAuthority] + ) throws -> Int { + var total = bootstrapHeaderByteCount + for share in shares { + let componentBytes = encodedComponentBytes(share.hiddenComponents) + + encodedComponentBytes(share.rootHiddenComponents) + let (recordBytes, recordOverflow) = shareRecordHeaderByteCount + .addingReportingOverflow(componentBytes) + let (nextTotal, totalOverflow) = total.addingReportingOverflow(recordBytes) + guard !recordOverflow, !totalOverflow, + nextTotal <= absoluteMaximumBootstrapBytes else { + throw DoryFSWorkerBootstrapError.bootstrapTooLarge( + limit: absoluteMaximumBootstrapBytes, + actual: recordOverflow || totalOverflow + ? Int.max + : nextTotal + ) + } + total = nextTotal + } + return total + } + + private static func append(_ limits: DoryFSWorkerLimits, to writer: inout BootstrapWriter) { + writer.append(UInt32(limits.maximumRequestBytes)) + writer.append(UInt32(limits.maximumResponseBytes)) + writer.append(UInt32(limits.maximumFrameBytes)) + writer.append(UInt32(limits.maximumInFlightRequests)) + writer.append(UInt64(limits.maximumAggregateRequestBytes)) + writer.append(UInt64(limits.maximumAggregateResponseBytes)) + writer.append(limits.maximumOperationNanoseconds) + writer.append(limits.maximumDrainNanoseconds) + } + + private static func append( + _ share: DoryFSShareBootstrapAuthority, + to writer: inout BootstrapWriter + ) throws { + let recordStart = writer.count + writer.append(UInt32(0)) + let flags = UInt16(share.readOnly ? 1 : 0) | (share.coherencePolicy.rawValue << 1) + writer.append(flags) + writer.append(UInt16(0)) // reserved + writer.append(share.capabilityID.rawValue) + writer.append(share.expectedRootIdentity.device) + writer.append(share.expectedRootIdentity.inode) + writer.append(share.expectedRootIdentity.generation) + writer.append(share.guestIdentity.uid) + writer.append(share.guestIdentity.gid) + let limits = share.resourceLimits + writer.append(UInt32(limits.maximumInFlightRequests)) + writer.append(UInt64(limits.maximumAggregateRequestBytes)) + writer.append(UInt64(limits.maximumAggregateResponseBytes)) + writer.append(UInt32(limits.maximumLiveNonRootNodes)) + writer.append(UInt32(limits.maximumFileHandles)) + writer.append(UInt32(limits.maximumDirectoryHandles)) + writer.append(UInt32(limits.maximumDirectoryCursorEntries)) + writer.append(UInt64(limits.maximumDirectoryCursorNameBytes)) + writer.append(UInt32(limits.maximumAdvisoryLockOwners)) + writer.append(UInt32(limits.maximumPendingBlockingLocks)) + writer.append(UInt32(limits.reservedFileDescriptorHeadroom)) + writer.append(share.rootDescriptorIndex) + writer.append(UInt16(0)) // reserved descriptor transport flags + writer.append(UInt16(share.hiddenComponents.count)) + writer.append(UInt16(share.rootHiddenComponents.count)) + let componentByteCount = encodedComponentBytes(share.hiddenComponents) + + encodedComponentBytes(share.rootHiddenComponents) + writer.append(UInt32(componentByteCount)) + writer.append(UInt32(0)) // reserved + precondition(writer.count - recordStart == shareRecordHeaderByteCount) + for component in share.hiddenComponents + share.rootHiddenComponents { + let bytes = Array(component.utf8) + writer.append(UInt16(bytes.count)) + writer.append(bytes) + } + let recordLength = writer.count - recordStart + guard recordLength <= Int(UInt32.max) else { + throw DoryFSWorkerBootstrapError.bootstrapTooLarge( + limit: absoluteMaximumBootstrapBytes, + actual: writer.count + ) + } + writer.replaceUInt32(at: recordStart, with: UInt32(recordLength)) + } + + private static func readWorkerLimits( + from reader: inout BootstrapReader + ) throws -> DoryFSWorkerLimits { + let request = try reader.readUInt32(field: "maximumRequestBytes") + let response = try reader.readUInt32(field: "maximumResponseBytes") + let frame = try reader.readUInt32(field: "maximumFrameBytes") + let inFlight = try reader.readUInt32(field: "maximumInFlightRequests") + let aggregateRequest = try reader.readUInt64(field: "maximumAggregateRequestBytes") + let aggregateResponse = try reader.readUInt64(field: "maximumAggregateResponseBytes") + let operation = try reader.readUInt64(field: "maximumOperationNanoseconds") + let drain = try reader.readUInt64(field: "maximumDrainNanoseconds") + guard aggregateRequest <= UInt64(Int.max), aggregateResponse <= UInt64(Int.max) else { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits(field: "aggregateBytes") + } + do { + let limits = try DoryFSWorkerLimits( + maximumRequestBytes: Int(request), + maximumResponseBytes: Int(response), + maximumFrameBytes: Int(frame), + maximumInFlightRequests: Int(inFlight), + maximumAggregateRequestBytes: Int(aggregateRequest), + maximumAggregateResponseBytes: Int(aggregateResponse), + maximumOperationNanoseconds: operation, + maximumDrainNanoseconds: drain + ) + try validate(workerLimits: limits) + return limits + } catch let error as DoryFSWorkerBootstrapError { + throw error + } catch let error as DoryFSWorkerContractError { + if case .invalidLimits(let field) = error { + throw DoryFSWorkerBootstrapError.invalidWorkerLimits(field: field) + } + throw DoryFSWorkerBootstrapError.invalidWorkerLimits(field: "unknown") + } + } + + private static func readShare( + from reader: inout BootstrapReader, + index: Int + ) throws -> DoryFSShareBootstrapAuthority { + let recordStart = reader.offset + let recordLength = try reader.readUInt32(field: "share[\(index)].recordLength") + guard recordLength >= UInt32(shareRecordHeaderByteCount), + Int(recordLength) <= reader.remaining + 4 else { + throw DoryFSWorkerBootstrapError.invalidShareRecordLength(recordLength) + } + let recordEnd = recordStart + Int(recordLength) + reader.pushLimit(recordEnd) + defer { reader.popLimit() } + let flags = try reader.readUInt16(field: "share[\(index)].flags") + guard flags & ~UInt16(0b111) == 0, + let coherencePolicy = DoryFSShareCoherencePolicy(rawValue: (flags >> 1) & 0b11) + else { + throw DoryFSWorkerBootstrapError.invalidShareFlags(flags) + } + guard try reader.readUInt16(field: "share[\(index)].reserved") == 0 else { + throw DoryFSWorkerBootstrapError.nonzeroReservedField + } + let capability = try DoryFSShareCapabilityID( + rawValue: reader.readUUID(field: "share[\(index)].capabilityID") + ) + let root = try DoryFSPinnedRootIdentity( + device: reader.readUInt64(field: "share[\(index)].device"), + inode: reader.readUInt64(field: "share[\(index)].inode"), + generation: reader.readUInt64(field: "share[\(index)].rootGeneration") + ) + let guest = DoryFSGuestIdentityPolicy( + uid: try reader.readUInt32(field: "share[\(index)].guestUID"), + gid: try reader.readUInt32(field: "share[\(index)].guestGID") + ) + let limits = try readShareLimits(from: &reader, index: index) + let rootDescriptorIndex = try reader.readUInt16( + field: "share[\(index)].rootDescriptorIndex" + ) + guard try reader.readUInt16(field: "share[\(index)].descriptorReserved") == 0 else { + throw DoryFSWorkerBootstrapError.nonzeroReservedField + } + let hiddenCount = Int( + try reader.readUInt16(field: "share[\(index)].hiddenCount") + ) + let rootHiddenCount = Int( + try reader.readUInt16(field: "share[\(index)].rootHiddenCount") + ) + guard hiddenCount <= maximumComponentsPerList else { + throw DoryFSWorkerBootstrapError.tooManyComponents( + limit: maximumComponentsPerList, + actual: hiddenCount + ) + } + guard rootHiddenCount <= maximumComponentsPerList else { + throw DoryFSWorkerBootstrapError.tooManyComponents( + limit: maximumComponentsPerList, + actual: rootHiddenCount + ) + } + let declaredComponentBytes = Int( + try reader.readUInt32(field: "share[\(index)].componentBytes") + ) + guard declaredComponentBytes <= maximumComponentBytesPerShare else { + throw DoryFSWorkerBootstrapError.componentBytesTooLarge( + limit: maximumComponentBytesPerShare, + actual: declaredComponentBytes + ) + } + guard try reader.readUInt32(field: "share[\(index)].reserved2") == 0 else { + throw DoryFSWorkerBootstrapError.nonzeroReservedField + } + let componentStart = reader.offset + let hidden = try readComponents(count: hiddenCount, from: &reader, index: index) + let rootHidden = try readComponents( + count: rootHiddenCount, + from: &reader, + index: index + ) + let consumedComponentBytes = reader.offset - componentStart + guard consumedComponentBytes == declaredComponentBytes, + reader.offset == recordEnd else { + throw DoryFSWorkerBootstrapError.shareRecordLengthMismatch( + declared: recordLength, + consumed: reader.offset - recordStart + ) + } + return try DoryFSShareBootstrapAuthority( + capabilityID: capability, + expectedRootIdentity: root, + readOnly: flags & 1 == 1, + coherencePolicy: coherencePolicy, + guestIdentity: guest, + resourceLimits: limits, + rootDescriptorIndex: rootDescriptorIndex, + hiddenComponents: hidden, + rootHiddenComponents: rootHidden + ) + } + + private static func readShareLimits( + from reader: inout BootstrapReader, + index: Int + ) throws -> DoryFSShareResourceLimits { + let inFlight = try reader.readUInt32(field: "share[\(index)].maximumInFlightRequests") + let aggregateRequest = try reader.readUInt64( + field: "share[\(index)].maximumAggregateRequestBytes" + ) + let aggregateResponse = try reader.readUInt64( + field: "share[\(index)].maximumAggregateResponseBytes" + ) + let nodes = try reader.readUInt32(field: "share[\(index)].maximumLiveNonRootNodes") + let fileHandles = try reader.readUInt32(field: "share[\(index)].maximumFileHandles") + let directoryHandles = try reader.readUInt32( + field: "share[\(index)].maximumDirectoryHandles" + ) + let cursorEntries = try reader.readUInt32( + field: "share[\(index)].maximumDirectoryCursorEntries" + ) + let cursorNameBytes = try reader.readUInt64( + field: "share[\(index)].maximumDirectoryCursorNameBytes" + ) + let lockOwners = try reader.readUInt32( + field: "share[\(index)].maximumAdvisoryLockOwners" + ) + let blockingLocks = try reader.readUInt32( + field: "share[\(index)].maximumPendingBlockingLocks" + ) + let descriptorHeadroom = try reader.readUInt32( + field: "share[\(index)].reservedFileDescriptorHeadroom" + ) + let values = [aggregateRequest, aggregateResponse, cursorNameBytes] + guard values.allSatisfy({ $0 <= UInt64(Int.max) }) else { + throw DoryFSWorkerBootstrapError.invalidShareResourceLimits( + field: "wideInteger" + ) + } + return try DoryFSShareResourceLimits( + maximumInFlightRequests: Int(inFlight), + maximumAggregateRequestBytes: Int(aggregateRequest), + maximumAggregateResponseBytes: Int(aggregateResponse), + maximumLiveNonRootNodes: Int(nodes), + maximumFileHandles: Int(fileHandles), + maximumDirectoryHandles: Int(directoryHandles), + maximumDirectoryCursorEntries: Int(cursorEntries), + maximumDirectoryCursorNameBytes: Int(cursorNameBytes), + maximumAdvisoryLockOwners: Int(lockOwners), + maximumPendingBlockingLocks: Int(blockingLocks), + reservedFileDescriptorHeadroom: Int(descriptorHeadroom) + ) + } + + private static func readComponents( + count: Int, + from reader: inout BootstrapReader, + index: Int + ) throws -> [String] { + var result = [String]() + result.reserveCapacity(count) + for componentIndex in 0..") + } + let bytes = try reader.readBytes( + count: length, + field: "share[\(index)].component[\(componentIndex)]" + ) + guard let component = String(bytes: bytes, encoding: .utf8) else { + throw DoryFSWorkerBootstrapError.invalidComponent("") + } + result.append(component) + } + return result + } + + private static func encodedComponentBytes(_ values: [String]) -> Int { + values.reduce(into: 0) { total, value in total += 2 + value.utf8.count } + } +} + +private struct BootstrapWriter { + private(set) var bytes: [UInt8] + + init(reservingCapacity: Int = 0) { + bytes = [] + bytes.reserveCapacity(reservingCapacity) + } + + var count: Int { bytes.count } + var data: Data { Data(bytes) } + + mutating func append(_ values: [UInt8]) { + bytes.append(contentsOf: values) + } + + mutating func append(_ value: UInt16) { + bytes.append(UInt8(truncatingIfNeeded: value)) + bytes.append(UInt8(truncatingIfNeeded: value >> 8)) + } + + mutating func append(_ value: UInt32) { + for shift in stride(from: 0, through: 24, by: 8) { + bytes.append(UInt8(truncatingIfNeeded: value >> UInt32(shift))) + } + } + + mutating func append(_ value: UInt64) { + for shift in stride(from: 0, through: 56, by: 8) { + bytes.append(UInt8(truncatingIfNeeded: value >> UInt64(shift))) + } + } + + mutating func append(_ value: UUID) { + let raw = value.uuid + append([ + raw.0, raw.1, raw.2, raw.3, raw.4, raw.5, raw.6, raw.7, + raw.8, raw.9, raw.10, raw.11, raw.12, raw.13, raw.14, raw.15, + ]) + } + + mutating func replaceUInt32(at offset: Int, with value: UInt32) { + for index in 0..<4 { + bytes[offset + index] = UInt8(truncatingIfNeeded: value >> UInt32(index * 8)) + } + } +} + +private struct BootstrapReader { + private let bytes: [UInt8] + private var limits: [Int] + private(set) var offset = 0 + + init(data: Data) { + self.bytes = [UInt8](data) + self.limits = [data.count] + } + + var remaining: Int { limits.last! - offset } + var isAtEnd: Bool { offset == limits.last! } + + mutating func pushLimit(_ value: Int) { + precondition(value >= offset && value <= limits.last!) + limits.append(value) + } + + mutating func popLimit() { + precondition(limits.count > 1) + limits.removeLast() + } + + mutating func readBytes(count: Int, field: String) throws -> [UInt8] { + guard count >= 0, count <= remaining else { + throw DoryFSWorkerBootstrapError.truncatedField(field) + } + let start = offset + offset += count + return Array(bytes[start.. UInt16 { + let value = try readBytes(count: 2, field: field) + return UInt16(value[0]) | (UInt16(value[1]) << 8) + } + + mutating func readUInt32(field: String) throws -> UInt32 { + let value = try readBytes(count: 4, field: field) + var result: UInt32 = 0 + for index in 0..<4 { + result |= UInt32(value[index]) << UInt32(index * 8) + } + return result + } + + mutating func readUInt64(field: String) throws -> UInt64 { + let value = try readBytes(count: 8, field: field) + var result: UInt64 = 0 + for index in 0..<8 { + result |= UInt64(value[index]) << UInt64(index * 8) + } + return result + } + + mutating func readUUID(field: String) throws -> UUID { + let value = try readBytes(count: 16, field: field) + return UUID(uuid: ( + value[0], value[1], value[2], value[3], + value[4], value[5], value[6], value[7], + value[8], value[9], value[10], value[11], + value[12], value[13], value[14], value[15] + )) + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerCoherence.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerCoherence.swift new file mode 100644 index 00000000..66a88e02 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerCoherence.swift @@ -0,0 +1,712 @@ +import Foundation + +/// One reverse invalidation planned by the filesystem worker from its pinned HostFS namespace. +/// The wire value contains only Linux FUSE identities and individual entry names; it never carries +/// a host path, share tag, descriptor, or guest-controlled authority selector. +public enum DoryFSWorkerCoherenceInvalidation: Equatable, Sendable { + case inode(nodeID: UInt64, offset: Int64, length: Int64) + case entry(parentNodeID: UInt64, name: String, flags: UInt32) + case delete(parentNodeID: UInt64, childNodeID: UInt64, name: String) +} + +/// A retained, replayable host-edit operation for exactly one worker generation and share +/// capability. The worker must retain the exact encoded bytes until it receives a matching ACK. +public struct DoryFSWorkerCoherenceBatch: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let shareCapabilityID: DoryFSShareCapabilityID + public let batchID: UInt64 + public let invalidations: [DoryFSWorkerCoherenceInvalidation] + /// Canonical paths relative to this capability's pinned root. The runner alone maps them onto + /// the corresponding guest mount; an absolute host or guest path cannot cross this contract. + public let nudgeRelativePaths: [String] + + public init( + generation: DoryFSWorkerGeneration, + shareCapabilityID: DoryFSShareCapabilityID, + batchID: UInt64, + invalidations: [DoryFSWorkerCoherenceInvalidation], + nudgeRelativePaths: [String] + ) throws { + guard batchID != 0 else { throw DoryFSWorkerCoherenceCodecError.invalidBatchID } + guard !invalidations.isEmpty || !nudgeRelativePaths.isEmpty else { + throw DoryFSWorkerCoherenceCodecError.emptyBatch + } + guard invalidations.count <= DoryFSWorkerCoherenceCodec.maximumInvalidations else { + throw DoryFSWorkerCoherenceCodecError.tooManyInvalidations( + limit: DoryFSWorkerCoherenceCodec.maximumInvalidations, + actual: invalidations.count + ) + } + guard nudgeRelativePaths.count <= DoryFSWorkerCoherenceCodec.maximumNudgePaths else { + throw DoryFSWorkerCoherenceCodecError.tooManyNudgePaths( + limit: DoryFSWorkerCoherenceCodec.maximumNudgePaths, + actual: nudgeRelativePaths.count + ) + } + var previousInvalidationKey: String? + for invalidation in invalidations { + switch invalidation { + case .inode(let nodeID, let offset, let length): + guard nodeID != 0 else { + throw DoryFSWorkerCoherenceCodecError.invalidNodeID + } + guard (offset == -1 && length == 0) || (offset == 0 && length == -1) else { + throw DoryFSWorkerCoherenceCodecError.invalidInvalidationRange + } + case .entry(let parentNodeID, let name, let flags): + guard parentNodeID != 0 else { + throw DoryFSWorkerCoherenceCodecError.invalidNodeID + } + guard flags == 0 else { + throw DoryFSWorkerCoherenceCodecError.invalidEntryFlags(flags) + } + try DoryFSWorkerCoherenceCodec.validateEntryName(name) + case .delete(let parentNodeID, let childNodeID, let name): + guard parentNodeID != 0, childNodeID != 0 else { + throw DoryFSWorkerCoherenceCodecError.invalidNodeID + } + try DoryFSWorkerCoherenceCodec.validateEntryName(name) + } + let key = DoryFSWorkerCoherenceCodec.canonicalKey(for: invalidation) + guard previousInvalidationKey.map({ $0 < key }) ?? true else { + throw previousInvalidationKey == key + ? DoryFSWorkerCoherenceCodecError.duplicateInvalidation + : DoryFSWorkerCoherenceCodecError.nonCanonicalOrdering + } + previousInvalidationKey = key + } + var previousNudgePath: String? + for path in nudgeRelativePaths { + try DoryFSWorkerCoherenceCodec.validateRelativePath(path) + guard previousNudgePath.map({ $0 < path }) ?? true else { + throw previousNudgePath == path + ? DoryFSWorkerCoherenceCodecError.duplicateNudgePath + : DoryFSWorkerCoherenceCodecError.nonCanonicalOrdering + } + previousNudgePath = path + } + self.generation = generation + self.shareCapabilityID = shareCapabilityID + self.batchID = batchID + self.invalidations = invalidations + self.nudgeRelativePaths = nudgeRelativePaths + } +} + +public struct DoryFSWorkerCoherenceAcknowledgement: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let shareCapabilityID: DoryFSShareCapabilityID + public let batchID: UInt64 + + public init( + generation: DoryFSWorkerGeneration, + shareCapabilityID: DoryFSShareCapabilityID, + batchID: UInt64 + ) throws { + guard batchID != 0 else { throw DoryFSWorkerCoherenceCodecError.invalidBatchID } + self.generation = generation + self.shareCapabilityID = shareCapabilityID + self.batchID = batchID + } + + public init(accepting batch: DoryFSWorkerCoherenceBatch) throws { + try self.init( + generation: batch.generation, + shareCapabilityID: batch.shareCapabilityID, + batchID: batch.batchID + ) + } +} + +/// Bounded, path-free worker observation state used by the runner's five-second resource report. +/// Counts describe only capabilities and queues; capability UUIDs, tags, and host paths never +/// enter telemetry. +public struct DoryFSWorkerCoherenceStatus: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let running: Bool + public let configuredShareCount: UInt32 + public let invalidationOnlyShareCount: UInt32 + public let watcherNudgeShareCount: UInt32 + public let requiredObservationShareCount: UInt32 + public let observedRequiredShareCount: UInt32 + public let observationStreamCount: UInt32 + public let pendingEventCount: UInt32 + public let pendingEventLimit: UInt32 + public let receivedEventCount: UInt64 + public let deliveredBatchCount: UInt64 + public let failedBatchCount: UInt64 + public let eventLossCount: UInt64 + + public init( + generation: DoryFSWorkerGeneration, + running: Bool, + configuredShareCount: UInt32, + invalidationOnlyShareCount: UInt32, + watcherNudgeShareCount: UInt32, + requiredObservationShareCount: UInt32, + observedRequiredShareCount: UInt32, + observationStreamCount: UInt32, + pendingEventCount: UInt32, + pendingEventLimit: UInt32, + receivedEventCount: UInt64, + deliveredBatchCount: UInt64, + failedBatchCount: UInt64, + eventLossCount: UInt64 + ) throws { + guard configuredShareCount <= UInt32(DoryFSWorkerBootstrapCodec.maximumShares), + UInt64(invalidationOnlyShareCount) + UInt64(watcherNudgeShareCount) + == UInt64(configuredShareCount), + requiredObservationShareCount <= configuredShareCount, + observedRequiredShareCount <= requiredObservationShareCount, + observationStreamCount >= observedRequiredShareCount, + pendingEventCount <= pendingEventLimit, + pendingEventLimit <= 1_048_576 else { + throw DoryFSWorkerCoherenceStatusCodecError.invalidCounts + } + self.generation = generation + self.running = running + self.configuredShareCount = configuredShareCount + self.invalidationOnlyShareCount = invalidationOnlyShareCount + self.watcherNudgeShareCount = watcherNudgeShareCount + self.requiredObservationShareCount = requiredObservationShareCount + self.observedRequiredShareCount = observedRequiredShareCount + self.observationStreamCount = observationStreamCount + self.pendingEventCount = pendingEventCount + self.pendingEventLimit = pendingEventLimit + self.receivedEventCount = receivedEventCount + self.deliveredBatchCount = deliveredBatchCount + self.failedBatchCount = failedBatchCount + self.eventLossCount = eventLossCount + } +} + +public enum DoryFSWorkerCoherenceStatusCodecError: Error, Equatable, Sendable { + case invalidLength + case invalidMagic + case unsupportedVersion(UInt16) + case invalidFlags(UInt16) + case nonzeroReservedField + case invalidGeneration + case invalidCounts + case nonCanonicalEncoding +} + +public enum DoryFSWorkerCoherenceStatusCodec { + public static let byteCount = 96 + private static let magic: [UInt8] = [0x44, 0x46, 0x43, 0x53] // DFCS + private static let version: UInt16 = 1 + + public static func encode(_ status: DoryFSWorkerCoherenceStatus) -> Data { + var bytes = [UInt8]() + bytes.reserveCapacity(byteCount) + bytes.append(contentsOf: magic) + bytes.appendLE(version) + bytes.appendLE(UInt16(status.running ? 1 : 0)) + bytes.appendLE(UInt32(byteCount)) + bytes.appendLE(UInt32(0)) + bytes.appendLE(status.generation.rawValue) + bytes.appendLE(status.configuredShareCount) + bytes.appendLE(status.invalidationOnlyShareCount) + bytes.appendLE(status.watcherNudgeShareCount) + bytes.appendLE(status.requiredObservationShareCount) + bytes.appendLE(status.observedRequiredShareCount) + bytes.appendLE(status.observationStreamCount) + bytes.appendLE(status.pendingEventCount) + bytes.appendLE(status.pendingEventLimit) + bytes.appendLE(status.receivedEventCount) + bytes.appendLE(status.deliveredBatchCount) + bytes.appendLE(status.failedBatchCount) + bytes.appendLE(status.eventLossCount) + bytes.appendLE(UInt64(0)) + precondition(bytes.count == byteCount) + return Data(bytes) + } + + public static func decode(_ data: Data) throws -> DoryFSWorkerCoherenceStatus { + let bytes = [UInt8](data) + guard bytes.count == byteCount, + bytes.leUInt32(at: 8) == UInt32(byteCount) else { + throw DoryFSWorkerCoherenceStatusCodecError.invalidLength + } + guard Array(bytes[0..<4]) == magic else { + throw DoryFSWorkerCoherenceStatusCodecError.invalidMagic + } + guard bytes.leUInt16(at: 4) == version else { + throw DoryFSWorkerCoherenceStatusCodecError.unsupportedVersion( + bytes.leUInt16(at: 4) + ) + } + let flags = bytes.leUInt16(at: 6) + guard flags & ~UInt16(1) == 0 else { + throw DoryFSWorkerCoherenceStatusCodecError.invalidFlags(flags) + } + guard bytes.leUInt32(at: 12) == 0, bytes.leUInt64(at: 88) == 0 else { + throw DoryFSWorkerCoherenceStatusCodecError.nonzeroReservedField + } + guard let generation = try? DoryFSWorkerGeneration( + rawValue: bytes.leUInt64(at: 16) + ) else { + throw DoryFSWorkerCoherenceStatusCodecError.invalidGeneration + } + let status: DoryFSWorkerCoherenceStatus + do { + status = try DoryFSWorkerCoherenceStatus( + generation: generation, + running: flags & 1 == 1, + configuredShareCount: bytes.leUInt32(at: 24), + invalidationOnlyShareCount: bytes.leUInt32(at: 28), + watcherNudgeShareCount: bytes.leUInt32(at: 32), + requiredObservationShareCount: bytes.leUInt32(at: 36), + observedRequiredShareCount: bytes.leUInt32(at: 40), + observationStreamCount: bytes.leUInt32(at: 44), + pendingEventCount: bytes.leUInt32(at: 48), + pendingEventLimit: bytes.leUInt32(at: 52), + receivedEventCount: bytes.leUInt64(at: 56), + deliveredBatchCount: bytes.leUInt64(at: 64), + failedBatchCount: bytes.leUInt64(at: 72), + eventLossCount: bytes.leUInt64(at: 80) + ) + } catch { + throw DoryFSWorkerCoherenceStatusCodecError.invalidCounts + } + guard encode(status) == data else { + throw DoryFSWorkerCoherenceStatusCodecError.nonCanonicalEncoding + } + return status + } +} + +public enum DoryFSWorkerCoherenceCodecError: Error, Equatable, Sendable { + case frameTooLarge(limit: Int, actual: Int) + case shortFrame(minimum: Int, actual: Int) + case invalidMagic + case unsupportedVersion(UInt16) + case unknownFrameKind(UInt8) + case nonzeroReservedField + case frameLengthMismatch(declared: UInt32, actual: Int) + case invalidGeneration + case invalidCapabilityID + case invalidBatchID + case invalidNodeID + case invalidInvalidationRange + case invalidEntryFlags(UInt32) + case duplicateInvalidation + case duplicateNudgePath + case nonCanonicalOrdering + case emptyBatch + case tooManyInvalidations(limit: Int, actual: Int) + case tooManyNudgePaths(limit: Int, actual: Int) + case invalidInvalidationKind(UInt8) + case invalidEntryName(String) + case invalidRelativePath(String) + case truncatedField(String) + case trailingBytes + case nonCanonicalEncoding +} + +/// Exact bounded binary framing for worker-to-runner coherence and runner-to-worker ACKs. +/// Unknown kinds, flags, reserved bytes, non-canonical strings, and trailing bytes fail closed. +public enum DoryFSWorkerCoherenceCodec { + public static let maximumFrameBytes = 256 * 1_024 + public static let maximumInvalidations = 1_024 + public static let maximumNudgePaths = 512 + public static let maximumEntryNameBytes = 255 + public static let maximumRelativePathBytes = 4_095 + + private static let version: UInt16 = 1 + private static let batchMagic: [UInt8] = [0x44, 0x46, 0x43, 0x31] // DFC1 + private static let acknowledgementMagic: [UInt8] = [0x44, 0x46, 0x43, 0x41] // DFCA + private static let batchKind: UInt8 = 1 + private static let acknowledgementKind: UInt8 = 2 + private static let batchHeaderBytes = 64 + private static let invalidationHeaderBytes = 32 + private static let acknowledgementBytes = 48 + + public static func encode(_ batch: DoryFSWorkerCoherenceBatch) throws -> Data { + var bytes = [UInt8]() + bytes.reserveCapacity(min(maximumFrameBytes, batchHeaderBytes + batch.invalidations.count * 32)) + bytes.append(contentsOf: batchMagic) + bytes.appendLE(version) + bytes.append(batchKind) + bytes.append(0) + bytes.appendLE(UInt32(0)) // patched after the complete bounded frame is assembled + bytes.appendLE(UInt32(0)) + bytes.appendLE(batch.generation.rawValue) + append(batch.shareCapabilityID.rawValue, to: &bytes) + bytes.appendLE(batch.batchID) + bytes.appendLE(UInt32(batch.invalidations.count)) + bytes.appendLE(UInt32(batch.nudgeRelativePaths.count)) + bytes.appendLE(UInt64(0)) + precondition(bytes.count == batchHeaderBytes) + + for invalidation in batch.invalidations { + switch invalidation { + case .inode(let nodeID, let offset, let length): + appendInvalidationHeader( + kind: 1, + nameByteCount: 0, + first: nodeID, + second: UInt64(bitPattern: offset), + third: UInt64(bitPattern: length), + to: &bytes + ) + case .entry(let parentNodeID, let name, let flags): + try validateEntryName(name) + let nameBytes = Array(name.utf8) + appendInvalidationHeader( + kind: 2, + nameByteCount: nameBytes.count, + first: parentNodeID, + second: 0, + third: UInt64(flags), + to: &bytes + ) + bytes.append(contentsOf: nameBytes) + case .delete(let parentNodeID, let childNodeID, let name): + try validateEntryName(name) + let nameBytes = Array(name.utf8) + appendInvalidationHeader( + kind: 3, + nameByteCount: nameBytes.count, + first: parentNodeID, + second: childNodeID, + third: 0, + to: &bytes + ) + bytes.append(contentsOf: nameBytes) + } + guard bytes.count <= maximumFrameBytes else { + throw DoryFSWorkerCoherenceCodecError.frameTooLarge( + limit: maximumFrameBytes, + actual: bytes.count + ) + } + } + for path in batch.nudgeRelativePaths { + try validateRelativePath(path) + let pathBytes = Array(path.utf8) + bytes.appendLE(UInt32(pathBytes.count)) + bytes.append(contentsOf: pathBytes) + guard bytes.count <= maximumFrameBytes else { + throw DoryFSWorkerCoherenceCodecError.frameTooLarge( + limit: maximumFrameBytes, + actual: bytes.count + ) + } + } + guard let length = UInt32(exactly: bytes.count) else { + throw DoryFSWorkerCoherenceCodecError.frameTooLarge( + limit: maximumFrameBytes, + actual: bytes.count + ) + } + patchUInt32(length, at: 8, in: &bytes) + return Data(bytes) + } + + public static func decodeBatch(_ data: Data) throws -> DoryFSWorkerCoherenceBatch { + let bytes = [UInt8](data) + guard bytes.count <= maximumFrameBytes else { + throw DoryFSWorkerCoherenceCodecError.frameTooLarge( + limit: maximumFrameBytes, + actual: bytes.count + ) + } + guard bytes.count >= batchHeaderBytes else { + throw DoryFSWorkerCoherenceCodecError.shortFrame( + minimum: batchHeaderBytes, + actual: bytes.count + ) + } + guard Array(bytes[0..<4]) == batchMagic else { + throw DoryFSWorkerCoherenceCodecError.invalidMagic + } + guard bytes.leUInt16(at: 4) == version else { + throw DoryFSWorkerCoherenceCodecError.unsupportedVersion(bytes.leUInt16(at: 4)) + } + guard bytes[6] == batchKind else { + throw DoryFSWorkerCoherenceCodecError.unknownFrameKind(bytes[6]) + } + guard bytes[7] == 0, bytes.leUInt32(at: 12) == 0, bytes.leUInt64(at: 56) == 0 else { + throw DoryFSWorkerCoherenceCodecError.nonzeroReservedField + } + let declaredLength = bytes.leUInt32(at: 8) + guard UInt64(declaredLength) == UInt64(bytes.count) else { + throw DoryFSWorkerCoherenceCodecError.frameLengthMismatch( + declared: declaredLength, + actual: bytes.count + ) + } + guard let generation = try? DoryFSWorkerGeneration(rawValue: bytes.leUInt64(at: 16)) else { + throw DoryFSWorkerCoherenceCodecError.invalidGeneration + } + guard let capability = try? DoryFSShareCapabilityID(rawValue: readUUID(bytes, at: 24)) else { + throw DoryFSWorkerCoherenceCodecError.invalidCapabilityID + } + let batchID = bytes.leUInt64(at: 40) + guard batchID != 0 else { throw DoryFSWorkerCoherenceCodecError.invalidBatchID } + let invalidationCount = Int(bytes.leUInt32(at: 48)) + let nudgeCount = Int(bytes.leUInt32(at: 52)) + guard invalidationCount <= maximumInvalidations else { + throw DoryFSWorkerCoherenceCodecError.tooManyInvalidations( + limit: maximumInvalidations, + actual: invalidationCount + ) + } + guard nudgeCount <= maximumNudgePaths else { + throw DoryFSWorkerCoherenceCodecError.tooManyNudgePaths( + limit: maximumNudgePaths, + actual: nudgeCount + ) + } + + var cursor = batchHeaderBytes + var invalidations = [DoryFSWorkerCoherenceInvalidation]() + invalidations.reserveCapacity(invalidationCount) + for _ in 0..") + } + switch kind { + case 1: + guard nameLength == 0, first != 0 else { + throw DoryFSWorkerCoherenceCodecError.nonCanonicalEncoding + } + invalidations.append(.inode( + nodeID: first, + offset: Int64(bitPattern: second), + length: Int64(bitPattern: third) + )) + case 2: + guard first != 0, second == 0, third <= UInt64(UInt32.max) else { + throw DoryFSWorkerCoherenceCodecError.nonCanonicalEncoding + } + try validateEntryName(name) + invalidations.append(.entry( + parentNodeID: first, + name: name, + flags: UInt32(third) + )) + case 3: + guard first != 0, second != 0, third == 0 else { + throw DoryFSWorkerCoherenceCodecError.nonCanonicalEncoding + } + try validateEntryName(name) + invalidations.append(.delete( + parentNodeID: first, + childNodeID: second, + name: name + )) + default: + throw DoryFSWorkerCoherenceCodecError.invalidInvalidationKind(kind) + } + } + + var nudges = [String]() + nudges.reserveCapacity(nudgeCount) + for _ in 0..") + } + try validateRelativePath(path) + nudges.append(path) + } + guard cursor == bytes.count else { throw DoryFSWorkerCoherenceCodecError.trailingBytes } + let batch = try DoryFSWorkerCoherenceBatch( + generation: generation, + shareCapabilityID: capability, + batchID: batchID, + invalidations: invalidations, + nudgeRelativePaths: nudges + ) + guard try encode(batch) == data else { + throw DoryFSWorkerCoherenceCodecError.nonCanonicalEncoding + } + return batch + } + + public static func encode( + _ acknowledgement: DoryFSWorkerCoherenceAcknowledgement + ) -> Data { + var bytes = [UInt8]() + bytes.reserveCapacity(acknowledgementBytes) + bytes.append(contentsOf: acknowledgementMagic) + bytes.appendLE(version) + bytes.append(acknowledgementKind) + bytes.append(0) + bytes.appendLE(UInt32(acknowledgementBytes)) + bytes.appendLE(UInt32(0)) + bytes.appendLE(acknowledgement.generation.rawValue) + append(acknowledgement.shareCapabilityID.rawValue, to: &bytes) + bytes.appendLE(acknowledgement.batchID) + precondition(bytes.count == acknowledgementBytes) + return Data(bytes) + } + + public static func decodeAcknowledgement( + _ data: Data + ) throws -> DoryFSWorkerCoherenceAcknowledgement { + let bytes = [UInt8](data) + guard bytes.count == acknowledgementBytes else { + throw DoryFSWorkerCoherenceCodecError.frameLengthMismatch( + declared: bytes.count >= 12 ? bytes.leUInt32(at: 8) : 0, + actual: bytes.count + ) + } + guard Array(bytes[0..<4]) == acknowledgementMagic else { + throw DoryFSWorkerCoherenceCodecError.invalidMagic + } + guard bytes.leUInt16(at: 4) == version else { + throw DoryFSWorkerCoherenceCodecError.unsupportedVersion(bytes.leUInt16(at: 4)) + } + guard bytes[6] == acknowledgementKind else { + throw DoryFSWorkerCoherenceCodecError.unknownFrameKind(bytes[6]) + } + guard bytes[7] == 0, + bytes.leUInt32(at: 8) == UInt32(acknowledgementBytes), + bytes.leUInt32(at: 12) == 0 else { + throw DoryFSWorkerCoherenceCodecError.nonzeroReservedField + } + guard let generation = try? DoryFSWorkerGeneration(rawValue: bytes.leUInt64(at: 16)) else { + throw DoryFSWorkerCoherenceCodecError.invalidGeneration + } + guard let capability = try? DoryFSShareCapabilityID(rawValue: readUUID(bytes, at: 24)) else { + throw DoryFSWorkerCoherenceCodecError.invalidCapabilityID + } + return try DoryFSWorkerCoherenceAcknowledgement( + generation: generation, + shareCapabilityID: capability, + batchID: bytes.leUInt64(at: 40) + ) + } + + static func validateEntryName(_ name: String) throws { + let bytes = Array(name.utf8) + guard !bytes.isEmpty, + bytes.count <= maximumEntryNameBytes, + name == name.precomposedStringWithCanonicalMapping, + name != ".", name != "..", + !bytes.contains(0), !bytes.contains(UInt8(ascii: "/")) else { + throw DoryFSWorkerCoherenceCodecError.invalidEntryName(name) + } + } + + static func validateRelativePath(_ path: String) throws { + let bytes = Array(path.utf8) + guard bytes.count <= maximumRelativePathBytes, + path == path.precomposedStringWithCanonicalMapping, + !path.hasPrefix("/"), !path.hasSuffix("/"), + !bytes.contains(0) else { + throw DoryFSWorkerCoherenceCodecError.invalidRelativePath(path) + } + if path.isEmpty { return } + let components = path.split(separator: "/", omittingEmptySubsequences: false) + guard components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) else { + throw DoryFSWorkerCoherenceCodecError.invalidRelativePath(path) + } + } + + static func canonicalKey( + for invalidation: DoryFSWorkerCoherenceInvalidation + ) -> String { + switch invalidation { + case .inode(let nodeID, _, _): + "i:\(nodeID)" + case .entry(let parentNodeID, let name, _): + "e:\(parentNodeID):\(name)" + case .delete(let parentNodeID, let childNodeID, let name): + "d:\(parentNodeID):\(childNodeID):\(name)" + } + } + + private static func appendInvalidationHeader( + kind: UInt8, + nameByteCount: Int, + first: UInt64, + second: UInt64, + third: UInt64, + to bytes: inout [UInt8] + ) { + bytes.append(kind) + bytes.append(0) + bytes.appendLE(UInt16(0)) + bytes.appendLE(UInt32(nameByteCount)) + bytes.appendLE(first) + bytes.appendLE(second) + bytes.appendLE(third) + } + + private static func append(_ value: UUID, to bytes: inout [UInt8]) { + let raw = value.uuid + bytes.append(contentsOf: [ + raw.0, raw.1, raw.2, raw.3, raw.4, raw.5, raw.6, raw.7, + raw.8, raw.9, raw.10, raw.11, raw.12, raw.13, raw.14, raw.15, + ]) + } + + private static func readUUID(_ bytes: [UInt8], at offset: Int) -> UUID { + UUID(uuid: ( + bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3], + bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7], + bytes[offset + 8], bytes[offset + 9], bytes[offset + 10], bytes[offset + 11], + bytes[offset + 12], bytes[offset + 13], bytes[offset + 14], bytes[offset + 15] + )) + } + + private static func patchUInt32(_ value: UInt32, at offset: Int, in bytes: inout [UInt8]) { + for index in 0..<4 { + bytes[offset + index] = UInt8(truncatingIfNeeded: value >> UInt32(index * 8)) + } + } +} + +/// End-to-end timing budget for worker-to-runner coherence and guest watcher notification. The +/// guest listener starts only after Linux has mounted and prepared its data disk, so an explicit +/// zero-path probe owns a bounded cold-start window. Real deliveries retain the short fail-stop +/// deadline and never nest that cold-start retry inside reverse XPC. +public enum DoryFSWorkerCoherenceTiming { + public static let guestWatcherAttemptNanoseconds: UInt64 = 2_000_000_000 + public static let guestWatcherStartupGraceNanoseconds: UInt64 = 30_000_000_000 + public static let guestWatcherRetryDelayNanoseconds: UInt64 = 50_000_000 + public static let guestWatcherMaximumRetryDelayNanoseconds: UInt64 = 250_000_000 + public static let preparationRequestNanoseconds: UInt64 = 5_000_000_000 + + /// A steady-state reverse delivery can consume the one-second virtiofs invalidation budget and + /// the two-second guest-watcher budget. Keep scheduler headroom outside both inner deadlines. + public static let reverseExchangeNanoseconds: UInt64 = 4_000_000_000 + + /// Activation drains every retained share before workload readiness. A total budget prevents a + /// pathological number of slow shares from turning startup into an unbounded XPC transaction. + public static let activationCatchupNanoseconds: UInt64 = 40_000_000_000 + + /// The outer request contains the catch-up budget plus one already-started reverse exchange. + public static let activationRequestNanoseconds: UInt64 = 45_000_000_000 +} diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerContracts.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerContracts.swift new file mode 100644 index 00000000..3c5718ad --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerContracts.swift @@ -0,0 +1,852 @@ +import Foundation + +/// Versioned, Foundation-only values that may cross the private DoryFS worker channel. The XPC +/// adapter transports one encoded `Data` value per call; neither side relies on Swift object +/// layout, `Codable` key handling, or guest-controlled host paths. +public enum DoryFSWorkerProtocol { + public static let version: UInt16 = 1 +} + +public enum DoryFSWorkerContractError: Error, Equatable, Sendable { + case invalidGeneration + case invalidCapabilityID + case invalidRequestID + case invalidCorrelationID + case invalidDeadline + case invalidLimits(field: String) + case frameTooLarge(limit: Int, actual: Int) + case shortFrame(minimum: Int, actual: Int) + case invalidMagic + case unsupportedVersion(UInt16) + case unknownFrameKind(UInt8) + case unknownOpcodeClass(UInt8) + case unknownReplyDisposition(UInt8) + case unknownRejectionCode(UInt16) + case nonzeroReservedField + case payloadLengthMismatch(declared: UInt32, actual: Int) + case unexpectedField(frame: String, field: String) +} + +public struct DoryFSWorkerGeneration: Hashable, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) throws { + guard rawValue != 0 else { throw DoryFSWorkerContractError.invalidGeneration } + self.rawValue = rawValue + } +} + +public struct DoryFSShareCapabilityID: Hashable, Sendable { + public let rawValue: UUID + + public init(rawValue: UUID) throws { + guard rawValue != Self.zeroUUID else { + throw DoryFSWorkerContractError.invalidCapabilityID + } + self.rawValue = rawValue + } + + public static func random() -> Self { + // UUID() cannot produce the all-zero sentinel in practice. Retain the loop so the type's + // invariant remains unconditional rather than probabilistic. + while true { + if let value = try? Self(rawValue: UUID()) { return value } + } + } + + private static let zeroUUID = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! +} + +public enum DoryFSWorkerOpcodeClass: UInt8, Equatable, Sendable { + /// Connection setup and non-filesystem control work. + case control = 1 + /// Namespace/attribute work that does not modify host state. + case metadata = 2 + /// File or directory payload transfer. + case data = 3 + /// Any operation that may change namespace, data, attributes, mappings, or locks. + case mutation = 4 + /// Priority cancellation control; adapters must not queue it behind normal blocking work. + case interrupt = 5 +} + +public enum DoryFSWorkerRejectionCode: UInt16, Equatable, Sendable { + case invalidRequest = 1 + case staleGeneration = 2 + case unknownShare = 3 + case deadlineExpired = 4 + case resourceExhausted = 5 + case shuttingDown = 6 + case internalFailure = 7 + /// This FUSE connection accepted DESTROY and no longer admits filesystem work. The worker + /// generation remains fail-stop; callers must not reinterpret this as a reconnect invitation. + case connectionTeardown = 8 +} + +/// Immutable launch-envelope bounds. A future worker receives the same values at bootstrap; the +/// broker enforces them before IPC and the worker must independently enforce them after decoding. +public struct DoryFSWorkerLimits: Equatable, Sendable { + public static let absoluteMaximumFrameBytes = 2 * 1_024 * 1_024 + + public let maximumRequestBytes: Int + public let maximumResponseBytes: Int + public let maximumFrameBytes: Int + public let maximumInFlightRequests: Int + public let maximumAggregateRequestBytes: Int + public let maximumAggregateResponseBytes: Int + public let maximumOperationNanoseconds: UInt64 + public let maximumDrainNanoseconds: UInt64 + + public init( + maximumRequestBytes: Int, + maximumResponseBytes: Int, + maximumFrameBytes: Int, + maximumInFlightRequests: Int, + maximumAggregateRequestBytes: Int, + maximumAggregateResponseBytes: Int, + maximumOperationNanoseconds: UInt64, + maximumDrainNanoseconds: UInt64 + ) throws { + guard maximumRequestBytes > 0 else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumRequestBytes") + } + guard maximumResponseBytes >= 0 else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumResponseBytes") + } + guard maximumFrameBytes >= DoryFSWorkerFrameCodec.headerByteCount, + maximumFrameBytes <= Self.absoluteMaximumFrameBytes else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumFrameBytes") + } + let largestPayload = max(maximumRequestBytes, maximumResponseBytes) + guard largestPayload <= maximumFrameBytes - DoryFSWorkerFrameCodec.headerByteCount else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumFrameBytes") + } + guard maximumInFlightRequests > 0 else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumInFlightRequests") + } + guard maximumAggregateRequestBytes >= maximumRequestBytes else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumAggregateRequestBytes") + } + guard maximumAggregateResponseBytes >= maximumResponseBytes else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumAggregateResponseBytes") + } + guard maximumOperationNanoseconds > 0 else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumOperationNanoseconds") + } + guard maximumDrainNanoseconds > 0 else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumDrainNanoseconds") + } + self.maximumRequestBytes = maximumRequestBytes + self.maximumResponseBytes = maximumResponseBytes + self.maximumFrameBytes = maximumFrameBytes + self.maximumInFlightRequests = maximumInFlightRequests + self.maximumAggregateRequestBytes = maximumAggregateRequestBytes + self.maximumAggregateResponseBytes = maximumAggregateResponseBytes + self.maximumOperationNanoseconds = maximumOperationNanoseconds + self.maximumDrainNanoseconds = maximumDrainNanoseconds + } + + /// Matches the current one-MiB FUSE payload negotiation while limiting aggregate mailbox + /// reservations to eight maximum-sized requests/replies and at most 32 small concurrent calls. + public static let production: Self = try! Self( + maximumRequestBytes: 40 + 1 * 1_024 * 1_024, + maximumResponseBytes: 16 + 1 * 1_024 * 1_024, + maximumFrameBytes: 1_024 * 1_024 + 128, + maximumInFlightRequests: 32, + maximumAggregateRequestBytes: 8 * (40 + 1 * 1_024 * 1_024), + maximumAggregateResponseBytes: 8 * (16 + 1 * 1_024 * 1_024), + maximumOperationNanoseconds: 30_000_000_000, + maximumDrainNanoseconds: 5_000_000_000 + ) +} + +public struct DoryFSWorkerRequest: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let shareCapabilityID: DoryFSShareCapabilityID + public let requestID: UInt64 + public let correlationID: UInt64 + public let opcodeClass: DoryFSWorkerOpcodeClass + public let responseCapacity: UInt32 + public let deadlineUptimeNanoseconds: UInt64 + public let payload: Data + + public init( + generation: DoryFSWorkerGeneration, + shareCapabilityID: DoryFSShareCapabilityID, + requestID: UInt64, + correlationID: UInt64, + opcodeClass: DoryFSWorkerOpcodeClass, + responseCapacity: UInt32, + deadlineUptimeNanoseconds: UInt64, + payload: Data + ) throws { + guard requestID != 0 else { throw DoryFSWorkerContractError.invalidRequestID } + guard correlationID != 0 else { throw DoryFSWorkerContractError.invalidCorrelationID } + guard deadlineUptimeNanoseconds != 0 else { + throw DoryFSWorkerContractError.invalidDeadline + } + self.generation = generation + self.shareCapabilityID = shareCapabilityID + self.requestID = requestID + self.correlationID = correlationID + self.opcodeClass = opcodeClass + self.responseCapacity = responseCapacity + self.deadlineUptimeNanoseconds = deadlineUptimeNanoseconds + self.payload = payload + } +} + +public struct DoryFSWorkerInterrupt: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let shareCapabilityID: DoryFSShareCapabilityID + public let targetRequestID: UInt64 + public let targetCorrelationID: UInt64 + public let deadlineUptimeNanoseconds: UInt64 + + public init( + generation: DoryFSWorkerGeneration, + shareCapabilityID: DoryFSShareCapabilityID, + targetRequestID: UInt64, + targetCorrelationID: UInt64, + deadlineUptimeNanoseconds: UInt64 + ) throws { + guard targetRequestID != 0 else { throw DoryFSWorkerContractError.invalidRequestID } + guard targetCorrelationID != 0 else { + throw DoryFSWorkerContractError.invalidCorrelationID + } + guard deadlineUptimeNanoseconds != 0 else { + throw DoryFSWorkerContractError.invalidDeadline + } + self.generation = generation + self.shareCapabilityID = shareCapabilityID + self.targetRequestID = targetRequestID + self.targetCorrelationID = targetCorrelationID + self.deadlineUptimeNanoseconds = deadlineUptimeNanoseconds + } +} + +public struct DoryFSWorkerDrain: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let shareCapabilityID: DoryFSShareCapabilityID + public let deadlineUptimeNanoseconds: UInt64 + + public init( + generation: DoryFSWorkerGeneration, + shareCapabilityID: DoryFSShareCapabilityID, + deadlineUptimeNanoseconds: UInt64 + ) throws { + guard deadlineUptimeNanoseconds != 0 else { + throw DoryFSWorkerContractError.invalidDeadline + } + self.generation = generation + self.shareCapabilityID = shareCapabilityID + self.deadlineUptimeNanoseconds = deadlineUptimeNanoseconds + } +} + +public struct DoryFSWorkerInvalidation: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let shareCapabilityID: DoryFSShareCapabilityID + + public init( + generation: DoryFSWorkerGeneration, + shareCapabilityID: DoryFSShareCapabilityID + ) { + self.generation = generation + self.shareCapabilityID = shareCapabilityID + } +} + +/// Completes the second phase of one worker execution after the VMM has either published the +/// response into the exact leased virtqueue chain or proved that publication did not happen. +/// +/// The worker retains any FUSE lookup/handle grants until this acknowledgement arrives. A +/// `.discardPublication` acknowledgement rolls those grants back; connection loss before either +/// acknowledgement is fail-stop for the worker generation. +public struct DoryFSWorkerPublication: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let shareCapabilityID: DoryFSShareCapabilityID + public let requestID: UInt64 + public let correlationID: UInt64 + + public init( + generation: DoryFSWorkerGeneration, + shareCapabilityID: DoryFSShareCapabilityID, + requestID: UInt64, + correlationID: UInt64 + ) throws { + guard requestID != 0 else { throw DoryFSWorkerContractError.invalidRequestID } + guard correlationID != 0 else { throw DoryFSWorkerContractError.invalidCorrelationID } + self.generation = generation + self.shareCapabilityID = shareCapabilityID + self.requestID = requestID + self.correlationID = correlationID + } +} + +public enum DoryFSWorkerReplyOutcome: Equatable, Sendable { + case completed(Data) + case rejected(DoryFSWorkerRejectionCode) +} + +public struct DoryFSWorkerReply: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let shareCapabilityID: DoryFSShareCapabilityID + public let requestID: UInt64 + public let correlationID: UInt64 + public let outcome: DoryFSWorkerReplyOutcome + + public init( + generation: DoryFSWorkerGeneration, + shareCapabilityID: DoryFSShareCapabilityID, + requestID: UInt64, + correlationID: UInt64, + outcome: DoryFSWorkerReplyOutcome + ) throws { + guard requestID != 0 else { throw DoryFSWorkerContractError.invalidRequestID } + guard correlationID != 0 else { throw DoryFSWorkerContractError.invalidCorrelationID } + self.generation = generation + self.shareCapabilityID = shareCapabilityID + self.requestID = requestID + self.correlationID = correlationID + self.outcome = outcome + } +} + +public struct DoryFSWorkerDrained: Equatable, Sendable { + public let generation: DoryFSWorkerGeneration + public let shareCapabilityID: DoryFSShareCapabilityID + + public init( + generation: DoryFSWorkerGeneration, + shareCapabilityID: DoryFSShareCapabilityID + ) { + self.generation = generation + self.shareCapabilityID = shareCapabilityID + } +} + +public enum DoryFSWorkerClientFrame: Equatable, Sendable { + case execute(DoryFSWorkerRequest) + case interrupt(DoryFSWorkerInterrupt) + case drain(DoryFSWorkerDrain) + case invalidate(DoryFSWorkerInvalidation) + case commitPublication(DoryFSWorkerPublication) + case discardPublication(DoryFSWorkerPublication) +} + +public enum DoryFSWorkerServiceFrame: Equatable, Sendable { + case reply(DoryFSWorkerReply) + case drained(DoryFSWorkerDrained) +} + +/// Exact version-1 binary framing. All integer fields are little endian, every reserved bit must be +/// zero, and the declared payload must consume the complete frame. This avoids last-key-wins and +/// unknown-field behavior at the future XPC boundary. +public enum DoryFSWorkerFrameCodec { + public static let headerByteCount = 72 + + private static let magic: [UInt8] = [0x44, 0x46, 0x53, 0x31] // "DFS1" + + private enum Kind: UInt8 { + case execute = 1 + case interrupt = 2 + case drain = 3 + case invalidate = 4 + case reply = 5 + case drained = 6 + case commitPublication = 7 + case discardPublication = 8 + } + + public static func encode( + _ frame: DoryFSWorkerClientFrame, + maximumFrameBytes: Int + ) throws -> Data { + switch frame { + case .execute(let request): + return try encodeFrame( + kind: .execute, + generation: request.generation, + capability: request.shareCapabilityID, + requestID: request.requestID, + correlationID: request.correlationID, + deadline: request.deadlineUptimeNanoseconds, + responseCapacity: request.responseCapacity, + opcodeClass: request.opcodeClass.rawValue, + disposition: 0, + rejection: 0, + payload: request.payload, + maximumFrameBytes: maximumFrameBytes + ) + case .interrupt(let interrupt): + return try encodeFrame( + kind: .interrupt, + generation: interrupt.generation, + capability: interrupt.shareCapabilityID, + requestID: interrupt.targetRequestID, + correlationID: interrupt.targetCorrelationID, + deadline: interrupt.deadlineUptimeNanoseconds, + responseCapacity: 0, + opcodeClass: 0, + disposition: 0, + rejection: 0, + payload: Data(), + maximumFrameBytes: maximumFrameBytes + ) + case .drain(let drain): + return try encodeFrame( + kind: .drain, + generation: drain.generation, + capability: drain.shareCapabilityID, + requestID: 0, + correlationID: 0, + deadline: drain.deadlineUptimeNanoseconds, + responseCapacity: 0, + opcodeClass: 0, + disposition: 0, + rejection: 0, + payload: Data(), + maximumFrameBytes: maximumFrameBytes + ) + case .invalidate(let invalidation): + return try encodeFrame( + kind: .invalidate, + generation: invalidation.generation, + capability: invalidation.shareCapabilityID, + requestID: 0, + correlationID: 0, + deadline: 0, + responseCapacity: 0, + opcodeClass: 0, + disposition: 0, + rejection: 0, + payload: Data(), + maximumFrameBytes: maximumFrameBytes + ) + case .commitPublication(let publication): + return try encodePublication( + publication, + kind: .commitPublication, + maximumFrameBytes: maximumFrameBytes + ) + case .discardPublication(let publication): + return try encodePublication( + publication, + kind: .discardPublication, + maximumFrameBytes: maximumFrameBytes + ) + } + } + + public static func encode( + _ frame: DoryFSWorkerServiceFrame, + maximumFrameBytes: Int + ) throws -> Data { + switch frame { + case .reply(let reply): + let disposition: UInt8 + let rejection: UInt16 + let payload: Data + switch reply.outcome { + case .completed(let bytes): + disposition = 1 + rejection = 0 + payload = bytes + case .rejected(let code): + disposition = 2 + rejection = code.rawValue + payload = Data() + } + return try encodeFrame( + kind: .reply, + generation: reply.generation, + capability: reply.shareCapabilityID, + requestID: reply.requestID, + correlationID: reply.correlationID, + deadline: 0, + responseCapacity: 0, + opcodeClass: 0, + disposition: disposition, + rejection: rejection, + payload: payload, + maximumFrameBytes: maximumFrameBytes + ) + case .drained(let drained): + return try encodeFrame( + kind: .drained, + generation: drained.generation, + capability: drained.shareCapabilityID, + requestID: 0, + correlationID: 0, + deadline: 0, + responseCapacity: 0, + opcodeClass: 0, + disposition: 0, + rejection: 0, + payload: Data(), + maximumFrameBytes: maximumFrameBytes + ) + } + } + + public static func decodeClientFrame( + _ data: Data, + maximumFrameBytes: Int + ) throws -> DoryFSWorkerClientFrame { + let decoded = try decodeFrame(data, maximumFrameBytes: maximumFrameBytes) + switch decoded.kind { + case .execute: + guard decoded.disposition == 0, decoded.rejection == 0 else { + throw DoryFSWorkerContractError.unexpectedField(frame: "execute", field: "disposition") + } + guard let opcodeClass = DoryFSWorkerOpcodeClass(rawValue: decoded.opcodeClass) else { + throw DoryFSWorkerContractError.unknownOpcodeClass(decoded.opcodeClass) + } + return .execute(try DoryFSWorkerRequest( + generation: decoded.generation, + shareCapabilityID: decoded.capability, + requestID: decoded.requestID, + correlationID: decoded.correlationID, + opcodeClass: opcodeClass, + responseCapacity: decoded.responseCapacity, + deadlineUptimeNanoseconds: decoded.deadline, + payload: decoded.payload + )) + case .interrupt: + try requireZero(decoded.responseCapacity, frame: "interrupt", field: "responseCapacity") + try requireZero(decoded.opcodeClass, frame: "interrupt", field: "opcodeClass") + try requireZero(decoded.disposition, frame: "interrupt", field: "disposition") + try requireZero(decoded.rejection, frame: "interrupt", field: "rejection") + try requireEmpty(decoded.payload, frame: "interrupt") + return .interrupt(try DoryFSWorkerInterrupt( + generation: decoded.generation, + shareCapabilityID: decoded.capability, + targetRequestID: decoded.requestID, + targetCorrelationID: decoded.correlationID, + deadlineUptimeNanoseconds: decoded.deadline + )) + case .drain: + try requireControlFieldsZero(decoded, frame: "drain", permitDeadline: true) + return .drain(try DoryFSWorkerDrain( + generation: decoded.generation, + shareCapabilityID: decoded.capability, + deadlineUptimeNanoseconds: decoded.deadline + )) + case .invalidate: + try requireControlFieldsZero(decoded, frame: "invalidate", permitDeadline: false) + return .invalidate(DoryFSWorkerInvalidation( + generation: decoded.generation, + shareCapabilityID: decoded.capability + )) + case .commitPublication: + return .commitPublication(try decodePublication(decoded, frame: "commitPublication")) + case .discardPublication: + return .discardPublication(try decodePublication(decoded, frame: "discardPublication")) + case .reply, .drained: + throw DoryFSWorkerContractError.unexpectedField(frame: "client", field: "kind") + } + } + + public static func decodeServiceFrame( + _ data: Data, + maximumFrameBytes: Int + ) throws -> DoryFSWorkerServiceFrame { + let decoded = try decodeFrame(data, maximumFrameBytes: maximumFrameBytes) + switch decoded.kind { + case .reply: + try requireZero(decoded.deadline, frame: "reply", field: "deadline") + try requireZero(decoded.responseCapacity, frame: "reply", field: "responseCapacity") + try requireZero(decoded.opcodeClass, frame: "reply", field: "opcodeClass") + let outcome: DoryFSWorkerReplyOutcome + switch decoded.disposition { + case 1: + try requireZero(decoded.rejection, frame: "reply", field: "rejection") + outcome = .completed(decoded.payload) + case 2: + try requireEmpty(decoded.payload, frame: "reply") + guard let rejection = DoryFSWorkerRejectionCode(rawValue: decoded.rejection) else { + throw DoryFSWorkerContractError.unknownRejectionCode(decoded.rejection) + } + outcome = .rejected(rejection) + default: + throw DoryFSWorkerContractError.unknownReplyDisposition(decoded.disposition) + } + return .reply(try DoryFSWorkerReply( + generation: decoded.generation, + shareCapabilityID: decoded.capability, + requestID: decoded.requestID, + correlationID: decoded.correlationID, + outcome: outcome + )) + case .drained: + try requireControlFieldsZero(decoded, frame: "drained", permitDeadline: false) + return .drained(DoryFSWorkerDrained( + generation: decoded.generation, + shareCapabilityID: decoded.capability + )) + case .execute, .interrupt, .drain, .invalidate, + .commitPublication, .discardPublication: + throw DoryFSWorkerContractError.unexpectedField(frame: "service", field: "kind") + } + } + + private static func encodePublication( + _ publication: DoryFSWorkerPublication, + kind: Kind, + maximumFrameBytes: Int + ) throws -> Data { + try encodeFrame( + kind: kind, + generation: publication.generation, + capability: publication.shareCapabilityID, + requestID: publication.requestID, + correlationID: publication.correlationID, + deadline: 0, + responseCapacity: 0, + opcodeClass: 0, + disposition: 0, + rejection: 0, + payload: Data(), + maximumFrameBytes: maximumFrameBytes + ) + } + + private static func decodePublication( + _ decoded: DecodedFrame, + frame: String + ) throws -> DoryFSWorkerPublication { + try requireZero(decoded.deadline, frame: frame, field: "deadline") + try requireZero(decoded.responseCapacity, frame: frame, field: "responseCapacity") + try requireZero(decoded.opcodeClass, frame: frame, field: "opcodeClass") + try requireZero(decoded.disposition, frame: frame, field: "disposition") + try requireZero(decoded.rejection, frame: frame, field: "rejection") + try requireEmpty(decoded.payload, frame: frame) + return try DoryFSWorkerPublication( + generation: decoded.generation, + shareCapabilityID: decoded.capability, + requestID: decoded.requestID, + correlationID: decoded.correlationID + ) + } + + private struct DecodedFrame { + let kind: Kind + let generation: DoryFSWorkerGeneration + let capability: DoryFSShareCapabilityID + let requestID: UInt64 + let correlationID: UInt64 + let deadline: UInt64 + let responseCapacity: UInt32 + let opcodeClass: UInt8 + let disposition: UInt8 + let rejection: UInt16 + let payload: Data + } + + private static func encodeFrame( + kind: Kind, + generation: DoryFSWorkerGeneration, + capability: DoryFSShareCapabilityID, + requestID: UInt64, + correlationID: UInt64, + deadline: UInt64, + responseCapacity: UInt32, + opcodeClass: UInt8, + disposition: UInt8, + rejection: UInt16, + payload: Data, + maximumFrameBytes: Int + ) throws -> Data { + try validateMaximumFrameBytes(maximumFrameBytes) + guard payload.count <= Int(UInt32.max) else { + throw DoryFSWorkerContractError.frameTooLarge( + limit: maximumFrameBytes, + actual: Int.max + ) + } + let (frameSize, overflow) = headerByteCount.addingReportingOverflow(payload.count) + guard !overflow, frameSize <= maximumFrameBytes else { + throw DoryFSWorkerContractError.frameTooLarge( + limit: maximumFrameBytes, + actual: overflow ? Int.max : frameSize + ) + } + var header = [UInt8]() + header.reserveCapacity(headerByteCount) + header.append(contentsOf: magic) + append(DoryFSWorkerProtocol.version, to: &header) + header.append(kind.rawValue) + header.append(0) // flags + append(generation.rawValue, to: &header) + append(capability.rawValue, to: &header) + append(requestID, to: &header) + append(correlationID, to: &header) + append(deadline, to: &header) + append(responseCapacity, to: &header) + append(UInt32(payload.count), to: &header) + header.append(opcodeClass) + header.append(disposition) + append(rejection, to: &header) + append(UInt32(0), to: &header) // reserved + precondition(header.count == headerByteCount) + + // Keep the fixed header as the only byte-array staging. Building a payload-sized Array and + // then converting that Array into Data duplicated every large FUSE frame before XPC. + var data = Data(capacity: frameSize) + data.append(contentsOf: header) + data.append(payload) + return data + } + + private static func decodeFrame( + _ data: Data, + maximumFrameBytes: Int + ) throws -> DecodedFrame { + try validateMaximumFrameBytes(maximumFrameBytes) + guard data.count <= maximumFrameBytes else { + throw DoryFSWorkerContractError.frameTooLarge( + limit: maximumFrameBytes, + actual: data.count + ) + } + guard data.count >= headerByteCount else { + throw DoryFSWorkerContractError.shortFrame( + minimum: headerByteCount, + actual: data.count + ) + } + // Decode only the fixed header. The payload remains a bounded Data slice over the received + // frame and can cross the nested RPC/frame decoder without another full-frame allocation. + let header = [UInt8](data.prefix(headerByteCount)) + guard Array(header[0..<4]) == magic else { + throw DoryFSWorkerContractError.invalidMagic + } + let version = readUInt16(header, at: 4) + guard version == DoryFSWorkerProtocol.version else { + throw DoryFSWorkerContractError.unsupportedVersion(version) + } + guard let kind = Kind(rawValue: header[6]) else { + throw DoryFSWorkerContractError.unknownFrameKind(header[6]) + } + guard header[7] == 0, readUInt32(header, at: 68) == 0 else { + throw DoryFSWorkerContractError.nonzeroReservedField + } + let payloadLength = readUInt32(header, at: 60) + let actualPayloadLength = data.count - headerByteCount + guard UInt64(payloadLength) == UInt64(actualPayloadLength) else { + throw DoryFSWorkerContractError.payloadLengthMismatch( + declared: payloadLength, + actual: actualPayloadLength + ) + } + let payloadStart = data.index(data.startIndex, offsetBy: headerByteCount) + return DecodedFrame( + kind: kind, + generation: try DoryFSWorkerGeneration(rawValue: readUInt64(header, at: 8)), + capability: try DoryFSShareCapabilityID(rawValue: readUUID(header, at: 16)), + requestID: readUInt64(header, at: 32), + correlationID: readUInt64(header, at: 40), + deadline: readUInt64(header, at: 48), + responseCapacity: readUInt32(header, at: 56), + opcodeClass: header[64], + disposition: header[65], + rejection: readUInt16(header, at: 66), + payload: data[payloadStart..= headerByteCount, + value <= DoryFSWorkerLimits.absoluteMaximumFrameBytes else { + throw DoryFSWorkerContractError.invalidLimits(field: "maximumFrameBytes") + } + } + + private static func requireControlFieldsZero( + _ decoded: DecodedFrame, + frame: String, + permitDeadline: Bool + ) throws { + try requireZero(decoded.requestID, frame: frame, field: "requestID") + try requireZero(decoded.correlationID, frame: frame, field: "correlationID") + if !permitDeadline { + try requireZero(decoded.deadline, frame: frame, field: "deadline") + } + try requireZero(decoded.responseCapacity, frame: frame, field: "responseCapacity") + try requireZero(decoded.opcodeClass, frame: frame, field: "opcodeClass") + try requireZero(decoded.disposition, frame: frame, field: "disposition") + try requireZero(decoded.rejection, frame: frame, field: "rejection") + try requireEmpty(decoded.payload, frame: frame) + } + + private static func requireZero( + _ value: T, + frame: String, + field: String + ) throws { + guard value == 0 else { + throw DoryFSWorkerContractError.unexpectedField(frame: frame, field: field) + } + } + + private static func requireEmpty(_ data: Data, frame: String) throws { + guard data.isEmpty else { + throw DoryFSWorkerContractError.unexpectedField(frame: frame, field: "payload") + } + } + + private static func append(_ value: UInt16, to bytes: inout [UInt8]) { + bytes.append(UInt8(truncatingIfNeeded: value)) + bytes.append(UInt8(truncatingIfNeeded: value >> 8)) + } + + private static func append(_ value: UInt32, to bytes: inout [UInt8]) { + for shift in stride(from: 0, through: 24, by: 8) { + bytes.append(UInt8(truncatingIfNeeded: value >> UInt32(shift))) + } + } + + private static func append(_ value: UInt64, to bytes: inout [UInt8]) { + for shift in stride(from: 0, through: 56, by: 8) { + bytes.append(UInt8(truncatingIfNeeded: value >> UInt64(shift))) + } + } + + private static func append(_ value: UUID, to bytes: inout [UInt8]) { + let raw = value.uuid + bytes.append(contentsOf: [ + raw.0, raw.1, raw.2, raw.3, raw.4, raw.5, raw.6, raw.7, + raw.8, raw.9, raw.10, raw.11, raw.12, raw.13, raw.14, raw.15, + ]) + } + + private static func readUInt16(_ bytes: [UInt8], at offset: Int) -> UInt16 { + UInt16(bytes[offset]) | (UInt16(bytes[offset + 1]) << 8) + } + + private static func readUInt32(_ bytes: [UInt8], at offset: Int) -> UInt32 { + var value: UInt32 = 0 + for index in 0..<4 { + value |= UInt32(bytes[offset + index]) << UInt32(index * 8) + } + return value + } + + private static func readUInt64(_ bytes: [UInt8], at offset: Int) -> UInt64 { + var value: UInt64 = 0 + for index in 0..<8 { + value |= UInt64(bytes[offset + index]) << UInt64(index * 8) + } + return value + } + + private static func readUUID(_ bytes: [UInt8], at offset: Int) -> UUID { + UUID(uuid: ( + bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3], + bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7], + bytes[offset + 8], bytes[offset + 9], bytes[offset + 10], bytes[offset + 11], + bytes[offset + 12], bytes[offset + 13], bytes[offset + 14], bytes[offset + 15] + )) + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerXPC.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerXPC.swift new file mode 100644 index 00000000..9f51aaeb --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/DoryFSWorkerXPC.swift @@ -0,0 +1,248 @@ +import Foundation + +/// Runner-private XPC identity. The service is embedded in `DoryHVRunner.app`; callers must use +/// this name from that runner's private service namespace rather than searching the outer app. +public enum DoryFSWorkerXPC { + public static let serviceName = "com.pythonxi.Dory.HVRunner.FSWorker" +} + +/// The complete Objective-C/XPC surface. Control data remains an exact bounded binary envelope. +/// Bootstrap additionally transfers a bounded array of already-open directory descriptors; no +/// host path, Swift object graph, `Codable` value, or `NSError` crosses the boundary. +@objc(DoryFSWorkerXPCProtocol) +public protocol DoryFSWorkerXPCProtocol: NSObjectProtocol { + func bootstrap( + _ request: Data, + rootDescriptors: [FileHandle], + withReply reply: @escaping (Data) -> Void + ) + func exchange(_ frame: Data, withReply reply: @escaping (Data) -> Void) + func sendOneWay(_ frame: Data) + func prepareCoherence(withReply reply: @escaping (Data) -> Void) + func activateCoherence(withReply reply: @escaping (Data) -> Void) + func coherenceStatus(withReply reply: @escaping (Data) -> Void) +} + +/// Reverse runner interface used only for worker-local host-edit coherence. The worker sends one +/// exact generation/capability-scoped batch and retains those exact bytes until the runner replies +/// with a matching acknowledgement. No Foundation path or object graph crosses this method. +@objc(DoryFSWorkerCoherenceSinkXPCProtocol) +public protocol DoryFSWorkerCoherenceSinkXPCProtocol: NSObjectProtocol { + func deliverCoherence( + _ frame: Data, + withReply reply: @escaping (Data) -> Void + ) +} + +/// Constructs the single transport interface used by both peers. The descriptor allowlist is +/// explicit so bootstrap cannot widen into arbitrary secure-coded object authority. +public enum DoryFSWorkerXPCInterface { + public static func make() -> NSXPCInterface { + let interface = NSXPCInterface(with: DoryFSWorkerXPCProtocol.self) + let descriptorClasses = NSSet( + objects: NSArray.self, + FileHandle.self + ) as! Set + interface.setClasses( + descriptorClasses, + for: #selector( + DoryFSWorkerXPCProtocol.bootstrap(_:rootDescriptors:withReply:) + ), + argumentIndex: 1, + ofReply: false + ) + return interface + } + + + public static func makeCoherenceSink() -> NSXPCInterface { + NSXPCInterface(with: DoryFSWorkerCoherenceSinkXPCProtocol.self) + } +} + +public enum DoryFSWorkerRPCFailureCode: UInt16, Equatable, Sendable { + case invalidEnvelope = 1 + case bootstrapRejected = 2 + case bootstrapAlreadyAttempted = 3 + case bootstrapRequired = 4 + case staleGeneration = 5 + case unknownShare = 6 + case deadlineExpired = 7 + case resourceExhausted = 8 + case duplicateRequest = 9 + case shuttingDown = 10 + case protocolViolation = 11 + case internalFailure = 12 + case bootstrapDescriptorTransferFailed = 13 + case bootstrapRootOpenFailed = 16 + case bootstrapRootIdentityMismatch = 17 +} + +/// Non-sensitive stage returned when a worker rejects bootstrap authority. The wire result never +/// includes a host path, capability identifier, descriptor number, inode, or errno; it exposes only +/// the stage needed to diagnose packaging and sandbox integration without weakening the boundary. +public enum DoryFSWorkerBootstrapRejectionReason: Equatable, Sendable { + case descriptorTransfer + case rootOpen + case rootIdentity +} + +public extension DoryFSWorkerRPCFailureCode { + var bootstrapRejectionReason: DoryFSWorkerBootstrapRejectionReason? { + switch self { + case .bootstrapDescriptorTransferFailed: + .descriptorTransfer + case .bootstrapRootOpenFailed: + .rootOpen + case .bootstrapRootIdentityMismatch: + .rootIdentity + default: + nil + } + } +} + +public enum DoryFSWorkerRPCResult: Equatable, Sendable { + case success(Data) + case failure(DoryFSWorkerRPCFailureCode) +} + +public enum DoryFSWorkerRPCResultError: Error, Equatable, Sendable { + case frameTooLarge(limit: Int, actual: Int) + case shortFrame(minimum: Int, actual: Int) + case invalidMagic + case unsupportedVersion(UInt16) + case unknownDisposition(UInt8) + case unknownFailureCode(UInt16) + case nonzeroReservedField + case unexpectedFailureCode(UInt16) + case unexpectedPayload + case payloadLengthMismatch(declared: UInt32, actual: Int) +} + +/// Exact outer result envelope used by bootstrap and request/reply RPCs. Inner bootstrap/FUSE +/// frames keep their own independent magic and size validation, so an adapter can never mistake a +/// malformed worker result for an authenticated service reply. +public enum DoryFSWorkerRPCResultCodec { + public static let headerByteCount = 16 + public static let absoluteMaximumPayloadBytes = + DoryFSWorkerBootstrapCodec.absoluteMaximumBootstrapBytes + + private static let magic: [UInt8] = [0x44, 0x46, 0x52, 0x31] // "DFR1" + private static let version: UInt16 = 1 + private static let successDisposition: UInt8 = 1 + private static let failureDisposition: UInt8 = 2 + + public static func encode( + _ result: DoryFSWorkerRPCResult, + maximumPayloadBytes: Int = absoluteMaximumPayloadBytes + ) throws -> Data { + try validateMaximum(maximumPayloadBytes) + let disposition: UInt8 + let failureCode: UInt16 + let payload: Data + switch result { + case .success(let bytes): + disposition = successDisposition + failureCode = 0 + payload = bytes + case .failure(let code): + disposition = failureDisposition + failureCode = code.rawValue + payload = Data() + } + guard payload.count <= maximumPayloadBytes, + payload.count <= Int(UInt32.max) else { + throw DoryFSWorkerRPCResultError.frameTooLarge( + limit: maximumPayloadBytes, + actual: payload.count + ) + } + var header = [UInt8]() + header.reserveCapacity(headerByteCount) + header.append(contentsOf: magic) + header.appendLE(version) + header.append(disposition) + header.append(0) + header.appendLE(failureCode) + header.appendLE(UInt16(0)) + header.appendLE(UInt32(payload.count)) + precondition(header.count == headerByteCount) + + // The inner service frame may be close to one MiB. Stage only this fixed header and append + // the inner Data directly into one pre-sized outer allocation. + var data = Data(capacity: headerByteCount + payload.count) + data.append(contentsOf: header) + data.append(payload) + return data + } + + public static func decode( + _ data: Data, + maximumPayloadBytes: Int = absoluteMaximumPayloadBytes + ) throws -> DoryFSWorkerRPCResult { + try validateMaximum(maximumPayloadBytes) + let maximumFrameBytes = headerByteCount + maximumPayloadBytes + guard data.count <= maximumFrameBytes else { + throw DoryFSWorkerRPCResultError.frameTooLarge( + limit: maximumFrameBytes, + actual: data.count + ) + } + guard data.count >= headerByteCount else { + throw DoryFSWorkerRPCResultError.shortFrame( + minimum: headerByteCount, + actual: data.count + ) + } + let header = [UInt8](data.prefix(headerByteCount)) + guard Array(header[0..<4]) == magic else { + throw DoryFSWorkerRPCResultError.invalidMagic + } + let receivedVersion = header.leUInt16(at: 4) + guard receivedVersion == version else { + throw DoryFSWorkerRPCResultError.unsupportedVersion(receivedVersion) + } + guard header[7] == 0, header.leUInt16(at: 10) == 0 else { + throw DoryFSWorkerRPCResultError.nonzeroReservedField + } + let payloadLength = header.leUInt32(at: 12) + let actualPayloadLength = data.count - headerByteCount + guard UInt64(payloadLength) == UInt64(actualPayloadLength) else { + throw DoryFSWorkerRPCResultError.payloadLengthMismatch( + declared: payloadLength, + actual: actualPayloadLength + ) + } + let failureCode = header.leUInt16(at: 8) + let payloadStart = data.index(data.startIndex, offsetBy: headerByteCount) + let payload = data[payloadStart..= 0, + maximumPayloadBytes <= absoluteMaximumPayloadBytes else { + throw DoryFSWorkerRPCResultError.frameTooLarge( + limit: absoluteMaximumPayloadBytes, + actual: maximumPayloadBytes + ) + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseProtocol.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/FuseProtocol.swift similarity index 97% rename from Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseProtocol.swift rename to Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/FuseProtocol.swift index 4cf842fc..9abfb52f 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseProtocol.swift +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/FuseProtocol.swift @@ -50,6 +50,25 @@ public enum FuseOpcode: UInt32, Sendable { case copyFileRange = 47 case setupmapping = 48 case removemapping = 49 + case syncfs = 50 +} + +public extension FuseOpcode { + var workerOpcodeClass: DoryFSWorkerOpcodeClass { + switch self { + case .initOp, .destroy, .notifyReply: + return .control + case .lookup, .forget, .getattr, .readlink, .statfs, .getxattr, + .listxattr, .opendir, .readdir, .releasedir, .getlk, .batchForget: + return .metadata + case .open, .read, .release, .flush, .fsync, .fsyncdir, .lseek, .syncfs: + return .data + case .interrupt: + return .interrupt + default: + return .mutation + } + } } public struct FuseInHeader: Equatable, Sendable { @@ -646,8 +665,8 @@ public enum FuseProtocol { | FuseInitFlag.doReaddirplus.rawValue | FuseInitFlag.parallelDirops.rawValue if writebackCache { // WRITEBACK_CACHE lets the guest coalesce buffered writes, removing the per-write round - // trip on the create storm. Linux ignores FOPEN_NOFLUSH while it is enabled; the runtime - // keeps an env opt-out (DORY_FUSE_WRITEBACK_CACHE=0) for the durability-strict benchmark arm. + // trip on the create storm. Linux ignores FOPEN_NOFLUSH while it is enabled. Production + // leaves this disabled until dirty-page conflict handling can preserve host edits. flags |= FuseInitFlag.writebackCache.rawValue } if killPrivV2 { diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/LittleEndianBytes.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/LittleEndianBytes.swift similarity index 94% rename from Packages/ContainerizationEngine/Sources/DoryHV/LittleEndianBytes.swift rename to Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/LittleEndianBytes.swift index 60b239a1..b64799f6 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/LittleEndianBytes.swift +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerContracts/LittleEndianBytes.swift @@ -1,4 +1,4 @@ -extension Array where Element == UInt8 { +public extension Array where Element == UInt8 { mutating func appendLE(_ value: UInt16) { Swift.withUnsafeBytes(of: value.littleEndian) { append(contentsOf: $0) } } @@ -29,7 +29,7 @@ extension Array where Element == UInt8 { } } -extension ArraySlice where Element == UInt8 { +public extension ArraySlice where Element == UInt8 { func leUInt16(at offset: Int) -> UInt16 { let index = startIndex + offset guard offset >= 0, index + 1 < endIndex else { return 0 } diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerHostCoherence.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerHostCoherence.swift new file mode 100644 index 00000000..c0a04341 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerHostCoherence.swift @@ -0,0 +1,1129 @@ +import CoreServices +import Darwin +import DoryFSWorkerContracts +import Foundation + +public enum DoryFSWorkerHostCoherenceError: Error, Equatable, Sendable { + case observationUnavailable + case eventLoss + case pendingOverflow(limit: Int) + case invalidAcknowledgement + case acknowledgementUnavailable + case planOverflow + case capabilityAuthorityUnavailable +} + +public struct DoryFSWorkerHostCoherenceStatistics: Equatable, Sendable { + public let running: Bool + public let configuredShareCount: Int + public let invalidationOnlyShareCount: Int + public let watcherNudgeShareCount: Int + public let requiredObservationShareCount: Int + public let observedRequiredShareCount: Int + public let observationStreamCount: Int + public let pendingEventCount: Int + public let pendingEventLimit: Int + public let receivedEventCount: UInt64 + public let deliveredBatchCount: UInt64 + public let failedBatchCount: UInt64 + public let eventLossCount: UInt64 +} + +/// Same-process host-event authority for one filesystem worker generation. Guest FUSE mutations +/// execute in this process, so `IgnoreSelf` and the explicit `OwnEvent` check can distinguish them +/// from edits made by host applications. Moving this stream back to the runner would destroy that +/// source attribution and reflect guest writes back as synthetic host edits. +final class DoryFSWorkerHostCoherence: @unchecked Sendable { + typealias Exchange = @Sendable (Data) throws -> Data + typealias Failure = @Sendable (DoryFSWorkerHostCoherenceError) -> Void + + private final class Endpoint: @unchecked Sendable { + let capability: DoryFSShareCapabilityID + let hostFS: HostFS + let root: String + let watcherNudgesEnabled: Bool + let policy: DoryFSShareCoherencePolicy + + init( + capability: DoryFSShareCapabilityID, + hostFS: HostFS, + policy: DoryFSShareCoherencePolicy + ) { + self.capability = capability + self.hostFS = hostFS + root = hostFS.eventRootPath + self.policy = policy + watcherNudgesEnabled = policy == .invalidationAndWatcherNudge + } + + func contains(_ path: String) -> Bool { + hostFS.acceptsEventPath(path) + } + + func relativePath(_ path: String) -> String? { + let normalized = URL(fileURLWithPath: path).standardizedFileURL.path + guard contains(normalized) else { return nil } + if normalized == root { return "" } + return String(normalized.dropFirst(root.count + 1)) + } + + } + + private struct Change: Sendable { + var path: String + var flags: UInt32 + var eventID: UInt64 + var directoryAggregate: Bool + + var requiresFailStop: Bool { + let mask = UInt32( + kFSEventStreamEventFlagMustScanSubDirs | + kFSEventStreamEventFlagUserDropped | + kFSEventStreamEventFlagKernelDropped | + kFSEventStreamEventFlagEventIdsWrapped | + kFSEventStreamEventFlagRootChanged | + kFSEventStreamEventFlagMount | + kFSEventStreamEventFlagUnmount + ) + return flags & mask != 0 + } + + func representsRemoval(pathIsMissing: Bool) -> Bool { + let mask = UInt32( + kFSEventStreamEventFlagItemRemoved | + kFSEventStreamEventFlagItemRenamed + ) + return flags & mask != 0 && pathIsMissing + } + } + + private final class CallbackBox: @unchecked Sendable { + weak var relay: DoryFSWorkerHostCoherence? + let capability: DoryFSShareCapabilityID + let historyCompletion: (@Sendable () -> Void)? + + init( + relay: DoryFSWorkerHostCoherence, + capability: DoryFSShareCapabilityID, + historyCompletion: (@Sendable () -> Void)? = nil + ) { + self.relay = relay + self.capability = capability + self.historyCompletion = historyCompletion + } + } + + private final class ActivationHistoryCompletion: @unchecked Sendable { + private let lock = NSLock() + private var completed = false + + var isComplete: Bool { lock.withLock { completed } } + + func complete() { + lock.withLock { completed = true } + } + } + + private struct ActivationReplayStream { + let stream: FSEventStreamRef + // FSEventStreamContext uses an unretained pointer; retain this box through final queue drain. + let box: CallbackBox + let completion: ActivationHistoryCompletion + let endpoint: Endpoint + let observationRoot: String + let expectedIdentity: HostFSEventPathIdentity + } + + static let pendingEventLimit = 65_536 + static let acknowledgementAttempts = 2 + + private static let streamFlags = FSEventStreamCreateFlags( + kFSEventStreamCreateFlagWatchRoot | + kFSEventStreamCreateFlagIgnoreSelf | + kFSEventStreamCreateFlagMarkSelf | + kFSEventStreamCreateFlagUseCFTypes | + kFSEventStreamCreateFlagFileEvents + ) + + private static let streamCallback: FSEventStreamCallback = { + _, info, count, eventPaths, eventFlags, eventIDs in + guard let info else { return } + let box = Unmanaged.fromOpaque(info).takeUnretainedValue() + let paths = unsafeBitCast(eventPaths, to: NSArray.self) + var changes = [Change]() + changes.reserveCapacity(count) + var historyDone = false + for index in 0..() + private var historyCaughtUpCapabilities = Set() + private var observedCapabilities = Set() + private var pending = [DoryFSShareCapabilityID: [String: Change]]() + private var running = false + private var activationCatchupInProgress = false + private var activationDeliveryInProgress = false + private var activationComplete = false + private var flushScheduled = false + private var terminalFailureReported = false + private var nextBatchID = UInt64.random(in: 1...UInt64.max) + private var receivedEventCount: UInt64 = 0 + private var deliveredBatchCount: UInt64 = 0 + private var failedBatchCount: UInt64 = 0 + private var eventLossCount: UInt64 = 0 + /// Test-only queue marker. Production leaves this nil; a blocked marker proves activation does + /// not release an unretained replay context ahead of callbacks queued at invalidation. + var activationReplayCleanupQueueTestHook: (@Sendable () -> Void)? + + init( + generation: DoryFSWorkerGeneration, + shares: [(DoryFSShareCapabilityID, HostFS, DoryFSShareCoherencePolicy)], + exchange: @escaping Exchange, + onFailure: @escaping Failure + ) throws { + self.generation = generation + preactivationEventID = FSEventsGetCurrentEventId() + endpoints = Dictionary(uniqueKeysWithValues: shares.map { capability, hostFS, policy in + ( + capability, + Endpoint( + capability: capability, + hostFS: hostFS, + policy: policy + ) + ) + }) + self.exchange = exchange + self.onFailure = onFailure + } + + deinit { + stop() + } + + var statistics: DoryFSWorkerHostCoherenceStatistics { + lock.withLock { + DoryFSWorkerHostCoherenceStatistics( + running: running && activationComplete, + configuredShareCount: endpoints.count, + invalidationOnlyShareCount: endpoints.values.filter { + $0.policy == .invalidationOnly + }.count, + watcherNudgeShareCount: endpoints.values.filter { + $0.policy == .invalidationAndWatcherNudge + }.count, + requiredObservationShareCount: requiredCapabilities.count, + observedRequiredShareCount: observedCapabilities + .intersection(requiredCapabilities).count, + observationStreamCount: streams.count, + pendingEventCount: pending.values.reduce(0) { $0 + $1.count }, + pendingEventLimit: Self.pendingEventLimit, + receivedEventCount: receivedEventCount, + deliveredBatchCount: deliveredBatchCount, + failedBatchCount: failedBatchCount, + eventLossCount: eventLossCount + ) + } + } + + /// Arms every root stream and drains retained FSEvents history without delivering a batch to + /// the runner. The VM calls this before it starts so there is no observation gap, while guest + /// watcher delivery remains impossible until the guest has explicitly proved readiness. + func prepare() throws { + let shouldPrepare = lock.withLock { () -> Bool in + guard !terminalFailureReported, !running else { return false } + running = true + activationCatchupInProgress = true + return true + } + guard shouldPrepare else { + guard lock.withLock({ + running && !activationCatchupInProgress && !terminalFailureReported + }) else { + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + return + } + do { + for endpoint in endpoints.values.sorted(by: { + $0.capability.rawValue.uuidString < $1.capability.rawValue.uuidString + }) { + try observe( + capability: endpoint.capability, + hostPath: endpoint.root + ) + } + for endpoint in endpoints.values { + endpoint.hostFS.setEventObservationHandler { [weak self] _ in + guard let self, + self.lock.withLock({ + self.running + && self.observedCapabilities.contains(endpoint.capability) + }) else { + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + } + } + // Drain the persistent history from the checkpoint captured at worker bootstrap before + // activation returns. The runner sink is installed at this point, so no preactivation + // host edit can be ACKed, dropped, or delivered into a missing handler window. + flushObservationStreams() + let caughtUp = lock.withLock { () -> Bool in + let ready = running + && requiredCapabilities.isSubset(of: historyCaughtUpCapabilities) + guard ready else { return false } + activationCatchupInProgress = false + observedCapabilities = requiredCapabilities + return true + } + guard caughtUp else { + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + } catch { + failStop(.observationUnavailable) + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + } + + /// Opens delivery only after the VM has completed the guest-watcher protocol handshake. Any + /// mutation retained by `prepare()` is synchronously acknowledged before this method returns, + /// so callers cannot publish workload readiness ahead of coherence catch-up. An event not yet + /// journaled at the replay marker remains on the continuously armed persistent stream; cache + /// safety at readiness does not depend on that callback because the known-inode sweep is ACKed. + func activateDelivery() throws { + activationLock.lock() + defer { activationLock.unlock() } + + let alreadyActive = lock.withLock { activationComplete } + if alreadyActive { return } + + guard lock.withLock({ + running + && !terminalFailureReported + && !activationCatchupInProgress + && requiredCapabilities.isSubset(of: historyCaughtUpCapabilities) + && observedCapabilities == requiredCapabilities + }) else { + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + + // Persistent delivery has no observable high-water mark for a quiet watched root. Create + // one activation-only replay from the worker's sealed checkpoint and require HistoryDone + // for every capability. A filesystem commit may still be awaiting journal ingestion at + // that marker, so readiness also owns a conservative, event-independent cache sweep below. + try replayObservationHistoryForActivation() + + let activationDeadline = Self.saturatingAdd( + DispatchTime.now().uptimeNanoseconds, + DoryFSWorkerCoherenceTiming.activationCatchupNanoseconds + ) + let reconciliationBatches: [DoryFSWorkerCoherenceBatch] + do { + reconciliationBatches = try activationReconciliationBatches() + } catch let error as DoryFSWorkerHostCoherenceError { + failStop(error) + throw error + } catch { + failStop(.planOverflow) + throw DoryFSWorkerHostCoherenceError.planOverflow + } + + let mustFlush = lock.withLock { () -> Bool? in + guard running, + !terminalFailureReported, + !activationCatchupInProgress, + requiredCapabilities.isSubset(of: historyCaughtUpCapabilities), + observedCapabilities == requiredCapabilities else { + return nil + } + if activationComplete { return false } + activationDeliveryInProgress = true + let mustFlush = !pending.isEmpty + if mustFlush { flushScheduled = true } + return mustFlush + } + guard let mustFlush else { + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + do { + for batch in reconciliationBatches { + try deliverRetainingUntilAcknowledged( + batch, + activationDeadlineUptimeNanoseconds: activationDeadline + ) + lock.withLock { + deliveredBatchCount = Self.saturatingAdd(deliveredBatchCount, 1) + } + } + } catch let error as DoryFSWorkerHostCoherenceError { + lock.withLock { + failedBatchCount = Self.saturatingAdd(failedBatchCount, 1) + } + failStop(error) + throw error + } catch { + lock.withLock { + failedBatchCount = Self.saturatingAdd(failedBatchCount, 1) + } + failStop(.acknowledgementUnavailable) + throw DoryFSWorkerHostCoherenceError.acknowledgementUnavailable + } + if mustFlush { + flush(activationDeadlineUptimeNanoseconds: activationDeadline) + } + let committed = lock.withLock { () -> Bool in + guard running, activationDeliveryInProgress, !terminalFailureReported else { + activationDeliveryInProgress = false + return false + } + activationDeliveryInProgress = false + activationComplete = true + return true + } + guard committed else { + throw DoryFSWorkerHostCoherenceError.acknowledgementUnavailable + } + // Events committed after the activation barrier may have accumulated while the retained + // batch was in reverse XPC. They are steady-state work and can now use the normal scheduler. + scheduleFlush() + } + + /// Compatibility helper for same-process embedders and focused tests that do not own a VM + /// readiness boundary. Production composition roots use the two explicit phases above. + func activate() throws { + try prepare() + try activateDelivery() + } + + /// Makes every event that already reached FSEvents observable before returning. Activation + /// uses this as its catch-up barrier; focused tests also use it to avoid timing the daemon's + /// normal batching latency when asserting exact same-PID suppression and external delivery. + func flushObservationStreams() { + let active = lock.withLock { running ? Array(streams.values) : [] } + for stream in active { FSEventStreamFlushSync(stream) } + } + + func stop() { + let retired = lock.withLock { () -> ([FSEventStreamRef], [CallbackBox]) in + running = false + activationCatchupInProgress = false + activationDeliveryInProgress = false + activationComplete = false + flushScheduled = false + pending.removeAll(keepingCapacity: false) + requiredCapabilities.removeAll(keepingCapacity: false) + historyCaughtUpCapabilities.removeAll(keepingCapacity: false) + observedCapabilities.removeAll(keepingCapacity: false) + let retired = (Array(streams.values), Array(callbackBoxes.values)) + streams.removeAll(keepingCapacity: false) + callbackBoxes.removeAll(keepingCapacity: false) + return retired + } + for endpoint in endpoints.values { + endpoint.hostFS.setEventObservationHandler(nil) + } + for stream in retired.0 { + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + } + _ = retired.1 + } + + private func observe( + capability: DoryFSShareCapabilityID, + hostPath: String + ) throws { + guard let endpoint = endpoints[capability] else { + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + let observationRoot: String + let expectedIdentity: HostFSEventPathIdentity + do { + observationRoot = try endpoint.hostFS.eventObservationRoot( + forHostPath: hostPath + ) + expectedIdentity = try endpoint.hostFS.eventObservationIdentity( + forRootPath: observationRoot + ) + guard try Self.pathnameIdentity(observationRoot) == expectedIdentity else { + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + } catch let error as DoryFSWorkerHostCoherenceError { + failStop(error) + throw error + } catch { + failStop(.observationUnavailable) + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + let key = capability.rawValue.uuidString + ":" + observationRoot + let alreadyObserved = lock.withLock { () -> Bool in + requiredCapabilities.insert(capability) + return streams[key] != nil + } + if alreadyObserved { return } + guard lock.withLock({ running }) else { + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + + let box = CallbackBox(relay: self, capability: capability) + var context = FSEventStreamContext( + version: 0, + info: Unmanaged.passUnretained(box).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + guard let stream = FSEventStreamCreate( + nil, + Self.streamCallback, + &context, + [observationRoot] as CFArray, + preactivationEventID, + 0.05, + Self.streamFlags + ) else { + failStop(.observationUnavailable) + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + FSEventStreamSetDispatchQueue(stream, streamQueue) + guard FSEventStreamStart(stream) else { + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + failStop(.observationUnavailable) + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + do { + guard try Self.pathnameIdentity(observationRoot) == expectedIdentity, + try endpoint.hostFS.eventObservationIdentity( + forRootPath: observationRoot + ) == expectedIdentity else { + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + } catch { + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + failStop(.observationUnavailable) + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + let accepted = lock.withLock { () -> Bool in + guard running, streams[key] == nil else { return false } + streams[key] = stream + callbackBoxes[key] = box + return true + } + if !accepted { + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + guard lock.withLock({ running && streams[key] != nil }) else { + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + } + } + + /// Replays the journal history observable for this worker generation into the same retained + /// pending map and waits for each stream's HistoryDone marker. Persistent streams remain armed + /// throughout, so later journal ingestion continues as steady-state delivery without an + /// observation gap. Duplicate callbacks coalesce by capability and path before delivery. + private func replayObservationHistoryForActivation() throws { + let capabilities = lock.withLock { requiredCapabilities } + let orderedEndpoints = endpoints.values.filter { + capabilities.contains($0.capability) + }.sorted { + $0.capability.rawValue.uuidString < $1.capability.rawValue.uuidString + } + var replays = [ActivationReplayStream]() + defer { + for replay in replays { + FSEventStreamStop(replay.stream) + FSEventStreamInvalidate(replay.stream) + } + // CoreServices retains only the raw context pointer. Invalidation prevents new + // callbacks, but callbacks already submitted to this dispatch queue may still use it. + // Drain a marker enqueued after invalidation before releasing streams and boxes. + if let hook = activationReplayCleanupQueueTestHook { + streamQueue.async(execute: hook) + } + streamQueue.sync {} + for replay in replays { + FSEventStreamRelease(replay.stream) + _ = replay.box + } + } + + do { + for endpoint in orderedEndpoints { + let observationRoot = try endpoint.hostFS.eventObservationRoot( + forHostPath: endpoint.root + ) + let expectedIdentity = try endpoint.hostFS.eventObservationIdentity( + forRootPath: observationRoot + ) + guard try Self.pathnameIdentity(observationRoot) == expectedIdentity else { + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + + let completion = ActivationHistoryCompletion() + let box = CallbackBox( + relay: self, + capability: endpoint.capability, + historyCompletion: completion.complete + ) + var context = FSEventStreamContext( + version: 0, + info: Unmanaged.passUnretained(box).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + guard let stream = FSEventStreamCreate( + nil, + Self.streamCallback, + &context, + [observationRoot] as CFArray, + preactivationEventID, + 0, + Self.streamFlags + ) else { + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + FSEventStreamSetDispatchQueue(stream, streamQueue) + guard FSEventStreamStart(stream) else { + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + replays.append(ActivationReplayStream( + stream: stream, + box: box, + completion: completion, + endpoint: endpoint, + observationRoot: observationRoot, + expectedIdentity: expectedIdentity + )) + } + + for replay in replays { FSEventStreamFlushSync(replay.stream) } + streamQueue.sync {} + + guard lock.withLock({ running && !terminalFailureReported }), + replays.allSatisfy({ $0.completion.isComplete }) else { + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + for replay in replays { + guard try Self.pathnameIdentity(replay.observationRoot) + == replay.expectedIdentity, + try replay.endpoint.hostFS.eventObservationIdentity( + forRootPath: replay.observationRoot + ) == replay.expectedIdentity else { + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + } + } catch let error as DoryFSWorkerHostCoherenceError { + failStop(error) + throw error + } catch { + failStop(.observationUnavailable) + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + } + + /// Invalidates every inode identity already known to the guest before readiness. This sweep is + /// deliberately independent of FSEvents callback timing: pre-activation FUSE replies are + /// fail-closed to zero validity, and this final acknowledged invalidation prevents a positive + /// lookup or open inode from carrying stale content across the activation boundary. Newly + /// unknown names have no negative dentry grant before activation and are discovered normally. + private func activationReconciliationBatches() throws -> [DoryFSWorkerCoherenceBatch] { + var batches = [DoryFSWorkerCoherenceBatch]() + for endpoint in endpoints.values.sorted(by: { + $0.capability.rawValue.uuidString < $1.capability.rawValue.uuidString + }) { + let keyedInvalidations = endpoint.hostFS.knownNodeIDsForLossRecovery().map { + ( + key: "i:\($0)", + value: DoryFSWorkerCoherenceInvalidation.inode( + nodeID: $0, + offset: 0, + length: -1 + ) + ) + }.sorted { $0.key < $1.key } + var start = 0 + while start < keyedInvalidations.count { + let end = min( + keyedInvalidations.count, + start + DoryFSWorkerCoherenceCodec.maximumInvalidations + ) + let batchID = lock.withLock { () -> UInt64 in + let value = nextBatchID + nextBatchID = value == UInt64.max ? 1 : value + 1 + return value + } + do { + batches.append(try DoryFSWorkerCoherenceBatch( + generation: generation, + shareCapabilityID: endpoint.capability, + batchID: batchID, + invalidations: keyedInvalidations[start.. HostFSEventPathIdentity { + let descriptor = Darwin.open( + path, + O_EVTONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard descriptor >= 0 else { + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + defer { Darwin.close(descriptor) } + var status = stat() + guard fstat(descriptor, &status) == 0 else { + throw DoryFSWorkerHostCoherenceError.observationUnavailable + } + return HostFSEventPathIdentity( + device: UInt64(truncatingIfNeeded: status.st_dev), + inode: UInt64(truncatingIfNeeded: status.st_ino), + generation: UInt64(truncatingIfNeeded: status.st_gen) + ) + } + + private func record(_ changes: [Change], capability: DoryFSShareCapabilityID) { + guard !changes.isEmpty, endpoints[capability] != nil else { return } + var terminal: DoryFSWorkerHostCoherenceError? + lock.withLock { + guard running else { return } + receivedEventCount = Self.saturatingAdd(receivedEventCount, UInt64(changes.count)) + if changes.contains(where: \.requiresFailStop) { + eventLossCount = Self.saturatingAdd(eventLossCount, 1) + terminal = .eventLoss + return + } + for change in changes { + let currentCount = pending.values.reduce(0) { $0 + $1.count } + if pending[capability]?[change.path] == nil, + currentCount >= Self.pendingEventLimit { + eventLossCount = Self.saturatingAdd(eventLossCount, 1) + terminal = .pendingOverflow(limit: Self.pendingEventLimit) + return + } + if var existing = pending[capability]?[change.path] { + existing.flags |= change.flags + existing.eventID = max(existing.eventID, change.eventID) + existing.directoryAggregate = existing.directoryAggregate || change.directoryAggregate + pending[capability]?[change.path] = existing + } else { + pending[capability, default: [:]][change.path] = change + } + } + } + if let terminal { + failStop(terminal) + } else { + scheduleFlush() + } + } + + private func scheduleFlush() { + let shouldSchedule = lock.withLock { () -> Bool in + guard running, + !activationCatchupInProgress, + activationComplete, + !flushScheduled, + !pending.isEmpty else { return false } + flushScheduled = true + return true + } + guard shouldSchedule else { return } + Task.detached(priority: .userInitiated) { [weak self] in + try? await Task.sleep(nanoseconds: 1_000_000) + self?.flush() + } + } + + private func flush( + activationDeadlineUptimeNanoseconds: UInt64? = nil + ) { + let batches = lock.withLock { () -> [(Endpoint, [Change])] in + guard running, activationComplete || activationDeliveryInProgress else { return [] } + let batches = pending.compactMap { capability, byPath -> (Endpoint, [Change])? in + guard let endpoint = endpoints[capability] else { return nil } + return (endpoint, byPath.values.sorted { + $0.path == $1.path ? $0.eventID < $1.eventID : $0.path < $1.path + }) + }.sorted { $0.0.capability.rawValue.uuidString < $1.0.capability.rawValue.uuidString } + pending.removeAll(keepingCapacity: true) + return batches + } + + do { + for (endpoint, changes) in batches { + if let batch = try plan(changes, endpoint: endpoint) { + try deliverRetainingUntilAcknowledged( + batch, + activationDeadlineUptimeNanoseconds: activationDeadlineUptimeNanoseconds + ) + lock.withLock { + deliveredBatchCount = Self.saturatingAdd(deliveredBatchCount, 1) + } + } + } + lock.withLock { + flushScheduled = false + } + scheduleFlush() + } catch let error as DoryFSWorkerHostCoherenceError { + lock.withLock { + failedBatchCount = Self.saturatingAdd(failedBatchCount, 1) + flushScheduled = false + } + failStop(error) + } catch { + lock.withLock { + failedBatchCount = Self.saturatingAdd(failedBatchCount, 1) + flushScheduled = false + } + failStop(.acknowledgementUnavailable) + } + } + + private func deliverRetainingUntilAcknowledged( + _ batch: DoryFSWorkerCoherenceBatch, + activationDeadlineUptimeNanoseconds: UInt64? + ) throws { + let exactFrame: Data + do { + exactFrame = try DoryFSWorkerCoherenceCodec.encode(batch) + } catch { + throw DoryFSWorkerHostCoherenceError.planOverflow + } + let expected = try DoryFSWorkerCoherenceAcknowledgement(accepting: batch) + for _ in 0..= activationDeadlineUptimeNanoseconds { + throw DoryFSWorkerHostCoherenceError.acknowledgementUnavailable + } + do { + let reply = try exchange(exactFrame) + guard try DoryFSWorkerCoherenceCodec.decodeAcknowledgement(reply) == expected else { + throw DoryFSWorkerHostCoherenceError.invalidAcknowledgement + } + return + } catch let error as DoryFSWorkerHostCoherenceError { + if error == .invalidAcknowledgement { throw error } + } catch { + continue + } + } + throw DoryFSWorkerHostCoherenceError.acknowledgementUnavailable + } + + private func plan( + _ incoming: [Change], + endpoint: Endpoint + ) throws -> DoryFSWorkerCoherenceBatch? { + var exactByPath = [String: Change]() + for change in incoming { + guard endpoint.contains(change.path) else { continue } + if var existing = exactByPath[change.path] { + existing.flags |= change.flags + existing.eventID = max(existing.eventID, change.eventID) + existing.directoryAggregate = existing.directoryAggregate || change.directoryAggregate + exactByPath[change.path] = existing + } else { + exactByPath[change.path] = change + } + } + + let namespaceMask = UInt32( + kFSEventStreamEventFlagItemCreated | + kFSEventStreamEventFlagItemRemoved | + kFSEventStreamEventFlagItemRenamed + ) + if exactByPath.values.contains(where: { $0.flags & namespaceMask != 0 }) { + let eventID = exactByPath.values.map(\.eventID).max() ?? 0 + let flags = UInt32( + kFSEventStreamEventFlagItemRemoved | + kFSEventStreamEventFlagItemRenamed + ) + for path in endpoint.hostFS.knownStaleHostPathsForNamespaceReconciliation() + where exactByPath[path] == nil { + exactByPath[path] = Change( + path: path, + flags: flags, + eventID: eventID, + directoryAggregate: false + ) + } + } + + var expanded = [String: Change]() + let directoryFlags = UInt32( + kFSEventStreamEventFlagItemModified | + kFSEventStreamEventFlagItemInodeMetaMod + ) + for change in exactByPath.values { + let changes: [Change] + if change.directoryAggregate { + changes = endpoint.hostFS.knownHostPaths(inHostDirectory: change.path).map { + Change( + path: $0, + flags: directoryFlags, + eventID: change.eventID, + directoryAggregate: false + ) + } + } else { + changes = [change] + } + for exact in changes { + if var existing = expanded[exact.path] { + existing.flags |= exact.flags + existing.eventID = max(existing.eventID, exact.eventID) + expanded[exact.path] = existing + } else { + expanded[exact.path] = exact + } + } + } + + var invalidations = [String: DoryFSWorkerCoherenceInvalidation]() + var deletedNodeIDs = Set() + var nudgePaths = Set() + for change in expanded.values.sorted(by: { $0.path < $1.path }) { + let aliasPaths = change.flags & UInt32(kFSEventStreamEventFlagItemRenamed) != 0 + ? [change.path] + : endpoint.hostFS.knownIdentityAliasHostPaths(forHostPath: change.path) + for aliasPath in aliasPaths { + if let snapshot = endpoint.hostFS.invalidationSnapshot(forHostPath: aliasPath), + !snapshot.nodeIDs.isEmpty || !snapshot.parentNodeIDs.isEmpty { + let pathIsMissing: Bool + do { + pathIsMissing = try endpoint.hostFS.eventPathIsMissing( + forHostPath: aliasPath + ) + } catch { + throw DoryFSWorkerHostCoherenceError.capabilityAuthorityUnavailable + } + let planned = Self.plannedInvalidations( + change: Change( + path: aliasPath, + flags: change.flags, + eventID: change.eventID, + directoryAggregate: false + ), + snapshot: snapshot, + pathIsMissing: pathIsMissing, + permitsContentInvalidation: aliasPath == change.path + ) + endpoint.hostFS.reconcileHostInvalidation( + forHostPath: aliasPath, + staleNodeIDs: snapshot.staleNodeIDs + ) + for (key, invalidation) in planned { + if case .delete(_, let childNodeID, _) = invalidation { + deletedNodeIDs.insert(childNodeID) + invalidations.removeValue(forKey: "i:\(childNodeID)") + } + if case .inode(let nodeID, _, _) = invalidation, + deletedNodeIDs.contains(nodeID) { + continue + } + invalidations[key] = Self.merge( + invalidation, + preserving: invalidations[key] + ) + } + } + // A newly created host path has no guest inode snapshot yet. Watcher delivery must + // still name it so Linux can discover the namespace mutation; invalidation and + // nudge planning intentionally have different preconditions. + if endpoint.watcherNudgesEnabled { + do { + if let relative = try endpoint.hostFS + .nearestEventNudgeRelativePath(forHostPath: aliasPath) { + nudgePaths.insert(relative) + } + } catch { + throw DoryFSWorkerHostCoherenceError.capabilityAuthorityUnavailable + } + } + } + } + for nodeID in deletedNodeIDs { + invalidations["i:\(nodeID)"] = .inode(nodeID: nodeID, offset: -1, length: 0) + } + guard !invalidations.isEmpty || !nudgePaths.isEmpty else { return nil } + let batchID = lock.withLock { () -> UInt64 in + let value = nextBatchID + nextBatchID = value == UInt64.max ? 1 : value + 1 + return value + } + do { + return try DoryFSWorkerCoherenceBatch( + generation: generation, + shareCapabilityID: endpoint.capability, + batchID: batchID, + invalidations: invalidations.keys.sorted().compactMap { invalidations[$0] }, + nudgeRelativePaths: nudgePaths.sorted() + ) + } catch { + throw DoryFSWorkerHostCoherenceError.planOverflow + } + } + + private static func plannedInvalidations( + change: Change, + snapshot: HostFSInvalidationSnapshot, + pathIsMissing: Bool, + permitsContentInvalidation: Bool + ) -> [String: DoryFSWorkerCoherenceInvalidation] { + var result = [String: DoryFSWorkerCoherenceInvalidation]() + let namespaceMask = UInt32( + kFSEventStreamEventFlagItemCreated | + kFSEventStreamEventFlagItemRemoved | + kFSEventStreamEventFlagItemRenamed + ) + let deletionCandidates: Set + if change.representsRemoval(pathIsMissing: pathIsMissing) + || change.flags & namespaceMask != 0 { + deletionCandidates = Set(snapshot.staleNodeIDs + snapshot.unverifiedNodeIDs) + } else { + deletionCandidates = Set(snapshot.staleNodeIDs) + } + let renameSource = change.representsRemoval(pathIsMissing: pathIsMissing) + && change.flags & UInt32(kFSEventStreamEventFlagItemRenamed) != 0 + let survivingLinks = Set(snapshot.survivingLinkNodeIDs) + let nonFinalUnlinks = renameSource + ? Set() + : deletionCandidates.intersection(survivingLinks) + let deleteNodeIDs = renameSource + ? deletionCandidates + : deletionCandidates.subtracting(survivingLinks) + let staleNodeIDs = Set(snapshot.staleNodeIDs) + let invalidatesContent = permitsContentInvalidation + && change.flags & UInt32(kFSEventStreamEventFlagItemModified) != 0 + + for nodeID in snapshot.nodeIDs where !deleteNodeIDs.contains(nodeID) { + if nonFinalUnlinks.contains(nodeID) { + result["i:\(nodeID)"] = .inode(nodeID: nodeID, offset: -1, length: 0) + } else if !staleNodeIDs.contains(nodeID) { + result["i:\(nodeID)"] = invalidatesContent + ? .inode(nodeID: nodeID, offset: 0, length: -1) + : .inode(nodeID: nodeID, offset: -1, length: 0) + } + } + for nodeID in deleteNodeIDs { + result["i:\(nodeID)"] = .inode(nodeID: nodeID, offset: -1, length: 0) + } + guard let name = snapshot.entryName else { return result } + for parentNodeID in snapshot.parentNodeIDs { + for childNodeID in deleteNodeIDs.sorted() { + result["d:\(parentNodeID):\(childNodeID):\(name)"] = .delete( + parentNodeID: parentNodeID, + childNodeID: childNodeID, + name: name + ) + } + } + let identityMayHaveChanged = !snapshot.staleNodeIDs.isEmpty + || !snapshot.unverifiedNodeIDs.isEmpty + let shouldInvalidateEntry = change.representsRemoval(pathIsMissing: pathIsMissing) + ? !nonFinalUnlinks.isEmpty + : identityMayHaveChanged + if shouldInvalidateEntry, !snapshot.nodeIDs.isEmpty { + for parentNodeID in snapshot.parentNodeIDs { + result["e:\(parentNodeID):\(name)"] = .entry( + parentNodeID: parentNodeID, + name: name, + flags: 0 + ) + } + } + return result + } + + private static func merge( + _ incoming: DoryFSWorkerCoherenceInvalidation, + preserving existing: DoryFSWorkerCoherenceInvalidation? + ) -> DoryFSWorkerCoherenceInvalidation { + guard let existing else { return incoming } + if case .inode(_, 0, -1) = existing, + case .inode(_, -1, 0) = incoming { + return existing + } + return incoming + } + + private func failStop(_ error: DoryFSWorkerHostCoherenceError) { + let shouldReport = lock.withLock { () -> Bool in + guard !terminalFailureReported else { return false } + terminalFailureReported = true + running = false + return true + } + guard shouldReport else { return } + // Revoke the event sources before surfacing terminal loss. Production exits the worker; + // embedders and tests still get deterministic cleanup instead of live orphan streams. + stop() + onFailure(error) + } + + private static func saturatingAdd(_ lhs: UInt64, _ rhs: UInt64) -> UInt64 { + let (value, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? UInt64.max : value + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerProcessResources.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerProcessResources.swift new file mode 100644 index 00000000..6fbfb1b9 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerProcessResources.swift @@ -0,0 +1,42 @@ +import Darwin + +public enum DoryFSWorkerProcessResourceError: Error, Equatable, Sendable { + case readFileDescriptorLimit(Int32) + case updateFileDescriptorLimit(Int32) +} + +/// Establishes the descriptor ceiling in the process that actually owns filesystem roots, inode +/// identity pins, open file/directory handles, and advisory-lock descriptors. Raising the VMM's +/// limit cannot affect an XPC service, because resource limits are process-local. +public enum DoryFSWorkerProcessResources { + public static let fileDescriptorCeiling: rlim_t = 262_144 + + static func desiredFileDescriptorSoftLimit( + current: rlim_t, + hard: rlim_t, + ceiling: rlim_t = fileDescriptorCeiling + ) -> rlim_t { + max(current, min(hard, ceiling)) + } + + @discardableResult + public static func raiseFileDescriptorSoftLimit() throws -> rlim_t { + var limit = rlimit() + guard getrlimit(RLIMIT_NOFILE, &limit) == 0 else { + let savedErrno = errno + throw DoryFSWorkerProcessResourceError.readFileDescriptorLimit(savedErrno) + } + + let desired = desiredFileDescriptorSoftLimit( + current: limit.rlim_cur, + hard: limit.rlim_max + ) + guard desired > limit.rlim_cur else { return limit.rlim_cur } + limit.rlim_cur = desired + guard setrlimit(RLIMIT_NOFILE, &limit) == 0 else { + let savedErrno = errno + throw DoryFSWorkerProcessResourceError.updateFileDescriptorLimit(savedErrno) + } + return desired + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerRootAuthority.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerRootAuthority.swift new file mode 100644 index 00000000..d43aedba --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerRootAuthority.swift @@ -0,0 +1,250 @@ +import Darwin +import DoryFSWorkerContracts +import Foundation + +/// Fail-closed errors raised while converting the exact bootstrap envelope and XPC-transferred +/// directory descriptors into pinned worker authority. Errors identify shares only by their +/// unforgeable capability; host paths and process-local descriptor numbers never cross this API. +public enum DoryFSWorkerRootAuthorityError: Error, Equatable, Sendable { + /// A worker process accepts exactly one bootstrap attempt. A failed attempt is terminal too; + /// the supervisor must replace the process instead of substituting another authority set. + case bootstrapAlreadyAttempted + case bootstrapNotAccepted + case descriptorCountMismatch(expected: Int, actual: Int) + case rootDescriptorUnavailable(DoryFSShareCapabilityID, errno: Int32) + case rootInspectionFailed(DoryFSShareCapabilityID, errno: Int32) + case rootIsNotDirectory(DoryFSShareCapabilityID) + case rootIdentityMismatch(DoryFSShareCapabilityID) + case unknownCapability(DoryFSShareCapabilityID) + case descriptorBorrowFailed(DoryFSShareCapabilityID, errno: Int32) +} + +/// Owns the immutable share roots for one signed worker process. +/// +/// The public surface deliberately has no URL, path, bookmark, mutation, or root-enumeration API. +/// Bootstrap consumes already-open directory descriptors transferred by XPC, duplicates them with +/// close-on-exec, and independently checks the sealed device/inode/generation identity. The sole +/// authority-use seam is a synchronous descriptor borrow selected by typed capability. +/// +/// The worker deliberately shares the runner's host filesystem namespace. Darwin App Sandbox does +/// not treat an inherited directory descriptor as authority to open its descendants, and an +/// unsandboxed runner cannot mint a Powerbox grant for another sandbox identity. Applying App +/// Sandbox here would therefore make every valid `openat` fail with `EPERM`; confinement is instead +/// the exact signed-XPC, one-shot descriptor, no-path, no-follow capability boundary below. +public final class DoryFSWorkerRootAuthority: @unchecked Sendable { + private enum Lifecycle { + case uninitialized + case resolving + case accepted([DoryFSShareCapabilityID: OwnedRoot]) + case failed + } + + private static let processBootstrapAdmission = DoryFSWorkerBootstrapAdmission() + + private let bootstrapAdmission: DoryFSWorkerBootstrapAdmission + private let stateLock = NSLock() + private var lifecycle: Lifecycle = .uninitialized + + /// Uses the process-wide one-shot gate. Production callers cannot substitute path reopening or + /// opt out of the descriptor identity check. + public init() { + bootstrapAdmission = Self.processBootstrapAdmission + } + + /// Decodes and consumes one exact bootstrap envelope, returning its exact receipt bytes only + /// after every transferred descriptor has been duplicated and matched to its sealed identity. + /// + /// Any error permanently consumes the process bootstrap attempt. Every duplicate acquired by + /// the failed transaction is synchronously released before the error is returned. + public func bootstrap( + exactBytes: Data, + rootDescriptors: [FileHandle] + ) throws -> Data { + guard bootstrapAdmission.claim() else { + throw DoryFSWorkerRootAuthorityError.bootstrapAlreadyAttempted + } + setLifecycle(.resolving) + + var acquired = [OwnedRoot]() + do { + let bootstrap = try DoryFSWorkerBootstrapCodec.decode(exactBytes) + guard rootDescriptors.count == bootstrap.shares.count else { + throw DoryFSWorkerRootAuthorityError.descriptorCountMismatch( + expected: bootstrap.shares.count, + actual: rootDescriptors.count + ) + } + acquired.reserveCapacity(bootstrap.shares.count) + for share in bootstrap.shares { + acquired.append(try acquireRoot(for: share, from: rootDescriptors)) + } + + var roots = [DoryFSShareCapabilityID: OwnedRoot]( + minimumCapacity: acquired.count + ) + for root in acquired { + // Duplicate capabilities and descriptor indices are rejected by the exact codec. + // Retain this invariant locally so future codec versions cannot overwrite roots. + guard roots.updateValue(root, forKey: root.capabilityID) == nil else { + throw DoryFSWorkerRootAuthorityError.rootIdentityMismatch(root.capabilityID) + } + } + setLifecycle(.accepted(roots)) + acquired.removeAll(keepingCapacity: false) + return DoryFSWorkerBootstrapCodec.encode( + DoryFSWorkerBootstrapReceipt(accepting: bootstrap) + ) + } catch { + for root in acquired.reversed() { + root.release() + } + setLifecycle(.failed) + throw error + } + } + + /// Borrows the pinned directory for one accepted capability during `body` only. + /// + /// The callback is nonescaping and cannot return the descriptor. The temporary duplicate is + /// always closed on callback exit, including thrown exits. A consumer that needs longer-lived + /// authority must duplicate it explicitly as part of its own bounded lifetime. + public func withBorrowedRootFileDescriptor( + for capabilityID: DoryFSShareCapabilityID, + _ body: (Int32) throws -> Result + ) throws -> Result { + let root = try acceptedRoot(for: capabilityID) + let borrowed = root.duplicateForBorrow() + guard borrowed >= 0 else { + throw DoryFSWorkerRootAuthorityError.descriptorBorrowFailed( + capabilityID, + errno: errno + ) + } + defer { _ = Darwin.close(borrowed) } + return try body(borrowed) + } + + // Focused tests use a fresh one-shot gate per scenario. Descriptor acquisition itself is not + // injectable: tests exercise the same Darwin duplication and inspection path as production. + init(bootstrapAdmission: DoryFSWorkerBootstrapAdmission) { + self.bootstrapAdmission = bootstrapAdmission + } + + private func acquireRoot( + for share: DoryFSShareBootstrapAuthority, + from descriptors: [FileHandle] + ) throws -> OwnedRoot { + let index = Int(share.rootDescriptorIndex) + guard descriptors.indices.contains(index) else { + throw DoryFSWorkerRootAuthorityError.descriptorCountMismatch( + expected: index + 1, + actual: descriptors.count + ) + } + + let duplicate = fcntl(descriptors[index].fileDescriptor, F_DUPFD_CLOEXEC, 0) + guard duplicate >= 0 else { + throw DoryFSWorkerRootAuthorityError.rootDescriptorUnavailable( + share.capabilityID, + errno: errno + ) + } + var transferred = false + defer { + if !transferred { _ = Darwin.close(duplicate) } + } + + var status = stat() + guard fstat(duplicate, &status) == 0 else { + throw DoryFSWorkerRootAuthorityError.rootInspectionFailed( + share.capabilityID, + errno: errno + ) + } + guard status.st_mode & S_IFMT == S_IFDIR else { + throw DoryFSWorkerRootAuthorityError.rootIsNotDirectory(share.capabilityID) + } + guard UInt64(truncatingIfNeeded: status.st_dev) + == share.expectedRootIdentity.device, + UInt64(truncatingIfNeeded: status.st_ino) + == share.expectedRootIdentity.inode, + UInt64(truncatingIfNeeded: status.st_gen) + == share.expectedRootIdentity.generation else { + throw DoryFSWorkerRootAuthorityError.rootIdentityMismatch(share.capabilityID) + } + + transferred = true + return OwnedRoot(capabilityID: share.capabilityID, descriptor: duplicate) + } + + private func acceptedRoot( + for capabilityID: DoryFSShareCapabilityID + ) throws -> OwnedRoot { + stateLock.lock() + defer { stateLock.unlock() } + guard case .accepted(let roots) = lifecycle else { + throw DoryFSWorkerRootAuthorityError.bootstrapNotAccepted + } + guard let root = roots[capabilityID] else { + throw DoryFSWorkerRootAuthorityError.unknownCapability(capabilityID) + } + return root + } + + private func setLifecycle(_ newValue: Lifecycle) { + stateLock.lock() + lifecycle = newValue + stateLock.unlock() + } +} + +final class DoryFSWorkerBootstrapAdmission: @unchecked Sendable { + private let lock = NSLock() + private var consumed = false + + func claim() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !consumed else { return false } + consumed = true + return true + } +} + +private final class OwnedRoot: @unchecked Sendable { + let capabilityID: DoryFSShareCapabilityID + + private let releaseLock = NSLock() + private var descriptor: Int32 + + init(capabilityID: DoryFSShareCapabilityID, descriptor: Int32) { + self.capabilityID = capabilityID + self.descriptor = descriptor + } + + func duplicateForBorrow() -> Int32 { + releaseLock.lock() + defer { releaseLock.unlock() } + guard descriptor >= 0 else { + errno = EBADF + return -1 + } + return fcntl(descriptor, F_DUPFD_CLOEXEC, 0) + } + + func release() { + releaseLock.lock() + guard descriptor >= 0 else { + releaseLock.unlock() + return + } + let ownedDescriptor = descriptor + descriptor = -1 + releaseLock.unlock() + _ = Darwin.close(ownedDescriptor) + } + + deinit { + release() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerService.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerService.swift new file mode 100644 index 00000000..b3453aff --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/DoryFSWorkerService.swift @@ -0,0 +1,682 @@ +import Darwin +import DoryFSWorkerContracts +import Foundation + +/// Data-only execution boundary embedded by the signed XPC service target. This type owns the +/// complete HostFS/FuseServer graph; callers can bootstrap, exchange exact frames, or send +/// priority one-way control frames, but can never obtain a path, descriptor, or server object. +public final class DoryFSWorkerService: @unchecked Sendable { + public typealias CoherenceExchange = @Sendable (Data) throws -> Data + public typealias CoherenceFailureHandler = @Sendable ( + DoryFSWorkerHostCoherenceError + ) -> Void + + private enum Lifecycle { + case awaitingBootstrap + case active(Workspace) + case failed + } + + private final class Workspace: @unchecked Sendable { + let generation: DoryFSWorkerGeneration + let limits: DoryFSWorkerLimits + let shares: [DoryFSShareCapabilityID: Share] + let hostCoherence: DoryFSWorkerHostCoherence? + + init( + generation: DoryFSWorkerGeneration, + limits: DoryFSWorkerLimits, + shares: [DoryFSShareCapabilityID: Share], + hostCoherence: DoryFSWorkerHostCoherence? + ) { + self.generation = generation + self.limits = limits + self.shares = shares + self.hostCoherence = hostCoherence + } + } + + private final class Share: @unchecked Sendable { + private enum State { + case active + case draining + case drained + case invalidated + } + + private struct Reservation { + let request: DoryFSWorkerRequest + let requestBytes: Int + let responseBytes: Int + } + + private struct PendingPublication { + let reservation: Reservation + let opcode: FuseOpcode? + let response: [UInt8] + } + + let capabilityID: DoryFSShareCapabilityID + let generation: DoryFSWorkerGeneration + let workerLimits: DoryFSWorkerLimits + let shareLimits: DoryFSShareResourceLimits + let hostFS: HostFS + let server: FuseServer + + private let lock = NSLock() + private var state: State = .active + private var activeByRequestID = [UInt64: Reservation]() + private var requestIDByCorrelationID = [UInt64: UInt64]() + private var pendingByRequestID = [UInt64: PendingPublication]() + private var aggregateRequestBytes = 0 + private var aggregateResponseBytes = 0 + private var destroyPublicationCommitted = false + private var resetCompleted = false + + init( + authority: DoryFSShareBootstrapAuthority, + generation: DoryFSWorkerGeneration, + workerLimits: DoryFSWorkerLimits, + hostFS: HostFS, + server: FuseServer + ) { + capabilityID = authority.capabilityID + self.generation = generation + self.workerLimits = workerLimits + shareLimits = authority.resourceLimits + self.hostFS = hostFS + self.server = server + } + + func execute(_ request: DoryFSWorkerRequest) -> DoryFSWorkerServiceFrame { + // Materialize the bounded FUSE payload once. Validation and execution consume the same + // immutable bytes; the former implementation rebuilt a second payload-sized Array for + // every admitted request. + let requestBytes = [UInt8](request.payload) + guard let opcode = validatedFUSEOpcode(request, bytes: requestBytes) else { + return reply(to: request, outcome: .rejected(.invalidRequest)) + } + let rejection = admit(request, opcode: opcode) + if let rejection { + return reply(to: request, outcome: .rejected(rejection)) + } + + let response = server.handle(request: requestBytes) + + let completion = finishExecution( + request: request, + opcode: opcode, + response: response + ) + switch completion { + case .accepted(let response): + return reply(to: request, outcome: .completed(Data(response))) + case .rejected(let code): + return reply(to: request, outcome: .rejected(code)) + } + } + + func interrupt(_ interrupt: DoryFSWorkerInterrupt) { + let admitted = lock.withLock { + guard state == .active || state == .draining, + interrupt.generation == generation, + interrupt.shareCapabilityID == capabilityID, + let requestID = requestIDByCorrelationID[interrupt.targetCorrelationID], + requestID == interrupt.targetRequestID else { return false } + return true + } + if admitted { + server.interrupt(requestUnique: interrupt.targetCorrelationID) + } + } + + func acknowledge( + _ publication: DoryFSWorkerPublication, + committed: Bool + ) { + var shouldReset = false + let pending: PendingPublication? = lock.withLock { + guard state == .active || state == .draining, + publication.generation == generation, + publication.shareCapabilityID == capabilityID, + let pending = pendingByRequestID[publication.requestID], + pending.reservation.request.correlationID == publication.correlationID else { + return nil + } + pendingByRequestID.removeValue(forKey: publication.requestID) + releaseReservationLocked(pending.reservation) + if committed, pending.opcode == .destroy { + destroyPublicationCommitted = true + } + shouldReset = completeDestroyIfQuiescentLocked() + return pending + } + guard let pending else { return } + if committed { + if pending.opcode == .initOp, + let header = try? FuseProtocol.decodeOutHeader(pending.response), + header.error == 0 { + server.markFuseInitCompleted() + } + } else if let opcode = pending.opcode { + server.rollbackUnpublishedResponse(opcode: opcode, response: pending.response) + } + if shouldReset { resetIfNeeded() } + } + + func drain(_ drain: DoryFSWorkerDrain) -> DoryFSWorkerServiceFrame? { + let accepted = lock.withLock { + guard state == .active, + drain.generation == generation, + drain.shareCapabilityID == capabilityID, + DispatchTime.now().uptimeNanoseconds < drain.deadlineUptimeNanoseconds, + activeByRequestID.isEmpty, + pendingByRequestID.isEmpty else { return false } + state = .drained + return true + } + guard accepted else { return nil } + resetIfNeeded() + return .drained(DoryFSWorkerDrained( + generation: generation, + shareCapabilityID: capabilityID + )) + } + + func invalidate(_ invalidation: DoryFSWorkerInvalidation) { + guard invalidation.generation == generation, + invalidation.shareCapabilityID == capabilityID else { return } + let pending: [PendingPublication] = lock.withLock { + guard state != .invalidated else { return [] } + state = .invalidated + let values = Array(pendingByRequestID.values) + pendingByRequestID.removeAll(keepingCapacity: false) + for value in values { + releaseReservationLocked(value.reservation) + } + return values + } + server.cancelAllRequests() + for value in pending { + if let opcode = value.opcode { + server.rollbackUnpublishedResponse(opcode: opcode, response: value.response) + } + } + resetIfQuiescent() + } + + private enum Completion { + case accepted([UInt8]) + case rejected(DoryFSWorkerRejectionCode) + } + + private func admit( + _ request: DoryFSWorkerRequest, + opcode: FuseOpcode + ) -> DoryFSWorkerRejectionCode? { + let now = DispatchTime.now().uptimeNanoseconds + guard request.generation == generation else { return .staleGeneration } + guard request.shareCapabilityID == capabilityID else { return .unknownShare } + guard request.deadlineUptimeNanoseconds > now, + request.deadlineUptimeNanoseconds - now + <= workerLimits.maximumOperationNanoseconds else { + return .deadlineExpired + } + guard request.payload.count <= workerLimits.maximumRequestBytes, + Int(request.responseCapacity) <= workerLimits.maximumResponseBytes, + request.payload.count <= shareLimits.maximumAggregateRequestBytes, + Int(request.responseCapacity) <= shareLimits.maximumAggregateResponseBytes else { + return .resourceExhausted + } + return lock.withLock { + switch state { + case .active: + break + case .draining, .drained: + return .connectionTeardown + case .invalidated: + return .shuttingDown + } + guard activeByRequestID.count + pendingByRequestID.count + < min( + workerLimits.maximumInFlightRequests, + shareLimits.maximumInFlightRequests + ) else { return .resourceExhausted } + guard activeByRequestID[request.requestID] == nil, + pendingByRequestID[request.requestID] == nil, + requestIDByCorrelationID[request.correlationID] == nil else { + return .invalidRequest + } + let (requestTotal, requestOverflow) = aggregateRequestBytes + .addingReportingOverflow(request.payload.count) + let (responseTotal, responseOverflow) = aggregateResponseBytes + .addingReportingOverflow(Int(request.responseCapacity)) + guard !requestOverflow, + !responseOverflow, + requestTotal <= min( + workerLimits.maximumAggregateRequestBytes, + shareLimits.maximumAggregateRequestBytes + ), + responseTotal <= min( + workerLimits.maximumAggregateResponseBytes, + shareLimits.maximumAggregateResponseBytes + ) else { return .resourceExhausted } + let reservation = Reservation( + request: request, + requestBytes: request.payload.count, + responseBytes: Int(request.responseCapacity) + ) + activeByRequestID[request.requestID] = reservation + requestIDByCorrelationID[request.correlationID] = request.requestID + aggregateRequestBytes = requestTotal + aggregateResponseBytes = responseTotal + if opcode == .destroy { + // No request arriving after DESTROY may acquire new host authority. Work that + // was already admitted is allowed to publish before the committed teardown + // resets every FUSE node/handle owned by this share. + state = .draining + } + return nil + } + } + + private func validatedFUSEOpcode( + _ request: DoryFSWorkerRequest, + bytes: [UInt8] + ) -> FuseOpcode? { + guard let header = try? FuseProtocol.decodeInHeader(bytes), + header.unique == request.correlationID, + header.length >= UInt32(FuseInHeader.byteCount), + Int(header.length) == bytes.count, + let opcode = FuseOpcode(rawValue: header.opcode), + opcode.workerOpcodeClass == request.opcodeClass else { return nil } + return opcode + } + + private func finishExecution( + request: DoryFSWorkerRequest, + opcode: FuseOpcode?, + response: [UInt8] + ) -> Completion { + var shouldReset = false + let result: Completion = lock.withLock { + guard let reservation = activeByRequestID.removeValue( + forKey: request.requestID + ) else { + return .rejected(.shuttingDown) + } + guard state == .active || state == .draining, + DispatchTime.now().uptimeNanoseconds + < request.deadlineUptimeNanoseconds else { + releaseReservationLocked(reservation) + shouldReset = state == .invalidated && activeByRequestID.isEmpty + return .rejected( + state == .invalidated ? .shuttingDown : .deadlineExpired + ) + } + guard response.count <= Int(request.responseCapacity), + response.count <= workerLimits.maximumResponseBytes else { + state = .invalidated + releaseReservationLocked(reservation) + shouldReset = activeByRequestID.isEmpty + return .rejected(.internalFailure) + } + pendingByRequestID[request.requestID] = PendingPublication( + reservation: reservation, + opcode: opcode, + response: response + ) + return .accepted(response) + } + if case .rejected = result, let opcode { + server.rollbackUnpublishedResponse(opcode: opcode, response: response) + } + if shouldReset { resetIfNeeded() } + return result + } + + private func completeDestroyIfQuiescentLocked() -> Bool { + guard state == .draining, + destroyPublicationCommitted, + activeByRequestID.isEmpty, + pendingByRequestID.isEmpty else { return false } + state = .drained + return true + } + + private func releaseReservationLocked(_ reservation: Reservation) { + requestIDByCorrelationID.removeValue( + forKey: reservation.request.correlationID + ) + precondition(aggregateRequestBytes >= reservation.requestBytes) + precondition(aggregateResponseBytes >= reservation.responseBytes) + aggregateRequestBytes -= reservation.requestBytes + aggregateResponseBytes -= reservation.responseBytes + } + + private func resetIfQuiescent() { + let ready = lock.withLock { + state == .invalidated + && activeByRequestID.isEmpty + && pendingByRequestID.isEmpty + } + if ready { resetIfNeeded() } + } + + private func resetIfNeeded() { + let shouldReset = lock.withLock { + guard !resetCompleted else { return false } + resetCompleted = true + return true + } + if shouldReset { server.resetConnection() } + } + + private func reply( + to request: DoryFSWorkerRequest, + outcome: DoryFSWorkerReplyOutcome + ) -> DoryFSWorkerServiceFrame { + .reply(try! DoryFSWorkerReply( + generation: request.generation, + shareCapabilityID: request.shareCapabilityID, + requestID: request.requestID, + correlationID: request.correlationID, + outcome: outcome + )) + } + } + + private let rootAuthority: DoryFSWorkerRootAuthority + private let coherenceExchange: CoherenceExchange? + private let coherenceFailureHandler: CoherenceFailureHandler + private let lifecycleLock = NSLock() + private var lifecycle: Lifecycle = .awaitingBootstrap + + public init() { + rootAuthority = DoryFSWorkerRootAuthority() + coherenceExchange = nil + coherenceFailureHandler = { _ in } + } + + /// Production XPC adapter initializer. Host-change observation is deliberately opt-in at this + /// composition boundary so pure service-core fixtures cannot accidentally acquire FSEvents + /// authority. The signed worker always supplies both callbacks and exits on any failure. + public init( + coherenceExchange: @escaping CoherenceExchange, + onCoherenceFailure: @escaping CoherenceFailureHandler + ) { + rootAuthority = DoryFSWorkerRootAuthority() + self.coherenceExchange = coherenceExchange + coherenceFailureHandler = onCoherenceFailure + } + + init(rootAuthority: DoryFSWorkerRootAuthority) { + self.rootAuthority = rootAuthority + coherenceExchange = nil + coherenceFailureHandler = { _ in } + } + + public func bootstrap( + exactBytes: Data, + rootDescriptors: [FileHandle] + ) -> Data { + let claimed = lifecycleLock.withLock { () -> Bool in + guard case .awaitingBootstrap = lifecycle else { return false } + lifecycle = .failed + return true + } + guard claimed else { + return encodeRPC(.failure(.bootstrapAlreadyAttempted)) + } + + do { + let bootstrap = try DoryFSWorkerBootstrapCodec.decode(exactBytes) + guard coherenceExchange != nil || bootstrap.shares.allSatisfy({ + $0.coherencePolicy == .disabled + }) else { + // A non-disabled policy is a correctness contract, not an advisory setting. Never + // accept the workspace unless this service composition can carry the worker's + // retained batches to the runner for invalidation and acknowledgement. + return encodeRPC(.failure(.bootstrapRejected)) + } + let receipt = try rootAuthority.bootstrap( + exactBytes: exactBytes, + rootDescriptors: rootDescriptors + ) + var shares = [DoryFSShareCapabilityID: Share]( + minimumCapacity: bootstrap.shares.count + ) + for authority in bootstrap.shares { + let pair = try rootAuthority.withBorrowedRootFileDescriptor( + for: authority.capabilityID + ) { descriptor in + let hostFS = try HostFS( + rootDirectoryFileDescriptor: descriptor, + guestUID: authority.guestIdentity.uid, + guestGID: authority.guestIdentity.gid, + readOnly: authority.readOnly, + hiddenNames: Set(authority.hiddenComponents), + rootHiddenNames: Set(authority.rootHiddenComponents), + resourceLimits: FuseResourceLimits(authority.resourceLimits) + ) + return (hostFS, FuseServer(hostFS: hostFS)) + } + shares[authority.capabilityID] = Share( + authority: authority, + generation: bootstrap.generation, + workerLimits: bootstrap.workerLimits, + hostFS: pair.0, + server: pair.1 + ) + } + let coherenceShares: [( + DoryFSShareCapabilityID, + HostFS, + DoryFSShareCoherencePolicy + )] = bootstrap.shares.compactMap { authority in + guard authority.coherencePolicy != .disabled else { return nil } + return shares[authority.capabilityID].map { + (authority.capabilityID, $0.hostFS, authority.coherencePolicy) + } + } + let hostCoherence: DoryFSWorkerHostCoherence? + if let exchange = coherenceExchange, !coherenceShares.isEmpty { + hostCoherence = try DoryFSWorkerHostCoherence( + generation: bootstrap.generation, + shares: coherenceShares, + exchange: exchange, + onFailure: { [weak self] error in + self?.lifecycleLock.withLock { self?.lifecycle = .failed } + self?.coherenceFailureHandler(error) + } + ) + } else { + hostCoherence = nil + } + let workspace = Workspace( + generation: bootstrap.generation, + limits: bootstrap.workerLimits, + shares: shares, + hostCoherence: hostCoherence + ) + lifecycleLock.withLock { lifecycle = .active(workspace) } + return encodeRPC(.success(receipt)) + } catch let error as DoryFSWorkerRootAuthorityError { + return encodeRPC(.failure(error.bootstrapFailureCode)) + } catch { + return encodeRPC(.failure(.bootstrapRejected)) + } + } + + public func exchange(exactFrame: Data) -> Data { + guard let workspace = activeWorkspace() else { + return encodeRPC(.failure(.bootstrapRequired)) + } + let frame: DoryFSWorkerClientFrame + do { + frame = try DoryFSWorkerFrameCodec.decodeClientFrame( + exactFrame, + maximumFrameBytes: workspace.limits.maximumFrameBytes + ) + } catch { + return encodeRPC(.failure(.invalidEnvelope)) + } + + switch frame { + case .execute(let request): + guard let share = workspace.shares[request.shareCapabilityID] else { + return encodeRPC(.failure(.unknownShare)) + } + return encodeServiceFrame(share.execute(request), limits: workspace.limits) + case .drain(let drain): + guard let share = workspace.shares[drain.shareCapabilityID] else { + return encodeRPC(.failure(.unknownShare)) + } + guard let reply = share.drain(drain) else { + return encodeRPC(.failure(.shuttingDown)) + } + return encodeServiceFrame(reply, limits: workspace.limits) + case .interrupt, .invalidate, .commitPublication, .discardPublication: + return encodeRPC(.failure(.protocolViolation)) + } + } + + public func sendOneWay(exactFrame: Data) { + guard let workspace = activeWorkspace(), + let frame = try? DoryFSWorkerFrameCodec.decodeClientFrame( + exactFrame, + maximumFrameBytes: workspace.limits.maximumFrameBytes + ) else { return } + switch frame { + case .interrupt(let interrupt): + workspace.shares[interrupt.shareCapabilityID]?.interrupt(interrupt) + case .invalidate(let invalidation): + workspace.shares[invalidation.shareCapabilityID]?.invalidate(invalidation) + case .commitPublication(let publication): + workspace.shares[publication.shareCapabilityID]?.acknowledge( + publication, + committed: true + ) + case .discardPublication(let publication): + workspace.shares[publication.shareCapabilityID]?.acknowledge( + publication, + committed: false + ) + case .execute, .drain: + break + } + } + + public var hostCoherenceStatistics: DoryFSWorkerHostCoherenceStatistics? { + activeWorkspace()?.hostCoherence?.statistics + } + + public func coherenceStatusExactBytes() -> Data { + guard let workspace = activeWorkspace() else { return Data() } + let statistics = workspace.hostCoherence?.statistics + let status = try! DoryFSWorkerCoherenceStatus( + generation: workspace.generation, + running: statistics?.running ?? true, + configuredShareCount: UInt32(statistics?.configuredShareCount ?? 0), + invalidationOnlyShareCount: UInt32( + statistics?.invalidationOnlyShareCount ?? 0 + ), + watcherNudgeShareCount: UInt32(statistics?.watcherNudgeShareCount ?? 0), + requiredObservationShareCount: UInt32( + statistics?.requiredObservationShareCount ?? 0 + ), + observedRequiredShareCount: UInt32( + statistics?.observedRequiredShareCount ?? 0 + ), + observationStreamCount: UInt32(statistics?.observationStreamCount ?? 0), + pendingEventCount: UInt32(statistics?.pendingEventCount ?? 0), + pendingEventLimit: UInt32( + statistics?.pendingEventLimit ?? DoryFSWorkerHostCoherence.pendingEventLimit + ), + receivedEventCount: statistics?.receivedEventCount ?? 0, + deliveredBatchCount: statistics?.deliveredBatchCount ?? 0, + failedBatchCount: statistics?.failedBatchCount ?? 0, + eventLossCount: statistics?.eventLossCount ?? 0 + ) + return DoryFSWorkerCoherenceStatusCodec.encode(status) + } + + public func activateCoherenceExactBytes() -> Data { + guard let workspace = activeWorkspace() else { return Data() } + do { + try workspace.hostCoherence?.activateDelivery() + return coherenceStatusExactBytes() + } catch { + lifecycleLock.withLock { lifecycle = .failed } + return Data() + } + } + + public func prepareCoherenceExactBytes() -> Data { + guard let workspace = activeWorkspace() else { return Data() } + do { + try workspace.hostCoherence?.prepare() + return coherenceStatusExactBytes() + } catch { + lifecycleLock.withLock { lifecycle = .failed } + return Data() + } + } + + private func activeWorkspace() -> Workspace? { + lifecycleLock.withLock { + guard case .active(let workspace) = lifecycle else { return nil } + return workspace + } + } + + private func encodeServiceFrame( + _ frame: DoryFSWorkerServiceFrame, + limits: DoryFSWorkerLimits + ) -> Data { + do { + return encodeRPC(.success(try DoryFSWorkerFrameCodec.encode( + frame, + maximumFrameBytes: limits.maximumFrameBytes + ))) + } catch { + return encodeRPC(.failure(.internalFailure)) + } + } + + private func encodeRPC(_ result: DoryFSWorkerRPCResult) -> Data { + // Every locally-constructed result is within an absolute compile-time envelope. + (try? DoryFSWorkerRPCResultCodec.encode(result)) ?? Data() + } +} + +extension DoryFSWorkerRootAuthorityError { + var bootstrapFailureCode: DoryFSWorkerRPCFailureCode { + switch self { + case .bootstrapAlreadyAttempted: + .bootstrapAlreadyAttempted + case .descriptorCountMismatch, .rootDescriptorUnavailable: + .bootstrapDescriptorTransferFailed + case .rootInspectionFailed, .rootIsNotDirectory, .descriptorBorrowFailed: + .bootstrapRootOpenFailed + case .rootIdentityMismatch: + .bootstrapRootIdentityMismatch + case .bootstrapNotAccepted, .unknownCapability: + .bootstrapRejected + } + } +} + +private extension FuseResourceLimits { + init(_ limits: DoryFSShareResourceLimits) { + self.init( + maximumLiveNonRootNodes: limits.maximumLiveNonRootNodes, + maximumFileHandles: limits.maximumFileHandles, + maximumDirectoryHandles: limits.maximumDirectoryHandles, + maximumDirectoryCursorEntries: limits.maximumDirectoryCursorEntries, + maximumDirectoryCursorNameBytes: limits.maximumDirectoryCursorNameBytes, + maximumAdvisoryLockOwners: limits.maximumAdvisoryLockOwners, + maximumPendingBlockingLocks: limits.maximumPendingBlockingLocks + ) + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/FuseResourceLimits.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/FuseResourceLimits.swift new file mode 100644 index 00000000..a761d309 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/FuseResourceLimits.swift @@ -0,0 +1,239 @@ +import Foundation + +/// Immutable per-share resource ceilings. Production uses one explicit profile; tests inject +/// smaller values through `HostFS` without environment switches or process-global state. +public struct FuseResourceLimits: Equatable, Sendable { + public let maximumLiveNonRootNodes: Int + public let maximumFileHandles: Int + public let maximumDirectoryHandles: Int + /// Stable cookie slots retained only after an entry is considered for a guest response. This + /// is aggregate across every open directory in one share, not a per-handle multiplier. + public let maximumDirectoryCursorEntries: Int + /// Aggregate UTF-8 bytes retained by those stable cookie slots. Container overhead is bounded + /// separately by the entry count; names themselves cannot amplify memory beyond this ceiling. + public let maximumDirectoryCursorNameBytes: Int + public let maximumAdvisoryLockOwners: Int + public let maximumPendingBlockingLocks: Int + + /// Per-share logical ceilings for package-manager-scale directory trees. The separate worker + /// process establishes its own bounded descriptor ceiling before accepting XPC authority; + /// raising the VMM process cannot provide that capacity. + public static let production = FuseResourceLimits( + maximumLiveNonRootNodes: 65_536, + maximumFileHandles: 16_384, + maximumDirectoryHandles: 4_096, + maximumDirectoryCursorEntries: 262_144, + maximumDirectoryCursorNameBytes: 32 * 1_024 * 1_024, + maximumAdvisoryLockOwners: 4_096, + maximumPendingBlockingLocks: 1_024 + ) + + public init( + maximumLiveNonRootNodes: Int, + maximumFileHandles: Int, + maximumDirectoryHandles: Int, + maximumDirectoryCursorEntries: Int = 262_144, + maximumDirectoryCursorNameBytes: Int = 32 * 1_024 * 1_024, + maximumAdvisoryLockOwners: Int, + maximumPendingBlockingLocks: Int + ) { + precondition(maximumLiveNonRootNodes > 0) + precondition(maximumFileHandles > 0) + precondition(maximumDirectoryHandles > 0) + precondition(maximumDirectoryCursorEntries > 0) + precondition(maximumDirectoryCursorNameBytes > 0) + precondition(maximumAdvisoryLockOwners > 0) + precondition(maximumPendingBlockingLocks > 0) + self.maximumLiveNonRootNodes = maximumLiveNonRootNodes + self.maximumFileHandles = maximumFileHandles + self.maximumDirectoryHandles = maximumDirectoryHandles + self.maximumDirectoryCursorEntries = maximumDirectoryCursorEntries + self.maximumDirectoryCursorNameBytes = maximumDirectoryCursorNameBytes + self.maximumAdvisoryLockOwners = maximumAdvisoryLockOwners + self.maximumPendingBlockingLocks = maximumPendingBlockingLocks + } +} + +public enum FuseResourceKind: String, CaseIterable, Equatable, Sendable { + case liveNonRootNodes + case fileHandles + case directoryHandles + case directoryCursorEntries + case directoryCursorNameBytes + case advisoryLockOwners + case pendingBlockingLocks +} + +public struct FuseResourceQuotaError: Error, Equatable, Sendable { + public let resource: FuseResourceKind + public let limit: Int + + public init(resource: FuseResourceKind, limit: Int) { + self.resource = resource + self.limit = limit + } +} + +public struct FuseResourceSnapshot: Equatable, Sendable { + public let limits: FuseResourceLimits + public let liveNonRootNodes: Int + public let fileHandles: Int + public let directoryHandles: Int + public let directoryCursorEntries: Int + public let directoryCursorNameBytes: Int + public let advisoryLockOwners: Int + public let pendingBlockingLocks: Int + + public init( + limits: FuseResourceLimits, + liveNonRootNodes: Int, + fileHandles: Int, + directoryHandles: Int, + directoryCursorEntries: Int, + directoryCursorNameBytes: Int, + advisoryLockOwners: Int, + pendingBlockingLocks: Int + ) { + self.limits = limits + self.liveNonRootNodes = liveNonRootNodes + self.fileHandles = fileHandles + self.directoryHandles = directoryHandles + self.directoryCursorEntries = directoryCursorEntries + self.directoryCursorNameBytes = directoryCursorNameBytes + self.advisoryLockOwners = advisoryLockOwners + self.pendingBlockingLocks = pendingBlockingLocks + } +} + +/// One atomic counter authority for all resources owned by a share. Callers keep the returned token +/// for exactly as long as the admitted resource remains live; explicit release is idempotent and +/// deinit is a rollback fence for every failed partial admission. +final class FuseResourceQuota: @unchecked Sendable { + let limits: FuseResourceLimits + + private let lock = NSLock() + private var counts: [FuseResourceKind: Int] = [:] + + init(limits: FuseResourceLimits) { + self.limits = limits + } + + func acquire(_ resource: FuseResourceKind) throws -> FuseResourceToken { + try lock.withLock { + let current = counts[resource, default: 0] + let limit = limit(for: resource) + guard current < limit else { + throw FuseResourceQuotaError(resource: resource, limit: limit) + } + counts[resource] = current + 1 + return FuseResourceToken(resource: resource, quota: self) + } + } + + /// Atomically reserves one stable directory cookie and its retained UTF-8 name. Keeping the + /// two counters under one lock avoids a partially admitted slot when either aggregate limit is + /// exhausted. + func reserveDirectoryCursorEntry(nameByteCount: Int) throws { + precondition(nameByteCount > 0) + try lock.withLock { + let entryCount = counts[.directoryCursorEntries, default: 0] + guard entryCount < limits.maximumDirectoryCursorEntries else { + throw FuseResourceQuotaError( + resource: .directoryCursorEntries, + limit: limits.maximumDirectoryCursorEntries + ) + } + let nameBytes = counts[.directoryCursorNameBytes, default: 0] + let (newNameBytes, overflow) = nameBytes.addingReportingOverflow(nameByteCount) + guard !overflow, newNameBytes <= limits.maximumDirectoryCursorNameBytes else { + throw FuseResourceQuotaError( + resource: .directoryCursorNameBytes, + limit: limits.maximumDirectoryCursorNameBytes + ) + } + counts[.directoryCursorEntries] = entryCount + 1 + counts[.directoryCursorNameBytes] = newNameBytes + } + } + + func releaseDirectoryCursor(entries: Int, nameBytes: Int) { + guard entries > 0 || nameBytes > 0 else { return } + precondition(entries >= 0 && nameBytes >= 0) + lock.withLock { + let currentEntries = counts[.directoryCursorEntries, default: 0] + let currentNameBytes = counts[.directoryCursorNameBytes, default: 0] + precondition(currentEntries >= entries, "unbalanced directory cursor entry release") + precondition(currentNameBytes >= nameBytes, "unbalanced directory cursor byte release") + updateCount(.directoryCursorEntries, to: currentEntries - entries) + updateCount(.directoryCursorNameBytes, to: currentNameBytes - nameBytes) + } + } + + func snapshot() -> FuseResourceSnapshot { + lock.withLock { + FuseResourceSnapshot( + limits: limits, + liveNonRootNodes: counts[.liveNonRootNodes, default: 0], + fileHandles: counts[.fileHandles, default: 0], + directoryHandles: counts[.directoryHandles, default: 0], + directoryCursorEntries: counts[.directoryCursorEntries, default: 0], + directoryCursorNameBytes: counts[.directoryCursorNameBytes, default: 0], + advisoryLockOwners: counts[.advisoryLockOwners, default: 0], + pendingBlockingLocks: counts[.pendingBlockingLocks, default: 0] + ) + } + } + + fileprivate func release(_ resource: FuseResourceKind) { + lock.withLock { + let current = counts[resource, default: 0] + precondition(current > 0, "unbalanced FUSE resource token release") + updateCount(resource, to: current - 1) + } + } + + private func updateCount(_ resource: FuseResourceKind, to value: Int) { + if value == 0 { + counts.removeValue(forKey: resource) + } else { + counts[resource] = value + } + } + + private func limit(for resource: FuseResourceKind) -> Int { + switch resource { + case .liveNonRootNodes: limits.maximumLiveNonRootNodes + case .fileHandles: limits.maximumFileHandles + case .directoryHandles: limits.maximumDirectoryHandles + case .directoryCursorEntries: limits.maximumDirectoryCursorEntries + case .directoryCursorNameBytes: limits.maximumDirectoryCursorNameBytes + case .advisoryLockOwners: limits.maximumAdvisoryLockOwners + case .pendingBlockingLocks: limits.maximumPendingBlockingLocks + } + } +} + +final class FuseResourceToken: @unchecked Sendable { + let resource: FuseResourceKind + + private let lock = NSLock() + private var quota: FuseResourceQuota? + + fileprivate init(resource: FuseResourceKind, quota: FuseResourceQuota) { + self.resource = resource + self.quota = quota + } + + func release() { + let quota = lock.withLock { () -> FuseResourceQuota? in + let current = self.quota + self.quota = nil + return current + } + quota?.release(resource) + } + + deinit { + release() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseServer.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/FuseServer.swift similarity index 62% rename from Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseServer.swift rename to Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/FuseServer.swift index 84b71900..2295d4c6 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseServer.swift +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/FuseServer.swift @@ -1,4 +1,5 @@ import Darwin +import DoryFSWorkerContracts import Foundation private struct FuseResponseCachePolicy: Sendable { @@ -71,17 +72,16 @@ private final class FuseCachePolicy: @unchecked Sendable { } } -public final class FuseServer: @unchecked Sendable { +final class FuseServer: @unchecked Sendable { static let maximumCoherentCacheValiditySeconds = FuseCachePolicy.maximumValiditySeconds static let negativeCoherentCacheValiditySeconds = FuseCachePolicy.negativeValiditySeconds private let hostFS: HostFS - private let daxWindow: DaxWindow? + private var resourceQuota: FuseResourceQuota { hostFS.resourceQuota } private let writebackCache: Bool private let killPrivV2: Bool private let fastCreateAttributes: Bool private let cachePolicy = FuseCachePolicy() - private let stats: FuseStats? private let anomalyLog = FuseAnomalyLog() private let lock = NSLock() private var nextFileHandle: UInt64 = 1 @@ -92,6 +92,7 @@ public final class FuseServer: @unchecked Sendable { private let advisoryLockCondition = NSCondition() private var pendingBlockingLocks: Set = [] private var pendingBlockingLockOwners: [UInt64: AdvisoryLockOwnerKey] = [:] + private var pendingBlockingLockTokens: [UInt64: FuseResourceToken] = [:] private var cancelledBlockingLocks: Set = [] var fileOperationLoadedTestHook: (() -> Void)? var directoryOperationLoadedTestHook: (() -> Void)? @@ -106,13 +107,22 @@ public final class FuseServer: @unchecked Sendable { let accessMode: HostFSAccessMode let append: Bool private let hostFS: HostFS - - init(fd: Int32, nodeID: UInt64, accessMode: HostFSAccessMode, append: Bool, hostFS: HostFS) { + private let resourceToken: FuseResourceToken + + init( + fd: Int32, + nodeID: UInt64, + accessMode: HostFSAccessMode, + append: Bool, + hostFS: HostFS, + resourceToken: FuseResourceToken + ) { self.fd = fd self.nodeID = nodeID self.accessMode = accessMode self.append = append self.hostFS = hostFS + self.resourceToken = resourceToken } deinit { @@ -129,22 +139,49 @@ public final class FuseServer: @unchecked Sendable { private final class OpenDirectoryHandle: @unchecked Sendable { let nodeID: UInt64 - /// Linux treats `fuse_read_in.offset` as a cookie into one open directory stream. Rebuilding - /// the sorted listing for every page makes that cookie unstable when a consumer such as - /// `rm -rf` deletes page one before requesting page two: the remaining entries shift left - /// and are skipped. Keep one enumeration snapshot for the lifetime of the open handle. - /// Slots are never renumbered during this open-directory lifetime. A removed name becomes - /// nil instead of shifting later cookies left; newly discovered names append new slots. - var entries: [HostFSEntry?]? + let cursor: HostFSDirectoryCursor + /// FUSE offsets identify the next entry and must remain stable when names are added or + /// removed. Slots append only as names are incrementally considered for a bounded response; + /// they never contain attributes/nodes and never snapshot undiscovered directory contents. + let operationLock = NSLock() + var cookieNames: [String] = [] + var knownCookieNames: Set = [] + var enumerationExhausted = false + var terminalCursorQuotaError: FuseResourceQuotaError? + var reservedCursorEntries = 0 + var reservedCursorNameBytes = 0 private let hostFS: HostFS - - init(nodeID: UInt64, entries: [HostFSEntry?]? = nil, hostFS: HostFS) { + private let resourceQuota: FuseResourceQuota + private let resourceToken: FuseResourceToken + + init( + nodeID: UInt64, + cursor: HostFSDirectoryCursor, + hostFS: HostFS, + resourceQuota: FuseResourceQuota, + resourceToken: FuseResourceToken + ) { self.nodeID = nodeID - self.entries = entries + self.cursor = cursor self.hostFS = hostFS + self.resourceQuota = resourceQuota + self.resourceToken = resourceToken + } + + func appendCookieName(_ name: String) throws { + let nameBytes = name.utf8.count + try resourceQuota.reserveDirectoryCursorEntry(nameByteCount: nameBytes) + cookieNames.append(name) + knownCookieNames.insert(name) + reservedCursorEntries += 1 + reservedCursorNameBytes += nameBytes } deinit { + resourceQuota.releaseDirectoryCursor( + entries: reservedCursorEntries, + nameBytes: reservedCursorNameBytes + ) hostFS.releaseOpenHandle(nodeID: nodeID) } } @@ -155,6 +192,11 @@ public final class FuseServer: @unchecked Sendable { let flock: Bool } + private struct AdvisoryLockDescriptorAdmission { + let key: AdvisoryLockOwnerKey + let descriptor: AdvisoryLockDescriptor + } + /// macOS process locks alias inside one server process, so every guest lock owner receives an /// independently reopened file description. OFD record locks and flock(2) then preserve the /// kernel's owner isolation even though all FUSE requests execute inside dory-hv. @@ -162,10 +204,17 @@ public final class FuseServer: @unchecked Sendable { let fd: Int32 let usesFlock: Bool let operationLock = NSLock() - - init(fd: Int32, usesFlock: Bool) { + private let resourceToken: FuseResourceToken + /// Protected by `FuseServer.lock`. Transient admissions keep a descriptor registered until + /// all same-owner operations finish; a successful lock makes it persistent until FLUSH or + /// RELEASE. This prevents one failed concurrent request from retiring another's owner. + var activeAdmissions = 0 + var retainedByOwner = false + + init(fd: Int32, usesFlock: Bool, resourceToken: FuseResourceToken) { self.fd = fd self.usesFlock = usesFlock + self.resourceToken = resourceToken } deinit { @@ -232,21 +281,17 @@ public final class FuseServer: @unchecked Sendable { public init( hostFS: HostFS, - daxWindow: DaxWindow? = nil, - writebackCache: Bool? = nil, - killPrivV2: Bool? = nil, - deferReleaseClose _: Bool? = nil, - fastCreateAttributes: Bool? = nil + writebackCache: Bool = false, + killPrivV2: Bool = true, + fastCreateAttributes: Bool = false ) { self.hostFS = hostFS - self.daxWindow = daxWindow - self.writebackCache = writebackCache ?? Self.writebackCacheEnabledFromEnvironment() - self.killPrivV2 = killPrivV2 ?? Self.killPrivV2EnabledFromEnvironment() + self.writebackCache = writebackCache + self.killPrivV2 = killPrivV2 // A synthetic create identity cannot distinguish the original inode from a host atomic // replacement that lands before the first getattr/open. Production always records the real // file key; tests may still opt in explicitly to exercise legacy reconciliation behavior. - self.fastCreateAttributes = fastCreateAttributes ?? false - self.stats = FuseStats.fromEnvironment() + self.fastCreateAttributes = fastCreateAttributes } deinit { @@ -268,6 +313,7 @@ public final class FuseServer: @unchecked Sendable { var fuseInitCompleted: Bool { cachePolicy.isFuseInitCompleted } var coherentCachingActive: Bool { cachePolicy.isActive } + public var resourceSnapshot: FuseResourceSnapshot { resourceQuota.snapshot() } func markFuseInitCompleted() { cachePolicy.markFuseInitCompleted() @@ -290,8 +336,6 @@ public final class FuseServer: @unchecked Sendable { } let payload = request[Int(FuseInHeader.byteCount).. Int { - writeReadResponse(header: header, payload: payload[...], writable: writable) - } - - public func writeReadResponse(header: FuseInHeader, payload: ArraySlice, writable: [VirtqueueSegment]) -> Int { - guard let first = writable.first, first.length >= FuseOutHeader.byteCount else { return 0 } - let totalCapacity = writable.reduce(0) { $0 + $1.length } - func finish(errno: Int32, payloadBytes: Int) -> Int { - let total = FuseOutHeader.byteCount + payloadBytes - first.pointer.storeBytes(of: UInt32(total).littleEndian, toByteOffset: 0, as: UInt32.self) - first.pointer.storeBytes(of: Int32(-FuseProtocol.linuxErrno(errno)).littleEndian, toByteOffset: 4, as: Int32.self) - first.pointer.storeBytes(of: header.unique.littleEndian, toByteOffset: 8, as: UInt64.self) - return total - } - guard payload.count >= 40 else { return finish(errno: EINVAL, payloadBytes: 0) } - let size = min(Int(payload.leUInt32(at: 16)), HostFS.maxReadCount) - guard let signedOffset = off_t(exactly: payload.leUInt64(at: 8)) else { - return finish(errno: EINVAL, payloadBytes: 0) - } - guard let openHandle = loadFile(handle: payload.leUInt64(at: 0)), - openHandle.nodeID == header.nodeID, - openHandle.permitsRead(writebackCache: writebackCache) else { - anomalyLog.log(describeStaleHandle(payload.leUInt64(at: 0), nodeID: header.nodeID, op: "READ(direct)")) - return finish(errno: EBADF, payloadBytes: 0) - } - let dataCapacity = min(size, totalCapacity - FuseOutHeader.byteCount) - guard dataCapacity > 0 else { return finish(errno: 0, payloadBytes: 0) } - - // Build iovecs over the writable bytes AFTER the 16-byte out header. - var iovecs = [iovec]() - var remaining = dataCapacity - var skip = FuseOutHeader.byteCount - for segment in writable where remaining > 0 { - var base = segment.pointer - var length = segment.length - if skip > 0 { - let drop = min(skip, length) - base = base.advanced(by: drop) - length -= drop - skip -= drop - } - guard length > 0 else { continue } - let take = min(length, remaining) - iovecs.append(iovec(iov_base: base, iov_len: take)) - remaining -= take - } - let readCount = preadv(openHandle.fd, iovecs, Int32(iovecs.count), signedOffset) - guard readCount >= 0 else { return finish(errno: errno, payloadBytes: 0) } - return finish(errno: 0, payloadBytes: Int(readCount)) - } - - /// Complete direct LOOKUP response. Handling hits as well as misses here is important: probing for - /// a miss and then falling back on a hit performs the same descriptor-relative stat twice. Require - /// enough output space for either result before touching HostFS so an undersized chain can fall - /// back without registering an entry or acquiring a lookup reference. - public func writeLookupResponse(header: FuseInHeader, payload: ArraySlice, writable: [VirtqueueSegment]) -> Int { - let payloadBytes = 128 - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount + payloadBytes else { - return 0 - } - stats?.record(.lookup) - do { - let name = try readCString(payload) - guard let entry = try hostFS.lookupIfExists(parent: header.nodeID, name: name) else { - let validity = cachePolicy.negativeEntryValiditySeconds - guard validity > 0 else { - return writeErrorResponse(unique: header.unique, errno: ENOENT, writable: writable) - } - var writer = FuseDirectResponseWriter(writable: writable) - writer.appendOutHeader(unique: header.unique, error: 0, payloadByteCount: payloadBytes) - writer.append(encodeNegativeEntryOut(validity: validity)) - return writer.written - } - hostFS.retainLookup(nodeID: entry.nodeID) - var writer = FuseDirectResponseWriter(writable: writable) - writer.appendOutHeader(unique: header.unique, error: 0, payloadByteCount: payloadBytes) - appendEntryOut(entry.attributes, to: &writer) - return writer.written - } catch { - return writeErrorResponse(unique: header.unique, errno: mapError(error), writable: writable) - } - } - - /// Direct GETXATTR response for the default policy: Dory does not expose host extended attributes - /// through broad home shares, so the stable answer is ENODATA. This is on the hot path for Alpine's - /// shell-created files. - public func writeGetXattrNoDataResponse(header: FuseInHeader, writable: [VirtqueueSegment]) -> Int { - stats?.record(.getxattr) - // ENOSYS latches fc->no_getxattr guest-side; see the array-path getxattr case. - return writeErrorResponse(unique: header.unique, errno: ENOSYS, writable: writable) - } - - /// Direct GETATTR response. Metadata-heavy create loops ask for attrs after create; emitting the - /// fixed attr payload in place avoids another response allocation on that path. - public func writeGetattrResponse( - header: FuseInHeader, - payload: ArraySlice, - writable: [VirtqueueSegment] - ) -> Int { - stats?.record(.getattr) - let payloadBytes = 104 - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount + payloadBytes else { return 0 } - do { - let attrs = try getattrAttributes(header: header, payload: payload) - var writer = FuseDirectResponseWriter(writable: writable) - writer.appendOutHeader(unique: header.unique, error: 0, payloadByteCount: payloadBytes) - appendAttrOut(attrs, to: &writer) - return writer.written - } catch { - return writeErrorResponse(unique: header.unique, errno: mapError(error), writable: writable) - } - } - - /// Direct CREATE response. The common shell redirection path creates and opens a file, then writes - /// and releases it immediately. This keeps the create response on the same direct path as write and - /// release while preserving the normal fallback for undersized writable chains. - public func writeCreateResponse(header: FuseInHeader, payload: ArraySlice, writable: [VirtqueueSegment]) -> Int { - stats?.record(.create) - let payloadBytes = 144 - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount + payloadBytes else { return 0 } - do { - guard payload.count >= 16 else { - return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) - } - guard let intent = FileOpenIntent(wireFlags: payload.leUInt32(at: 0)) else { - return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) - } - let mode = UInt16(truncatingIfNeeded: payload.leUInt32(at: 4)) - let name = try readCString(payload.dropFirst(16)) - let created = try hostFS.createFileAndOpen( - parent: header.nodeID, - name: name, - mode: mode, - accessMode: hostAccessMode(for: intent), - preferredIdentityAccessMode: .readWrite, - exclusive: intent.exclusive, - truncate: intent.truncate, - append: intent.append && !writebackCache, - syntheticAttributes: fastCreateAttributes, - retainOpenHandle: true, - ownerUID: header.uid, - ownerGID: header.gid - ) - let handle = storeRetainedFile( - fd: created.fd, - nodeID: created.entry.nodeID, - accessMode: intent.accessMode, - append: intent.append - ) - hostFS.retainLookup(nodeID: created.entry.nodeID) - var writer = FuseDirectResponseWriter(writable: writable) - writer.appendOutHeader(unique: header.unique, error: 0, payloadByteCount: payloadBytes) - appendEntryOut(created.entry.attributes, to: &writer) - appendOpenOut(handle: handle, openFlags: fileOpenFlags, to: &writer) - return writer.written - } catch { - return writeErrorResponse(unique: header.unique, errno: mapError(error), writable: writable) - } - } - - /// Direct MKDIR response. It is cold compared with CREATE, but keeping directory setup on the - /// direct path avoids a stray allocation in create/delete benchmark loops. - public func writeMkdirResponse(header: FuseInHeader, payload: ArraySlice, writable: [VirtqueueSegment]) -> Int { - stats?.record(.mkdir) - let payloadBytes = 128 - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount + payloadBytes else { return 0 } - do { - guard payload.count >= 8 else { - return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) - } - let mode = UInt16(truncatingIfNeeded: payload.leUInt32(at: 0)) - let name = try readCString(payload.dropFirst(8)) - let entry = try hostFS.mkdir( - parent: header.nodeID, - name: name, - mode: mode, - syntheticAttributes: fastCreateAttributes, - ownerUID: header.uid, - ownerGID: header.gid - ) - hostFS.retainLookup(nodeID: entry.nodeID) - var writer = FuseDirectResponseWriter(writable: writable) - writer.appendOutHeader(unique: header.unique, error: 0, payloadByteCount: payloadBytes) - appendEntryOut(entry.attributes, to: &writer) - return writer.written - } catch { - return writeErrorResponse(unique: header.unique, errno: mapError(error), writable: writable) - } - } - - /// Direct UNLINK/RMDIR response for cleanup-heavy bind mount loops. The host mutation still goes - /// through HostFS; this only avoids allocating and copying the empty success frame. - public func writeRemoveResponse(header: FuseInHeader, opcode: FuseOpcode, payload: ArraySlice, writable: [VirtqueueSegment]) -> Int { - stats?.record(opcode) - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount else { return 0 } - do { - let name = try readCString(payload) - switch opcode { - case .unlink: - try hostFS.unlink(parent: header.nodeID, name: name) - case .rmdir: - try hostFS.rmdir(parent: header.nodeID, name: name) - default: - return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) - } - return writeEmptySuccessResponse(unique: header.unique, writable: writable) - } catch { - return writeErrorResponse(unique: header.unique, errno: mapError(error), writable: writable) - } - } - - /// Direct WRITE response path for the virtio-fs device. The response is a fixed - /// `fuse_out_header + fuse_write_out`, so writing it straight into the guest avoids a tiny - /// allocation and scatter-copy on every small write in metadata-heavy bind workloads. - public func writeWriteResponse(header: FuseInHeader, payload: ArraySlice, writable: [VirtqueueSegment]) -> Int { - stats?.record(.write) - let payloadBytes = 8 - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount + payloadBytes else { return 0 } - do { - guard payload.count >= 40 else { return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) } - let handle = payload.leUInt64(at: 0) - let offset = payload.leUInt64(at: 8) - let size = Int(payload.leUInt32(at: 16)) - guard payload.count >= 40 + size else { - return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) - } - guard let openHandle = loadFile(handle: handle), - openHandle.nodeID == header.nodeID, - openHandle.permitsWrite else { - anomalyLog.log(describeStaleHandle(handle, nodeID: header.nodeID, op: "WRITE(direct)")) - return writeErrorResponse(unique: header.unique, errno: EBADF, writable: writable) - } - fileOperationLoadedTestHook?() - let written = try payload.withUnsafeBytes { raw -> Int in - let base = raw.baseAddress?.advanced(by: 40) - return try hostFS.write( - handle: openHandle.fd, - offset: offset, - bytes: UnsafeRawBufferPointer(start: base, count: size), - append: openHandle.append && !writebackCache - ) - } - if openHandle.append && !writebackCache { - try hostFS.recordAppendWrite(nodeID: header.nodeID, handle: openHandle.fd) - } else { - hostFS.recordWrite(nodeID: header.nodeID, offset: offset, count: written) - } - killPrivilegeBitsIfRequested(writeFlags: payload.leUInt32(at: 20), fd: openHandle.fd) - var writer = FuseDirectResponseWriter(writable: writable) - writer.appendOutHeader(unique: header.unique, error: 0, payloadByteCount: payloadBytes) - writer.appendLE(UInt32(written)) - writer.appendLE(UInt32(0)) - return writer.written - } catch { - return writeErrorResponse(unique: header.unique, errno: mapError(error), writable: writable) - } - } - - /// Direct RELEASE/RELEASEDIR response path. RELEASE is close-heavy in shell-file-create loops; - /// keeping it allocation-free makes the common success path cheaper without changing close - /// ordering or deferred-close semantics. - public func writeReleaseResponse(header: FuseInHeader, payload: ArraySlice, writable: [VirtqueueSegment]) -> Int { - guard let opcode = FuseOpcode(rawValue: header.opcode), opcode == .release || opcode == .releasedir else { - return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) - } - stats?.record(opcode) - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount else { return 0 } - guard payload.count >= 8 else { - return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) - } - let handle = payload.leUInt64(at: 0) - if opcode == .release { - if payload.count >= 24 { - releaseAdvisoryLocks(nodeID: header.nodeID, owner: payload.leUInt64(at: 16)) - } - releaseFile(handle: handle) - } else { - releaseDirectory(handle: handle) - } - return writeEmptySuccessResponse(unique: header.unique, writable: writable) - } - - public func writeEmptySuccessResponse(unique: UInt64, writable: [VirtqueueSegment]) -> Int { - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount else { return 0 } - var writer = FuseDirectResponseWriter(writable: writable) - writer.appendOutHeader(unique: unique, error: 0, payloadByteCount: 0) - return writer.written - } - /// Reverses only server-side lifetime grants from a successful response that never reached the /// used ring. Host namespace/data mutations remain committed; the guest never received the /// node or handle references that would otherwise authorize keeping these resources alive. - func rollbackUnpublishedResponse( - opcode: FuseOpcode, - writable: [VirtqueueSegment], - written: Int - ) { - guard let response = copyEncodedResponse(writable: writable, written: written) else { return } - rollbackUnpublishedResponse(opcode: opcode, response: response) - } - func rollbackUnpublishedResponse(opcode: FuseOpcode, response: [UInt8]) { guard response.count >= FuseOutHeader.byteCount, Int32(bitPattern: response.leUInt32(at: 4)) == 0 else { return } @@ -951,106 +704,6 @@ public final class FuseServer: @unchecked Sendable { wakeAdvisoryLockWaiters() } - private func copyEncodedResponse( - writable: [VirtqueueSegment], - written: Int - ) -> [UInt8]? { - guard written >= FuseOutHeader.byteCount else { return nil } - var response = [UInt8]() - response.reserveCapacity(written) - var remaining = written - for segment in writable where remaining > 0 { - let take = min(segment.length, remaining) - response.append(contentsOf: UnsafeRawBufferPointer(start: segment.pointer, count: take)) - remaining -= take - } - guard remaining == 0 else { return nil } - let declaredLength = Int(response.leUInt32(at: 0)) - guard declaredLength >= FuseOutHeader.byteCount, declaredLength <= response.count else { - return nil - } - if declaredLength < response.count { - response.removeSubrange(declaredLength.. Int { - let errno = FuseProtocol.linuxErrno(rawErrno) - guard writable.reduce(0, { $0 + $1.length }) >= FuseOutHeader.byteCount else { return 0 } - var writer = FuseDirectResponseWriter(writable: writable) - writer.appendOutHeader(unique: unique, error: -errno, payloadByteCount: 0) - return writer.written - } - - /// Removes every metadata-cache grant from an already encoded successful response. VirtioFS - /// calls this under its publish fence when a queue-health policy transition overtook a worker. - /// Copying is intentional: this is a cold race path, and it handles TTL fields split - /// across arbitrary writable descriptor segments without weakening the direct hot paths. - @discardableResult - func neutralizeCacheGrants( - opcode: FuseOpcode, - writable: [VirtqueueSegment], - written: Int - ) -> Bool { - guard var response = copyEncodedResponse(writable: writable, written: written) else { return false } - let declaredLength = response.count - // Errors and response kinds without entry/attribute validity cannot grant metadata cache. - guard Int32(bitPattern: response.leUInt32(at: 4)) == 0 else { return true } - - func zeroUInt64(at offset: Int) -> Bool { - guard offset >= 0, offset + MemoryLayout.size <= declaredLength else { - return false - } - response.replaceSubrange(offset..<(offset + MemoryLayout.size), with: repeatElement(0, count: 8)) - return true - } - - let payloadStart = FuseOutHeader.byteCount - switch opcode { - case .lookup, .symlink, .link, .mkdir, .create: - // fuse_entry_out: nodeid, generation, entry_valid, attr_valid, ... - guard zeroUInt64(at: payloadStart + 16), zeroUInt64(at: payloadStart + 24) else { - return false - } - case .getattr, .setattr: - // fuse_attr_out begins with attr_valid. - guard zeroUInt64(at: payloadStart) else { return false } - case .readdirplus: - // Each packed record is fuse_entry_out (128 bytes) followed by fuse_dirent. Records are - // independently 8-byte aligned, so neutralize both validity fields in every entry. - var recordStart = payloadStart - while recordStart < declaredLength { - let nameLengthOffset = recordStart + 128 + 16 - guard nameLengthOffset + 4 <= declaredLength, - zeroUInt64(at: recordStart + 16), - zeroUInt64(at: recordStart + 24) else { - return false - } - let nameLength = Int(response.leUInt32(at: nameLengthOffset)) - let unalignedLength = 128 + 24 + nameLength - let recordLength = (unalignedLength + 7) & ~7 - guard recordLength >= 152, recordStart + recordLength <= declaredLength else { - return false - } - recordStart += recordLength - } - guard recordStart == declaredLength else { return false } - default: - return true - } - - var offset = 0 - for segment in writable where offset < declaredLength { - let take = min(segment.length, declaredLength - offset) - response[offset..<(offset + take)].withUnsafeBytes { source in - segment.pointer.copyMemory(from: source.baseAddress!, byteCount: take) - } - offset += take - } - return offset == declaredLength - } - private func handleWrite(header: FuseInHeader, payload: ArraySlice) throws -> [UInt8] { guard payload.count >= 40 else { return errorResponse(unique: header.unique, errno: EINVAL) } let handle = payload.leUInt64(at: 0) @@ -1091,26 +744,86 @@ public final class FuseServer: @unchecked Sendable { guard let offset = Int(exactly: payload.leUInt64(at: 8)) else { return errorResponse(unique: header.unique, errno: EINVAL) } - guard let entries = try directoryEntries( - handle: handle, - nodeID: header.nodeID, - refresh: offset == 0 - ) else { + guard let directory = loadDirectory(handle: handle), directory.nodeID == header.nodeID else { anomalyLog.log(describeStaleHandle(handle, nodeID: header.nodeID, op: "READDIRPLUS")) return errorResponse(unique: header.unique, errno: EBADF) } let maxSize = Int(payload.leUInt32(at: 16)) - var data = [UInt8]() - var retainedNodeIDs = [UInt64]() - for (index, optionalEntry) in entries.enumerated().dropFirst(offset) { - guard let entry = optionalEntry else { continue } - let encoded = encodeDirentPlus(entry, offset: UInt64(index + 1)) - guard data.count + encoded.count <= maxSize else { break } - data.append(contentsOf: encoded) - retainedNodeIDs.append(entry.nodeID) - } - hostFS.retainLookups(nodeIDs: retainedNodeIDs) - return successResponse(unique: header.unique, payload: data) + directoryOperationLoadedTestHook?() + + return try directory.operationLock.withLock { + if let terminalError = directory.terminalCursorQuotaError { + throw terminalError + } + guard offset <= directory.cookieNames.count else { + return errorResponse(unique: header.unique, errno: EINVAL) + } + if offset == 0 { + try hostFS.rewindDirectoryCursor(directory.cursor) + directory.enumerationExhausted = false + } + + var data = [UInt8]() + data.reserveCapacity(min(maxSize, 64 * 1_024)) + var retainedNodeIDs: [UInt64] = [] + var slot = offset + + while data.count < maxSize { + if slot == directory.cookieNames.count { + guard !directory.enumerationExhausted else { break } + let nextName: String? + do { + nextName = try hostFS.nextDirectoryName(from: directory.cursor) + } catch { + if data.isEmpty { throw error } + break + } + guard let nextName else { + directory.enumerationExhausted = true + break + } + guard !directory.knownCookieNames.contains(nextName) else { continue } + do { + try directory.appendCookieName(nextName) + } catch let quotaError as FuseResourceQuotaError { + // The host stream has advanced past a name we cannot retain as a stable + // cookie. Latch the cursor rather than silently skipping it. Entries already + // encoded in this reply remain valid; the next page reports EOVERFLOW. + directory.terminalCursorQuotaError = quotaError + if data.isEmpty { throw quotaError } + break + } + } + + let name = directory.cookieNames[slot] + let encodedLength = Self.direntPlusEncodedLength(nameByteCount: name.utf8.count) + guard encodedLength <= maxSize - data.count else { break } + + let entry: HostFSEntry? + do { + entry = try hostFS.lookupIfExists(parent: header.nodeID, name: name) + } catch HostFSError.operationNotSupported { + // Unsupported host special files keep a stable hole in the cookie space. + slot += 1 + continue + } catch { + if data.isEmpty { throw error } + break + } + slot += 1 + guard let entry else { + // A removed name remains a hole so later cookies never shift left. + continue + } + let encoded = encodeDirentPlus(entry, offset: UInt64(slot)) + precondition(encoded.count == encodedLength) + data.append(contentsOf: encoded) + retainedNodeIDs.append(entry.nodeID) + } + + hostFS.retainLookups(nodeIDs: retainedNodeIDs) + return successResponse(unique: header.unique, payload: data) + } } private func handleStatFS(header: FuseInHeader) throws -> [UInt8] { @@ -1162,23 +875,6 @@ public final class FuseServer: @unchecked Sendable { return successResponse(unique: header.unique, payload: []) } - public func writeFlushResponse( - header: FuseInHeader, - payload: ArraySlice, - writable: [VirtqueueSegment] - ) -> Int { - guard payload.count >= 24 else { - return writeErrorResponse(unique: header.unique, errno: EINVAL, writable: writable) - } - guard let openHandle = loadFile(handle: payload.leUInt64(at: 0)), - openHandle.nodeID == header.nodeID else { - anomalyLog.log(describeStaleHandle(payload.leUInt64(at: 0), nodeID: header.nodeID, op: "FLUSH(direct)")) - return writeErrorResponse(unique: header.unique, errno: EBADF, writable: writable) - } - releaseAdvisoryLocks(nodeID: header.nodeID, owner: payload.leUInt64(at: 16)) - return writeEmptySuccessResponse(unique: header.unique, writable: writable) - } - private func handleCreate(header: FuseInHeader, payload: ArraySlice) throws -> [UInt8] { guard payload.count >= 16 else { return errorResponse(unique: header.unique, errno: EINVAL) } guard let intent = FileOpenIntent(wireFlags: payload.leUInt32(at: 0)) else { @@ -1186,6 +882,7 @@ public final class FuseServer: @unchecked Sendable { } let mode = UInt16(truncatingIfNeeded: payload.leUInt32(at: 4)) let name = try readCString(payload.dropFirst(16)) + let handleToken = try resourceQuota.acquire(.fileHandles) let created = try hostFS.createFileAndOpen( parent: header.nodeID, name: name, @@ -1204,7 +901,8 @@ public final class FuseServer: @unchecked Sendable { fd: created.fd, nodeID: created.entry.nodeID, accessMode: intent.accessMode, - append: intent.append + append: intent.append, + resourceToken: handleToken ) let entry = created.entry hostFS.retainLookup(nodeID: entry.nodeID) @@ -1295,12 +993,16 @@ public final class FuseServer: @unchecked Sendable { guard !request.isFlock else { return errorResponse(unique: header.unique, errno: EOPNOTSUPP) } - let descriptor = try advisoryLockDescriptor( + let admission = try advisoryLockDescriptor( nodeID: header.nodeID, owner: request.owner, flock: false, openHandle: openHandle ) + defer { + finishAdvisoryLockDescriptor(admission, retainOwner: false) + } + let descriptor = admission.descriptor var record = try darwinLockRecord(request) let rc = descriptor.operationLock.withLock { fcntl(descriptor.fd, F_OFD_GETLK, &record) @@ -1342,12 +1044,17 @@ public final class FuseServer: @unchecked Sendable { if !request.isFlock, request.type == 1, !openHandle.permitsWrite { return errorResponse(unique: header.unique, errno: EBADF) } - let descriptor = try advisoryLockDescriptor( + let admission = try advisoryLockDescriptor( nodeID: header.nodeID, owner: request.owner, flock: request.isFlock, openHandle: openHandle ) + var retainOwner = false + defer { + finishAdvisoryLockDescriptor(admission, retainOwner: retainOwner) + } + let descriptor = admission.descriptor let rc: Int32 if request.isFlock { var operation: Int32 @@ -1387,6 +1094,7 @@ public final class FuseServer: @unchecked Sendable { } } guard rc == 0 else { throw HostFSError.systemCall(request.isFlock ? "flock" : "F_OFD_SETLK", errno) } + if request.type != 2 { retainOwner = true } if request.type == 2 { wakeAdvisoryLockWaiters() } return successResponse(unique: header.unique, payload: []) } @@ -1402,17 +1110,28 @@ public final class FuseServer: @unchecked Sendable { attempt: () throws -> Int32 ) throws -> Int32 { guard blocking else { return try attempt() } - advisoryLockCondition.withLock { - pendingBlockingLocks.insert(requestUnique) - pendingBlockingLockOwners[requestUnique] = ownerKey - cancelledBlockingLocks.remove(requestUnique) + let resourceToken = try resourceQuota.acquire(.pendingBlockingLocks) + do { + try advisoryLockCondition.withLock { + guard pendingBlockingLocks.insert(requestUnique).inserted else { + throw HostFSError.invalidName("duplicate blocking-lock request identity") + } + pendingBlockingLockOwners[requestUnique] = ownerKey + pendingBlockingLockTokens[requestUnique] = resourceToken + cancelledBlockingLocks.remove(requestUnique) + } + } catch { + resourceToken.release() + throw error } defer { - advisoryLockCondition.withLock { + let token = advisoryLockCondition.withLock { () -> FuseResourceToken? in pendingBlockingLocks.remove(requestUnique) pendingBlockingLockOwners.removeValue(forKey: requestUnique) cancelledBlockingLocks.remove(requestUnique) + return pendingBlockingLockTokens.removeValue(forKey: requestUnique) } + token?.release() } while true { advisoryLockCondition.lock() @@ -1445,28 +1164,52 @@ public final class FuseServer: @unchecked Sendable { private func handleInterrupt(payload: ArraySlice) { guard payload.count >= 8 else { return } let interruptedUnique = payload.leUInt64(at: 0) - advisoryLockCondition.withLock { - guard pendingBlockingLocks.contains(interruptedUnique) else { return } + let token = advisoryLockCondition.withLock { () -> FuseResourceToken? in + guard pendingBlockingLocks.contains(interruptedUnique) else { return nil } + // Keep the request identity registered until its worker observes cancellation. The + // quota token can be returned immediately without allowing a duplicate unique ID to + // erase the cancellation marker while the original request is still unwinding. cancelledBlockingLocks.insert(interruptedUnique) advisoryLockCondition.broadcast() + return pendingBlockingLockTokens.removeValue(forKey: interruptedUnique) } + token?.release() + } + + func interrupt(requestUnique: UInt64) { + var payload = [UInt8]() + payload.appendLE(requestUnique) + handleInterrupt(payload: payload[...]) + } + + func cancelAllRequests() { + cancelAllBlockingLocks() } private func cancelAllBlockingLocks() { - advisoryLockCondition.withLock { + var tokens = advisoryLockCondition.withLock { () -> [FuseResourceToken] in cancelledBlockingLocks.formUnion(pendingBlockingLocks) + let tokens = Array(pendingBlockingLockTokens.values) + pendingBlockingLockTokens.removeAll(keepingCapacity: false) advisoryLockCondition.broadcast() + return tokens } + tokens.removeAll(keepingCapacity: false) } private func cancelBlockingLocks(nodeID: UInt64, owner: UInt64) { - advisoryLockCondition.withLock { + var tokens = advisoryLockCondition.withLock { () -> [FuseResourceToken] in let requests = pendingBlockingLockOwners.compactMap { unique, key in key.nodeID == nodeID && key.owner == owner ? unique : nil } cancelledBlockingLocks.formUnion(requests) + let tokens = requests.compactMap { + pendingBlockingLockTokens.removeValue(forKey: $0) + } advisoryLockCondition.broadcast() + return tokens } + tokens.removeAll(keepingCapacity: false) } private func wakeAdvisoryLockWaiters() { @@ -1478,9 +1221,16 @@ public final class FuseServer: @unchecked Sendable { owner: UInt64, flock: Bool, openHandle: OpenFileHandle - ) throws -> AdvisoryLockDescriptor { + ) throws -> AdvisoryLockDescriptorAdmission { let key = AdvisoryLockOwnerKey(nodeID: nodeID, owner: owner, flock: flock) - if let existing = lock.withLock({ lockOwners[key] }) { return existing } + if let existing = lock.withLock({ () -> AdvisoryLockDescriptorAdmission? in + guard let descriptor = lockOwners[key] else { return nil } + descriptor.activeAdmissions += 1 + return AdvisoryLockDescriptorAdmission(key: key, descriptor: descriptor) + }) { + return existing + } + let resourceToken = try resourceQuota.acquire(.advisoryLockOwners) // HostFS performs a contained openat() and verifies the inode identity, producing a new // open description for each guest lock owner. This is essential on Darwin because simply // opening /dev/fd aliases the source description and collapses different guest owners. @@ -1489,14 +1239,43 @@ public final class FuseServer: @unchecked Sendable { accessMode: openHandle.accessMode, append: false ) - let candidate = AdvisoryLockDescriptor(fd: fd, usesFlock: flock) + let candidate = AdvisoryLockDescriptor( + fd: fd, + usesFlock: flock, + resourceToken: resourceToken + ) return lock.withLock { - if let existing = lockOwners[key] { return existing } + if let existing = lockOwners[key] { + existing.activeAdmissions += 1 + return AdvisoryLockDescriptorAdmission(key: key, descriptor: existing) + } + candidate.activeAdmissions = 1 lockOwners[key] = candidate - return candidate + return AdvisoryLockDescriptorAdmission(key: key, descriptor: candidate) } } + private func finishAdvisoryLockDescriptor( + _ admission: AdvisoryLockDescriptorAdmission, + retainOwner: Bool + ) { + var removed: AdvisoryLockDescriptor? = lock.withLock { + let descriptor = admission.descriptor + precondition(descriptor.activeAdmissions > 0, "unbalanced advisory-lock admission") + descriptor.activeAdmissions -= 1 + if retainOwner { descriptor.retainedByOwner = true } + guard lockOwners[admission.key] === descriptor, + descriptor.activeAdmissions == 0, + !descriptor.retainedByOwner else { + return nil + } + return lockOwners.removeValue(forKey: admission.key) + } + let didRemove = removed != nil + removed = nil + if didRemove { wakeAdvisoryLockWaiters() } + } + private func darwinLockRecord(_ request: FuseLockRequest) throws -> flock { guard request.start <= request.end, request.start <= UInt64(Int64.max) else { @@ -1518,7 +1297,6 @@ public final class FuseServer: @unchecked Sendable { } private func releaseAdvisoryLocks(nodeID: UInt64, owner: UInt64) { - guard owner != 0 else { return } cancelBlockingLocks(nodeID: nodeID, owner: owner) var released: [AdvisoryLockDescriptor] = lock.withLock { let keys = lockOwners.keys.filter { $0.nodeID == nodeID && $0.owner == owner } @@ -1534,49 +1312,6 @@ public final class FuseServer: @unchecked Sendable { return successResponse(unique: header.unique, payload: []) } - private func handleSetupMapping(header: FuseInHeader, payload: ArraySlice) throws -> [UInt8] { - guard let daxWindow else { - return errorResponse(unique: header.unique, errno: ENOSYS) - } - let request = try FuseProtocol.decodeSetupMappingIn(Array(payload)) - let knownMappingFlags = FuseSetupMappingFlag.read.rawValue - | FuseSetupMappingFlag.write.rawValue - guard request.flags & ~knownMappingFlags == 0 else { - return errorResponse(unique: header.unique, errno: EINVAL) - } - // virtio-fs sends fh = -1 for inode-based DAX mappings; resolve the file from the node id. - // The backend mmaps read-write (Apple's hv_vm_map rejects a read-only host region), so the - // fd must be writable; a read-only file therefore falls back to plain FUSE reads via the - // thrown error. The backend keeps its own mmap, so a temporary open is closed after setup. - let fd: Int32 - var temporaryFD: Int32? - if request.fileHandle == UInt64.max { - fd = try hostFS.openReadWrite(nodeID: header.nodeID) - temporaryFD = fd - } else if let openHandle = loadFile(handle: request.fileHandle), - openHandle.nodeID == header.nodeID, - request.flags & FuseSetupMappingFlag.write.rawValue == 0 - || openHandle.permitsWrite, - request.flags & FuseSetupMappingFlag.read.rawValue == 0 - || openHandle.permitsRead(writebackCache: false) { - fd = openHandle.fd - } else { - return errorResponse(unique: header.unique, errno: EBADF) - } - defer { if let temporaryFD { hostFS.close(handle: temporaryFD) } } - _ = try daxWindow.setup(request, fileDescriptor: fd) - return successResponse(unique: header.unique, payload: []) - } - - private func handleRemoveMapping(header: FuseInHeader, payload: ArraySlice) throws -> [UInt8] { - guard let daxWindow else { - return errorResponse(unique: header.unique, errno: ENOSYS) - } - let request = try FuseProtocol.decodeRemoveMappingIn(Array(payload)) - try daxWindow.remove(request) - return successResponse(unique: header.unique, payload: []) - } - /// HostFS already reserved the node lifetime atomically with its identity duplicate. This map /// insertion cannot fail, so ownership of both the fd and the open-handle reference transfers /// directly to the returned FUSE handle. @@ -1584,14 +1319,16 @@ public final class FuseServer: @unchecked Sendable { fd: Int32, nodeID: UInt64, accessMode: HostFSAccessMode, - append: Bool + append: Bool, + resourceToken: FuseResourceToken ) -> UInt64 { lock.withLock { storeFileLocked( fd: fd, nodeID: nodeID, accessMode: accessMode, - append: append + append: append, + resourceToken: resourceToken ) } } @@ -1600,7 +1337,8 @@ public final class FuseServer: @unchecked Sendable { fd: Int32, nodeID: UInt64, accessMode: HostFSAccessMode, - append: Bool + append: Bool, + resourceToken: FuseResourceToken ) -> UInt64 { let handle = allocateFileHandleLocked() fileHandles[handle] = OpenFileHandle( @@ -1608,16 +1346,31 @@ public final class FuseServer: @unchecked Sendable { nodeID: nodeID, accessMode: accessMode, append: append, - hostFS: hostFS + hostFS: hostFS, + resourceToken: resourceToken ) return handle } private func storeDirectory(nodeID: UInt64) throws -> UInt64 { + let resourceToken = try resourceQuota.acquire(.directoryHandles) try hostFS.retainOpenHandle(nodeID: nodeID) + let cursor: HostFSDirectoryCursor + do { + cursor = try hostFS.openDirectoryCursor(nodeID: nodeID) + } catch { + hostFS.releaseOpenHandle(nodeID: nodeID) + throw error + } return lock.withLock { let handle = allocateDirectoryHandleLocked() - directoryHandles[handle] = OpenDirectoryHandle(nodeID: nodeID, hostFS: hostFS) + directoryHandles[handle] = OpenDirectoryHandle( + nodeID: nodeID, + cursor: cursor, + hostFS: hostFS, + resourceQuota: resourceQuota, + resourceToken: resourceToken + ) return handle } } @@ -1643,42 +1396,6 @@ public final class FuseServer: @unchecked Sendable { lock.withLock { directoryHandles[handle] } } - private func directoryEntries( - handle: UInt64, - nodeID: UInt64, - refresh: Bool - ) throws -> [HostFSEntry?]? { - guard let directory = loadDirectory(handle: handle), directory.nodeID == nodeID else { - return nil - } - directoryOperationLoadedTestHook?() - let cached = lock.withLock { directory.entries } - if !refresh, let entries = cached { return entries } - - // Host enumeration can perform many descriptor-relative stats; never hold the server's - // handle-table lock across it. If two first-page requests race, the first installed - // snapshot wins and both callers use that same stable cookie space. - let discovered = try hostFS.readdirplus(nodeID: nodeID) - return lock.withLock { - if !refresh, let entries = directory.entries { return entries } - if let entries = directory.entries { - var currentByName = Dictionary(uniqueKeysWithValues: discovered.map { ($0.name, $0) }) - var stableSlots = entries.map { previous -> HostFSEntry? in - guard let previous else { return nil } - return currentByName.removeValue(forKey: previous.name) - } - // `discovered` is sorted, so additions are deterministic while existing cookies - // retain their original slot numbers. Replacements update attributes in place. - stableSlots.append(contentsOf: discovered.compactMap { currentByName[$0.name] }) - directory.entries = stableSlots - return stableSlots - } - let initial = discovered.map(Optional.some) - directory.entries = initial - return initial - } - } - private func releaseFile(handle: UInt64) { // Dropping the table's strong reference closes immediately only when no request queue is // still using the handle. Otherwise OpenFileHandle.deinit runs after the last operation. @@ -1786,17 +1503,6 @@ public final class FuseServer: @unchecked Sendable { appendAttr(attrs, to: &data) } - private func appendEntryOut(_ attrs: HostFSAttributes, to writer: inout FuseDirectResponseWriter) { - let cache = cachePolicy.responsePolicy - writer.appendLE(attrs.nodeID) - writer.appendLE(UInt64(1)) - writer.appendLE(cache.entryValiditySeconds) // entry_valid - writer.appendLE(cache.attrValiditySeconds) // attr_valid - writer.appendLE(UInt32(0)) - writer.appendLE(UInt32(0)) - appendAttr(attrs, to: &writer) - } - private func appendAttrOut(_ attrs: HostFSAttributes, to data: inout [UInt8]) { let cache = cachePolicy.responsePolicy data.appendLE(cache.attrValiditySeconds) // attr_valid @@ -1805,26 +1511,12 @@ public final class FuseServer: @unchecked Sendable { appendAttr(attrs, to: &data) } - private func appendAttrOut(_ attrs: HostFSAttributes, to writer: inout FuseDirectResponseWriter) { - let cache = cachePolicy.responsePolicy - writer.appendLE(cache.attrValiditySeconds) // attr_valid - writer.appendLE(UInt32(0)) - writer.appendLE(UInt32(0)) - appendAttr(attrs, to: &writer) - } - private func appendOpenOut(handle: UInt64, openFlags: UInt32, to data: inout [UInt8]) { data.appendLE(handle) data.appendLE(openFlags) data.appendLE(UInt32(0)) } - private func appendOpenOut(handle: UInt64, openFlags: UInt32, to writer: inout FuseDirectResponseWriter) { - writer.appendLE(handle) - writer.appendLE(openFlags) - writer.appendLE(UInt32(0)) - } - private func encodeDirentPlus(_ entry: HostFSEntry, offset: UInt64) -> [UInt8] { let name = Array(entry.name.utf8) var data = encodeEntryOut(entry.attributes) @@ -1837,6 +1529,10 @@ public final class FuseServer: @unchecked Sendable { return data } + private static func direntPlusEncodedLength(nameByteCount: Int) -> Int { + (128 + 24 + nameByteCount + 7) & ~7 + } + private func encodeAttr(_ attrs: HostFSAttributes) -> [UInt8] { var data = [UInt8]() data.reserveCapacity(88) @@ -1863,25 +1559,6 @@ public final class FuseServer: @unchecked Sendable { data.appendLE(UInt32(0)) } - private func appendAttr(_ attrs: HostFSAttributes, to writer: inout FuseDirectResponseWriter) { - writer.appendLE(attrs.nodeID) - writer.appendLE(attrs.size) - writer.appendLE((attrs.size + 511) / 512) - writer.appendLE(UInt64(bitPattern: attrs.atimeSeconds)) - writer.appendLE(UInt64(bitPattern: attrs.mtimeSeconds)) - writer.appendLE(UInt64(bitPattern: attrs.ctimeSeconds)) - writer.appendLE(attrs.atimeNsec) - writer.appendLE(attrs.mtimeNsec) - writer.appendLE(attrs.ctimeNsec) - writer.appendLE(attrs.mode) - writer.appendLE(attrs.linkCount) - writer.appendLE(attrs.uid) - writer.appendLE(attrs.gid) - writer.appendLE(UInt32(0)) - writer.appendLE(UInt32(4096)) - writer.appendLE(UInt32(0)) - } - private func direntType(for attrs: HostFSAttributes) -> UInt32 { if attrs.isDirectory { return 4 } if attrs.isSymlink { return 10 } @@ -1898,24 +1575,6 @@ public final class FuseServer: @unchecked Sendable { try? hostFS.clearPrivilegedBits(handle: fd) } - private static func writebackCacheEnabledFromEnvironment() -> Bool { - // Default OFF: desktop shares are bidirectional. With FUSE writeback enabled, dirty guest - // pages can survive a reverse invalidation and later overwrite a host editor's change even - // after the notification barrier completes. Keep explicit opt-in for isolated experiments; - // production coherence requires write-through until dirty-page conflict handling exists. - guard let value = ProcessInfo.processInfo.environment["DORY_FUSE_WRITEBACK_CACHE"]?.lowercased() else { - return false - } - return ["1", "true", "yes", "on"].contains(value) - } - - private static func killPrivV2EnabledFromEnvironment() -> Bool { - guard let value = ProcessInfo.processInfo.environment["DORY_FUSE_KILLPRIV"]?.lowercased() else { - return true - } - return !["0", "false", "no", "off"].contains(value) - } - private var fileOpenFlags: UInt32 { // FOPEN_KEEP_CACHE cannot be revoked from handles that were opened before notification // health degraded. Metadata validity is bounded and fenceable; page-cache retention is not. @@ -1956,14 +1615,19 @@ public final class FuseServer: @unchecked Sendable { return EPROTO case RequestError.badFileDescriptor: return EBADF - case DaxWindowError.unaligned, DaxWindowError.outOfBounds, DaxWindowError.invalidWindow: - return EINVAL - case DaxWindowError.overlap: - return EBUSY - case DaxWindowError.missingMapping: - return ENOENT - case DaxWindowError.mappingFailed, DaxWindowError.unmappingFailed: - return EIO + case let quota as FuseResourceQuotaError: + // Node identities and every handle/lock-owner table entry retain a descriptor or a + // descriptor-backed identity, so EMFILE accurately reports per-share FD exhaustion. + // Blocking-lock admission consumes request capacity instead and is retryable once a + // waiter completes or is interrupted, matching Linux EAGAIN semantics. + switch quota.resource { + case .pendingBlockingLocks: + return EAGAIN + case .directoryCursorEntries, .directoryCursorNameBytes: + return EOVERFLOW + default: + return EMFILE + } default: return EIO } @@ -1988,34 +1652,6 @@ private final class FuseAnomalyLog: @unchecked Sendable { } } -private final class FuseStats: @unchecked Sendable { - private let lock = NSLock() - private var counts: [FuseOpcode: Int] = [:] - private var total = 0 - - static func fromEnvironment() -> FuseStats? { - let value = ProcessInfo.processInfo.environment["DORY_FUSE_STATS"] ?? "" - guard ["1", "true", "yes", "on"].contains(value.lowercased()) else { return nil } - FileHandle.standardError.write(Data("dory-hv: fuse stats enabled\n".utf8)) - return FuseStats() - } - - func record(_ opcode: FuseOpcode) { - let snapshot: (Int, [FuseOpcode: Int])? = lock.withLock { - total += 1 - counts[opcode, default: 0] += 1 - guard total <= 20 || total.isMultiple(of: 100) else { return nil } - return (total, counts) - } - guard let snapshot else { return } - let line = snapshot.1 - .sorted { lhs, rhs in lhs.key.rawValue < rhs.key.rawValue } - .map { "\($0.key)=\($0.value)" } - .joined(separator: " ") - FileHandle.standardError.write(Data("dory-hv: fuse stats total=\(snapshot.0) \(line)\n".utf8)) - } -} - private extension NSLock { func withLock(_ body: () throws -> R) rethrows -> R { lock() @@ -2023,54 +1659,3 @@ private extension NSLock { return try body() } } - -private struct FuseDirectResponseWriter { - private let writable: [VirtqueueSegment] - private var segmentIndex = 0 - private var segmentOffset = 0 - private(set) var written = 0 - - init(writable: [VirtqueueSegment]) { - self.writable = writable - } - - mutating func appendOutHeader(unique: UInt64, error: Int32, payloadByteCount: Int) { - appendLE(UInt32(FuseOutHeader.byteCount + payloadByteCount)) - appendLE(UInt32(bitPattern: error)) - appendLE(unique) - } - - mutating func append(_ bytes: [UInt8]) { - bytes.withUnsafeBytes { append($0) } - } - - mutating func appendLE(_ value: UInt32) { - var value = value.littleEndian - withUnsafeBytes(of: &value) { append($0) } - } - - mutating func appendLE(_ value: UInt64) { - var value = value.littleEndian - withUnsafeBytes(of: &value) { append($0) } - } - - private mutating func append(_ source: UnsafeRawBufferPointer) { - var copied = 0 - while copied < source.count, segmentIndex < writable.count { - let segment = writable[segmentIndex] - let remainingInSegment = segment.length - segmentOffset - if remainingInSegment <= 0 { - segmentIndex += 1 - segmentOffset = 0 - continue - } - let take = min(remainingInSegment, source.count - copied) - segment.pointer - .advanced(by: segmentOffset) - .copyMemory(from: source.baseAddress!.advanced(by: copied), byteCount: take) - copied += take - segmentOffset += take - written += take - } - } -} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFS.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/HostFS.swift similarity index 87% rename from Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFS.swift rename to Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/HostFS.swift index e6c4513c..de12b238 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFS.swift +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerServiceCore/HostFS.swift @@ -1,4 +1,5 @@ import Darwin +import DoryFSWorkerContracts import Foundation public enum HostFSError: Error, Equatable { @@ -107,6 +108,12 @@ public struct HostFSInvalidationSnapshot: Equatable, Sendable { } } +struct HostFSEventPathIdentity: Equatable, Sendable { + let device: UInt64 + let inode: UInt64 + let generation: UInt64 +} + public enum HostFSTimestampUpdate: Equatable, Sendable { case value(seconds: Int64, nanoseconds: UInt32) case now @@ -156,7 +163,7 @@ public struct HostFSSetattrRequest: Equatable, Sendable { } } -public final class HostFS: @unchecked Sendable { +final class HostFS: @unchecked Sendable { public static let rootNodeID: UInt64 = 1 public static let maxReadCount: Int = 1 << 20 @@ -196,6 +203,8 @@ public final class HostFS: @unchecked Sendable { /// description prevents Darwin from recycling this inode number while the guest can still /// refer to its node ID, including after every pathname has been removed. var identityFD: Int32 + /// Admission for this non-root identity descriptor. Root is structural and has no token. + var resourceToken: FuseResourceToken? = nil /// Number of lookup references handed to the guest in fuse_entry_out records. A detached /// node stays alive until the matching FORGET/BATCH_FORGET requests release these refs. var lookupCount: UInt64 = 0 @@ -217,11 +226,16 @@ public final class HostFS: @unchecked Sendable { } private let rootPath: String + /// Worker-local diagnostic spelling derived from the pinned root descriptor. This never + /// crosses the worker contract; it exists only so the same process that performs guest FUSE + /// mutations can attach `IgnoreSelf` FSEvents streams with correct source attribution. + var eventRootPath: String { rootPath } /// Accept both the caller's standardized spelling and the canonical `realpath` spelling. /// FSEvents reports paths in the spelling used to create its stream, which can differ across /// macOS aliases such as `/var` and `/private/var`. private let hostEventRootPaths: [String] private let rootFD: Int32 + let resourceQuota: FuseResourceQuota private let guestUID: UInt32 private let guestGID: UInt32 private let readOnly: Bool @@ -246,7 +260,7 @@ public final class HostFS: @unchecked Sendable { private var knownChildNamesByParentPath: [String: Set] = [:] /// Called after the guest resolves a real host path. Production uses this to subscribe /// FSEvents only to the accessed top-level project/volume instead of the entire export root. - private var eventObservationHandler: (@Sendable (String) -> Void)? + private var eventObservationHandler: (@Sendable (String) throws -> Void)? private let lock = NSLock() /// Deterministic test seam for the narrow unlinkat-to-index-update window. @@ -273,7 +287,7 @@ public final class HostFS: @unchecked Sendable { } } - public init( + public convenience init( rootPath: String, // The host filesystem server runs as the signed-in macOS user. Export that identity by // default so the standard PUID/PGID convention (`id -u` / `id -g`) sees the same owner on @@ -282,7 +296,8 @@ public final class HostFS: @unchecked Sendable { guestGID: UInt32 = getgid(), readOnly: Bool = false, hiddenNames: Set = [], - rootHiddenNames: Set = [] + rootHiddenNames: Set = [], + resourceLimits: FuseResourceLimits = .production ) throws { var resolved = [CChar](repeating: 0, count: Int(PATH_MAX)) guard realpath(rootPath, &resolved) != nil else { @@ -295,10 +310,112 @@ public final class HostFS: @unchecked Sendable { throw HostFSError.invalidRoot(rootPath) } - self.rootPath = root let suppliedRoot = URL(fileURLWithPath: rootPath).standardizedFileURL.path - self.hostEventRootPaths = Array(Set([root, suppliedRoot])).sorted { $0.count > $1.count } - self.rootFD = fd + try self.init( + ownedRootFD: fd, + rootPath: root, + hostEventRootPaths: Self.hostEventRootSpellings([root, suppliedRoot]), + invalidRootDescription: rootPath, + guestUID: guestUID, + guestGID: guestGID, + readOnly: readOnly, + hiddenNames: hiddenNames, + rootHiddenNames: rootHiddenNames, + resourceLimits: resourceLimits + ) + } + + /// Constructs the share from an already-authorized directory descriptor. The caller retains + /// its descriptor; HostFS duplicates it with CLOEXEC and never reopens `eventRootPath` for + /// filesystem authority. This is the production seam for a sandbox worker that received the + /// open descriptor over XPC and verified the pinned root identity before FUSE admission. + public convenience init( + rootDirectoryFileDescriptor: Int32, + eventRootPath: String, + guestUID: UInt32 = getuid(), + guestGID: UInt32 = getgid(), + readOnly: Bool = false, + hiddenNames: Set = [], + rootHiddenNames: Set = [], + resourceLimits: FuseResourceLimits = .production + ) throws { + guard rootDirectoryFileDescriptor >= 0, + eventRootPath.hasPrefix("/"), + !eventRootPath.utf8.contains(0) else { + throw HostFSError.invalidRoot(eventRootPath) + } + let fd = fcntl(rootDirectoryFileDescriptor, F_DUPFD_CLOEXEC, 0) + guard fd >= 0 else { + throw HostFSError.systemCall("duplicate authorized root", errno) + } + let standardizedRoot = URL(fileURLWithPath: eventRootPath).standardizedFileURL.path + try self.init( + ownedRootFD: fd, + rootPath: standardizedRoot, + hostEventRootPaths: Self.hostEventRootSpellings([standardizedRoot]), + invalidRootDescription: eventRootPath, + guestUID: guestUID, + guestGID: guestGID, + readOnly: readOnly, + hiddenNames: hiddenNames, + rootHiddenNames: rootHiddenNames, + resourceLimits: resourceLimits + ) + } + + /// Constructs the production share from pinned descriptor authority only. The diagnostic + /// spelling used by the worker-local event machinery is derived from that descriptor with + /// `F_GETPATH`; no caller can pair an authorized descriptor with an unrelated host path. + public convenience init( + rootDirectoryFileDescriptor: Int32, + guestUID: UInt32 = getuid(), + guestGID: UInt32 = getgid(), + readOnly: Bool = false, + hiddenNames: Set = [], + rootHiddenNames: Set = [], + resourceLimits: FuseResourceLimits = .production + ) throws { + guard rootDirectoryFileDescriptor >= 0 else { + throw HostFSError.invalidRoot("authorized root descriptor") + } + var path = [CChar](repeating: 0, count: Int(PATH_MAX)) + guard fcntl(rootDirectoryFileDescriptor, F_GETPATH, &path) == 0 else { + throw HostFSError.systemCall("inspect authorized root", errno) + } + let pathBytes = path.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + let eventRootPath = String(decoding: pathBytes, as: UTF8.self) + guard eventRootPath.hasPrefix("/"), eventRootPath != "/" else { + throw HostFSError.invalidRoot("authorized root descriptor") + } + try self.init( + rootDirectoryFileDescriptor: rootDirectoryFileDescriptor, + eventRootPath: eventRootPath, + guestUID: guestUID, + guestGID: guestGID, + readOnly: readOnly, + hiddenNames: hiddenNames, + rootHiddenNames: rootHiddenNames, + resourceLimits: resourceLimits + ) + } + + /// Designated initializer taking ownership of `ownedRootFD`, including every failure path. + private init( + ownedRootFD: Int32, + rootPath: String, + hostEventRootPaths: [String], + invalidRootDescription: String, + guestUID: UInt32, + guestGID: UInt32, + readOnly: Bool, + hiddenNames: Set, + rootHiddenNames: Set, + resourceLimits: FuseResourceLimits + ) throws { + self.rootPath = rootPath + self.hostEventRootPaths = hostEventRootPaths + self.rootFD = ownedRootFD + self.resourceQuota = FuseResourceQuota(limits: resourceLimits) self.guestUID = guestUID self.guestGID = guestGID self.readOnly = readOnly @@ -306,14 +423,15 @@ public final class HostFS: @unchecked Sendable { self.rootHiddenNameKeys = Set(rootHiddenNames.map(Self.hiddenNameKey)) var st = stat() - guard fstat(fd, &st) == 0 else { - Darwin.close(fd) - throw HostFSError.invalidRoot(rootPath) + guard fstat(ownedRootFD, &st) == 0, + st.st_mode & S_IFMT == S_IFDIR else { + Darwin.close(ownedRootFD) + throw HostFSError.invalidRoot(invalidRootDescription) } - let identityFD = fcntl(fd, F_DUPFD_CLOEXEC, 0) + let identityFD = fcntl(ownedRootFD, F_DUPFD_CLOEXEC, 0) guard identityFD >= 0 else { let savedErrno = errno - Darwin.close(fd) + Darwin.close(ownedRootFD) throw HostFSError.systemCall("pin root identity", savedErrno) } let attrs = Self.attributes(from: st, nodeID: Self.rootNodeID, uid: guestUID, gid: guestGID) @@ -323,24 +441,137 @@ public final class HostFS: @unchecked Sendable { attachedPaths: [""], attributes: attrs, fileKey: FileKey(st), - identityFD: identityFD + identityFD: identityFD, + resourceToken: nil ) self.idsByFileKey[FileKey(st)] = [Self.rootNodeID] self.idsByRelativePath["", default: []].append(Self.rootNodeID) } - public func setEventObservationHandler(_ handler: (@Sendable (String) -> Void)?) { + public func setEventObservationHandler(_ handler: (@Sendable (String) throws -> Void)?) { lock.withLock { eventObservationHandler = handler } } - private func notifyEventObservation(for relativePath: String) { + /// Chooses a real directory root for FSEvents using descriptor-confined classification. A + /// positive top-level directory gets a narrow stream. A top-level file uses the pinned share + /// directory because FSEvents roots are directories; this is less selective but still bounded + /// to the single explicit capability. + func eventObservationRoot(forHostPath hostPath: String) throws -> String { + guard let relativePath = eventRelativePath(forHostPath: hostPath) else { + throw HostFSError.permissionDenied("invalid host-event observation path") + } + if relativePath.isEmpty { return rootPath } + guard let first = relativePath.split( + separator: "/", + omittingEmptySubsequences: true + ).first else { + throw HostFSError.permissionDenied("invalid host-event observation path") + } + let topLevel = String(first) + var status = stat() + let result = fstatat(rootFD, cPath(topLevel), &status, Self.containedStatFlags) + if result == 0, status.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) { + return rootPath + "/" + topLevel + } + if result == 0 { return rootPath } + let savedErrno = errno + if savedErrno == ENOENT || savedErrno == ENOTDIR || savedErrno == ELOOP { + return rootPath + } + throw HostFSError.systemCall("classify host-event observation root", savedErrno) + } + + /// Returns the descriptor-confined identity that an FSEvents root pathname must still name. + func eventObservationIdentity(forRootPath hostPath: String) throws -> HostFSEventPathIdentity { + guard let relativePath = eventRelativePath(forHostPath: hostPath) else { + throw HostFSError.permissionDenied("observation root outside pinned root") + } + var status = stat() + let result = relativePath.isEmpty + ? fstat(rootFD, &status) + : fstatat(rootFD, cPath(relativePath), &status, Self.containedStatFlags) + guard result == 0, + status.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) else { + throw HostFSError.systemCall("inspect host-event observation root", errno) + } + return HostFSEventPathIdentity( + device: UInt64(truncatingIfNeeded: status.st_dev), + inode: UInt64(truncatingIfNeeded: status.st_ino), + generation: UInt64(truncatingIfNeeded: status.st_gen) + ) + } + + /// Classifies an FSEvents pathname using only the already-pinned root descriptor. The worker + /// must never turn an event string into ambient pathname authority with `lstat(2)`: a parent + /// can be renamed between lexical validation and lookup. Darwin's `AT_RESOLVE_BENEATH` keeps + /// the entire classification in the capability's directory tree in one VFS operation. + func eventPathIsMissing(forHostPath hostPath: String) throws -> Bool { + guard let relativePath = eventRelativePath(forHostPath: hostPath) else { + throw HostFSError.permissionDenied("event path outside pinned root") + } + var status = stat() + let result = relativePath.isEmpty + ? fstat(rootFD, &status) + : fstatat(rootFD, cPath(relativePath), &status, Self.containedStatFlags) + if result == 0 { return false } + let savedErrno = errno + if savedErrno == ENOENT || savedErrno == ENOTDIR || savedErrno == ELOOP { + return true + } + throw HostFSError.systemCall("classify host event", savedErrno) + } + + /// Finds the closest existing regular file or directory for a watcher nudge without resolving + /// an absolute host pathname. Missing entries walk their already-validated relative parents, + /// never above this capability's pinned root and never through a symlink. + func nearestEventNudgeRelativePath(forHostPath hostPath: String) throws -> String? { + guard var relativePath = eventRelativePath(forHostPath: hostPath) else { + throw HostFSError.permissionDenied("event path outside pinned root") + } + while true { + var status = stat() + let result = relativePath.isEmpty + ? fstat(rootFD, &status) + : fstatat(rootFD, cPath(relativePath), &status, Self.containedStatFlags) + if result == 0 { + let kind = status.st_mode & mode_t(S_IFMT) + return kind == mode_t(S_IFREG) || kind == mode_t(S_IFDIR) + ? relativePath + : nil + } + let savedErrno = errno + guard savedErrno == ENOENT || savedErrno == ENOTDIR || savedErrno == ELOOP else { + throw HostFSError.systemCall("resolve host-event nudge", savedErrno) + } + guard !relativePath.isEmpty else { return nil } + if let separator = relativePath.lastIndex(of: "/") { + relativePath = String(relativePath[.. Bool { + eventRelativePath(forHostPath: hostPath) != nil + } + + public var resourceSnapshot: FuseResourceSnapshot { + resourceQuota.snapshot() + } + + private func notifyEventObservation(for relativePath: String) throws { guard !relativePath.isEmpty else { return } let handler = lock.withLock { eventObservationHandler } - handler?(rootPath + "/" + relativePath) + try handler?(rootPath + "/" + relativePath) } deinit { for node in nodes.values { + node.resourceToken?.release() Darwin.close(node.identityFD) identityPinClosedTestHook?(node.id, node.identityFD) } @@ -681,6 +912,30 @@ public final class HostFS: @unchecked Sendable { } } + private static func hostEventRootSpellings(_ roots: [String]) -> [String] { + let systemAliases = [ + (canonical: "/private/tmp", alias: "/tmp"), + (canonical: "/private/var", alias: "/var"), + (canonical: "/private/etc", alias: "/etc"), + ] + var spellings = Set(roots) + for root in roots { + for pair in systemAliases { + for (source, target) in [ + (pair.canonical, pair.alias), + (pair.alias, pair.canonical), + ] { + if root == source { + spellings.insert(target) + } else if root.hasPrefix(source + "/") { + spellings.insert(target + root.dropFirst(source.count)) + } + } + } + } + return spellings.sorted { $0.count > $1.count } + } + private func eventRelativePath(forHostPath hostPath: String) -> String? { guard hostPath.hasPrefix("/"), !hostPath.utf8.contains(0) else { return nil } let standardizedPath = URL(fileURLWithPath: hostPath).standardizedFileURL.path @@ -741,6 +996,14 @@ public final class HostFS: @unchecked Sendable { } public func lookupIfExists(parent: UInt64, name: String) throws -> HostFSEntry? { + try lookupIfExists(parent: parent, name: name, nodeReservation: nil) + } + + private func lookupIfExists( + parent: UInt64, + name: String, + nodeReservation suppliedReservation: FuseResourceToken? + ) throws -> HostFSEntry? { try validateComponent(name) try requireVisible(name, parent: parent) let parentNode = try attachedNode(for: parent) @@ -748,10 +1011,6 @@ public final class HostFS: @unchecked Sendable { throw HostFSError.notDirectory(parent) } let relative = join(parentNode.relativePath, name) - // Arm the narrow top-level observation root before the namespace stat becomes the lookup - // linearization point. A host replacement after this call is therefore observable before - // Linux can cache the positive result. - notifyEventObservation(for: relative) var st = stat() let result = fstatat(rootFD, cPath(relative), &st, Self.containedStatFlags) guard result == 0 else { @@ -772,6 +1031,11 @@ public final class HostFS: @unchecked Sendable { throw HostFSError.operationNotSupported("special host file: \(relative)") } + // Arm observation after proving this is a positive lookup but before returning or pinning + // the identity. A replacement after stream start is delivered; a replacement before the + // later pin is incorporated into the identity that Linux receives. + try notifyEventObservation(for: relative) + // A real (non-synthetic) identity is already pinned for the common repeated-lookup case. // The contained fstatat above is the namespace linearization point: when its generation- // aware key still matches that pin, reopening and fstatting the same object adds no safety. @@ -781,6 +1045,13 @@ public final class HostFS: @unchecked Sendable { return cached } + let nodeReservation: FuseResourceToken + if let suppliedReservation { + nodeReservation = suppliedReservation + } else { + nodeReservation = try resourceQuota.acquire(.liveNonRootNodes) + } + let identity: PinnedIdentity do { identity = try pinIdentity(relativePath: relative, expectedMode: st.st_mode) @@ -788,7 +1059,12 @@ public final class HostFS: @unchecked Sendable { detachLookupMiss(relativePath: relative) return nil } - return register(name: name, relativePath: relative, identity: identity) + return try register( + name: name, + relativePath: relative, + identity: identity, + resourceToken: nodeReservation + ) } private func cachedLookupEntry( @@ -1479,6 +1755,9 @@ public final class HostFS: @unchecked Sendable { let parentNode = try attachedNode(for: parent) guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) } let relative = join(parentNode.relativePath, name) + // Reserve identity capacity before O_CREAT can mutate the exported namespace. Coalescing + // with an already-pinned inode returns the token automatically after registration. + let nodeReservation = try resourceQuota.acquire(.liveNonRootNodes) let createOptions = (exclusive ? O_EXCL : 0) | (truncate ? O_TRUNC : 0) // Descriptor exhaustion is checked before O_CREAT mutates the namespace so the forced // test path observes no created file. A real F_DUPFD_CLOEXEC failure after creation is @@ -1530,11 +1809,12 @@ public final class HostFS: @unchecked Sendable { ) if syntheticAttributes { return ( - registerCreatedFile( + try registerCreatedFile( name: name, relativePath: relative, mode: mode, identity: identity, + resourceToken: nodeReservation, retainOpenHandle: retainOpenHandle, ownerUID: ownerUID, ownerGID: ownerGID @@ -1543,10 +1823,11 @@ public final class HostFS: @unchecked Sendable { ) } return ( - register( + try register( name: name, relativePath: relative, identity: identity, + resourceToken: nodeReservation, retainOpenHandle: retainOpenHandle, ownerUID: ownerUID, ownerGID: ownerGID @@ -1573,6 +1854,7 @@ public final class HostFS: @unchecked Sendable { let parentNode = try attachedNode(for: parent) guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) } let relative = join(parentNode.relativePath, name) + let nodeReservation = try resourceQuota.acquire(.liveNonRootNodes) let stagingName = temporaryEntryName() let result = stagingName.withCString { pointer in mkdirat(rootFD, pointer, mode_t(mode)) @@ -1598,11 +1880,18 @@ public final class HostFS: @unchecked Sendable { name: name, relativePath: relative, mode: mode, + resourceToken: nodeReservation, ownerUID: ownerUID, ownerGID: ownerGID ) } - let entry = try lookup(parent: parent, name: name) + guard let entry = try lookupIfExists( + parent: parent, + name: name, + nodeReservation: nodeReservation + ) else { + throw HostFSError.notFound(name) + } updateVirtualOwnership(nodeID: entry.nodeID, uid: ownerUID, gid: ownerGID) return HostFSEntry( name: entry.name, @@ -1627,6 +1916,7 @@ public final class HostFS: @unchecked Sendable { let parentNode = try attachedNode(for: parent) guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) } let relative = join(parentNode.relativePath, name) + let nodeReservation = try resourceQuota.acquire(.liveNonRootNodes) let stagingName = temporaryEntryName() let result = target.withCString { targetPointer in stagingName.withCString { namePointer in @@ -1649,7 +1939,13 @@ public final class HostFS: @unchecked Sendable { _ = unlinkat(rootFD, cPath(stagingName), Self.containedUnlinkFlags) throw HostFSError.systemCall("symlink \(relative)", savedErrno) } - let entry = try lookup(parent: parent, name: name) + guard let entry = try lookupIfExists( + parent: parent, + name: name, + nodeReservation: nodeReservation + ) else { + throw HostFSError.notFound(name) + } updateVirtualOwnership(nodeID: entry.nodeID, uid: ownerUID, gid: ownerGID) return HostFSEntry( name: entry.name, @@ -1862,7 +2158,9 @@ public final class HostFS: @unchecked Sendable { return try lookup(parent: newParent, name: newName) } - public func readdirplus(nodeID: UInt64) throws -> [HostFSEntry] { + /// Opens one identity-checked directory stream. The caller owns stable FUSE cookie slots; this + /// object exposes names incrementally and never snapshots, sorts, or registers the directory. + func openDirectoryCursor(nodeID: UInt64) throws -> HostFSDirectoryCursor { let node = try attachedNode(for: nodeID) guard node.attributes.isDirectory else { throw HostFSError.notDirectory(nodeID) @@ -1893,36 +2191,31 @@ public final class HostFS: @unchecked Sendable { Darwin.close(fd) throw HostFSError.systemCall("fdopendir \(node.relativePath)", savedErrno) } - defer { closedir(directory) } + return HostFSDirectoryCursor( + owner: ObjectIdentifier(self), + nodeID: nodeID, + directory: directory, + pathForErrors: node.relativePath + ) + } - var names: [String] = [] - errno = 0 - while let entry = readdir(directory) { - let length = Int(entry.pointee.d_namlen) - let name = withUnsafeBytes(of: entry.pointee.d_name) { bytes in - String(decoding: bytes.prefix(length), as: UTF8.self) - } - if name != ".", name != "..", !isHiddenName(name, parent: nodeID) { - names.append(name) - } - errno = 0 + func rewindDirectoryCursor(_ cursor: HostFSDirectoryCursor) throws { + guard cursor.owner == ObjectIdentifier(self) else { + throw HostFSError.invalidRoot("directory cursor belongs to another HostFS authority") } - let savedErrno = errno - guard savedErrno == 0 else { - throw HostFSError.systemCall("readdir \(node.relativePath)", savedErrno) + cursor.rewind() + } + + func nextDirectoryName(from cursor: HostFSDirectoryCursor) throws -> String? { + guard cursor.owner == ObjectIdentifier(self) else { + throw HostFSError.invalidRoot("directory cursor belongs to another HostFS authority") } - var entries: [HostFSEntry] = [] - for name in names.sorted() { - do { - entries.append(try lookup(parent: nodeID, name: name)) - } catch HostFSError.operationNotSupported { - // Unsupported host special files are intentionally absent from directory listings - // for the same reason direct lookup rejects them: FUSE cannot safely proxy their - // host-side blocking/IPC semantics. - continue + while let name = try cursor.nextRawName() { + if name != ".", name != "..", !isHiddenName(name, parent: cursor.nodeID) { + return name } } - return entries + return nil } public func statfs() throws -> HostFSStat { @@ -2466,6 +2759,7 @@ public final class HostFS: @unchecked Sendable { private func retireNodeLocked(_ id: UInt64) { guard id != Self.rootNodeID, let node = nodes.removeValue(forKey: id) else { return } + node.resourceToken?.release() removeFileKeyIndexLocked(node.fileKey, nodeID: id) for path in node.tombstonePaths { detachedIDsByRelativePath[path]?.remove(id) @@ -2521,10 +2815,11 @@ public final class HostFS: @unchecked Sendable { name: String, relativePath: String, identity: PinnedIdentity, + resourceToken: FuseResourceToken, retainOpenHandle: Bool = false, ownerUID: UInt32? = nil, ownerGID: UInt32? = nil - ) -> HostFSEntry { + ) throws -> HostFSEntry { let st = identity.status let key = FileKey(st) let result = lock.withLock { () -> (entry: HostFSEntry, retainedIdentityFD: Bool) in @@ -2626,6 +2921,7 @@ public final class HostFS: @unchecked Sendable { attributes: attrs, fileKey: key, identityFD: identity.fd, + resourceToken: resourceToken, openHandleCount: retainOpenHandle ? 1 : 0 ) insertFileKeyIndexLocked(key, nodeID: id) @@ -2638,7 +2934,7 @@ public final class HostFS: @unchecked Sendable { if !result.retainedIdentityFD { Darwin.close(identity.fd) } - notifyEventObservation(for: relativePath) + try notifyEventObservation(for: relativePath) return result.entry } @@ -2647,16 +2943,18 @@ public final class HostFS: @unchecked Sendable { relativePath: String, mode: UInt16, identity: PinnedIdentity, + resourceToken: FuseResourceToken, retainOpenHandle: Bool = false, ownerUID: UInt32? = nil, ownerGID: UInt32? = nil - ) -> HostFSEntry { - return registerCreatedNode( + ) throws -> HostFSEntry { + return try registerCreatedNode( name: name, relativePath: relativePath, mode: mode, type: UInt32(S_IFREG), identity: identity, + resourceToken: resourceToken, retainOpenHandle: retainOpenHandle, ownerUID: ownerUID, ownerGID: ownerGID @@ -2667,16 +2965,18 @@ public final class HostFS: @unchecked Sendable { name: String, relativePath: String, mode: UInt16, + resourceToken: FuseResourceToken, ownerUID: UInt32? = nil, ownerGID: UInt32? = nil ) throws -> HostFSEntry { let identity = try pinIdentity(relativePath: relativePath, expectedMode: mode_t(S_IFDIR)) - return registerCreatedNode( + return try registerCreatedNode( name: name, relativePath: relativePath, mode: mode, type: UInt32(S_IFDIR), identity: identity, + resourceToken: resourceToken, ownerUID: ownerUID, ownerGID: ownerGID ) @@ -2688,10 +2988,11 @@ public final class HostFS: @unchecked Sendable { mode: UInt16, type: UInt32, identity: PinnedIdentity, + resourceToken: FuseResourceToken, retainOpenHandle: Bool = false, ownerUID: UInt32? = nil, ownerGID: UInt32? = nil - ) -> HostFSEntry { + ) throws -> HostFSEntry { var ts = timespec() clock_gettime(CLOCK_REALTIME, &ts) let entry = lock.withLock { @@ -2726,6 +3027,7 @@ public final class HostFS: @unchecked Sendable { attributes: attrs, fileKey: key, identityFD: identity.fd, + resourceToken: resourceToken, openHandleCount: retainOpenHandle ? 1 : 0 ) insertFileKeyIndexLocked(key, nodeID: id) @@ -2733,7 +3035,7 @@ public final class HostFS: @unchecked Sendable { notePathKeyPresentLocked(relativePath) return HostFSEntry(name: name, nodeID: id, attributes: attrs) } - notifyEventObservation(for: relativePath) + try notifyEventObservation(for: relativePath) return entry } @@ -2991,6 +3293,55 @@ public final class HostFS: @unchecked Sendable { } +/// A live descriptor-relative directory stream. Its lock makes rewind/read indivisible per call; +/// `FuseServer` additionally serializes complete page construction for one guest directory handle. +/// `closedir` owns and closes the descriptor accepted by `fdopendir`. +final class HostFSDirectoryCursor: @unchecked Sendable { + fileprivate let owner: ObjectIdentifier + fileprivate let nodeID: UInt64 + + private let directory: UnsafeMutablePointer + private let pathForErrors: String + private let lock = NSLock() + + fileprivate init( + owner: ObjectIdentifier, + nodeID: UInt64, + directory: UnsafeMutablePointer, + pathForErrors: String + ) { + self.owner = owner + self.nodeID = nodeID + self.directory = directory + self.pathForErrors = pathForErrors + } + + fileprivate func rewind() { + lock.withLock { rewinddir(directory) } + } + + fileprivate func nextRawName() throws -> String? { + try lock.withLock { + errno = 0 + guard let entry = readdir(directory) else { + let savedErrno = errno + guard savedErrno == 0 else { + throw HostFSError.systemCall("readdir \(pathForErrors)", savedErrno) + } + return nil + } + let length = Int(entry.pointee.d_namlen) + return withUnsafeBytes(of: entry.pointee.d_name) { bytes in + String(decoding: bytes.prefix(length), as: UTF8.self) + } + } + } + + deinit { + closedir(directory) + } +} + struct FileKey: Hashable, Sendable { var device: UInt64 var inode: UInt64 diff --git a/Packages/ContainerizationEngine/Sources/DoryFSWorkerXPCService/DoryFSWorkerMain.swift b/Packages/ContainerizationEngine/Sources/DoryFSWorkerXPCService/DoryFSWorkerMain.swift new file mode 100644 index 00000000..4c87037b --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryFSWorkerXPCService/DoryFSWorkerMain.swift @@ -0,0 +1,192 @@ +import Darwin +import DoryFSWorkerContracts +import DoryFSWorkerServiceCore +import Foundation +import XPC + +/// The only Objective-C object exported by the filesystem worker. The adapter deliberately adds +/// no object-model API of its own: every request and reply remains an exact bounded binary +/// envelope validated independently by `DoryFSWorkerService`. +private enum DoryFSWorkerReverseExchangeError: Error { + case unavailable + case timedOut +} + +private final class DoryFSWorkerReverseExchange: @unchecked Sendable { + private final class ReplyState: @unchecked Sendable { + let condition = NSCondition() + var result: Result? + + func resolve(_ value: Result) { + condition.withLock { + guard result == nil else { return } + result = value + condition.broadcast() + } + } + } + + private let connection: NSXPCConnection + + init(connection: NSXPCConnection) { + self.connection = connection + } + + func exchange(_ exactFrame: Data) throws -> Data { + let state = ReplyState() + guard let sink = connection.remoteObjectProxyWithErrorHandler({ _ in + state.resolve(.failure(.unavailable)) + }) as? DoryFSWorkerCoherenceSinkXPCProtocol else { + throw DoryFSWorkerReverseExchangeError.unavailable + } + sink.deliverCoherence(exactFrame) { reply in + state.resolve(.success(reply)) + } + let result = state.condition.withLock { + let deadline = Date( + timeIntervalSinceNow: Double( + DoryFSWorkerCoherenceTiming.reverseExchangeNanoseconds + ) / 1_000_000_000 + ) + while state.result == nil, state.condition.wait(until: deadline) {} + return state.result + } + guard let result else { throw DoryFSWorkerReverseExchangeError.timedOut } + return try result.get() + } +} + +private final class DoryFSWorkerXPCAdapter: NSObject, DoryFSWorkerXPCProtocol { + private let service: DoryFSWorkerService + + init(connection: NSXPCConnection) { + let reverseExchange = DoryFSWorkerReverseExchange(connection: connection) + service = DoryFSWorkerService( + coherenceExchange: { try reverseExchange.exchange($0) }, + onCoherenceFailure: { error in + FileHandle.standardError.write(Data( + "dory-fs-worker: host coherence failed: \(error)\n".utf8 + )) + Darwin._exit(EXIT_FAILURE) + } + ) + super.init() + } + + func bootstrap( + _ request: Data, + rootDescriptors: [FileHandle], + withReply reply: @escaping (Data) -> Void + ) { + reply(service.bootstrap( + exactBytes: request, + rootDescriptors: rootDescriptors + )) + } + + func exchange(_ frame: Data, withReply reply: @escaping (Data) -> Void) { + reply(service.exchange(exactFrame: frame)) + } + + func sendOneWay(_ frame: Data) { + service.sendOneWay(exactFrame: frame) + } + + func coherenceStatus(withReply reply: @escaping (Data) -> Void) { + reply(service.coherenceStatusExactBytes()) + } + + func prepareCoherence(withReply reply: @escaping (Data) -> Void) { + reply(service.prepareCoherenceExactBytes()) + } + + func activateCoherence(withReply reply: @escaping (Data) -> Void) { + reply(service.activateCoherenceExactBytes()) + } +} + +/// Accepts one runner connection for the lifetime of this process. A disconnected worker exits +/// instead of returning to launchd with live roots, handles, locks, or a reusable bootstrap gate. +private final class DoryFSWorkerListenerDelegate: + NSObject, + NSXPCListenerDelegate, + @unchecked Sendable +{ + private static let runnerSigningRequirement = """ + anchor apple generic and identifier "com.pythonxi.Dory.HVRunner" and \ + certificate leaf[subject.OU] = "864H636QW4" + """ + + private let admissionLock = NSLock() + private var adapter: DoryFSWorkerXPCAdapter? + private var acceptedConnection = false + + func listener( + _ listener: NSXPCListener, + shouldAcceptNewConnection connection: NSXPCConnection + ) -> Bool { + guard connection.processIdentifier > 1, + connection.effectiveUserIdentifier == geteuid(), + connection.effectiveGroupIdentifier == getegid() else { + return false + } + + let claimed = admissionLock.withLock { + guard !acceptedConnection else { return false } + acceptedConnection = true + return true + } + guard claimed else { return false } + + // XPC services are otherwise eligible for launchd's idle SIGKILL between FUSE requests. + // This connection owns process-local root descriptors, FUSE handles, and one immutable + // worker generation, so its entire accepted lifetime is one explicit XPC transaction. + // Invalidation exits the process below; there is deliberately no reconnect/end path. + xpc_transaction_begin() + + // Foundation validates every incoming message against the sender's audit-token-bound code + // identity. This avoids PID-only identity checks and rejects an unsigned, re-signed, or + // different-team process even if it can discover the service name. The requirement is a + // fixed source literal; malformed dynamic requirement strings are intentionally impossible. + connection.setCodeSigningRequirement(Self.runnerSigningRequirement) + + let adapter = DoryFSWorkerXPCAdapter(connection: connection) + self.adapter = adapter + connection.exportedInterface = DoryFSWorkerXPCInterface.make() + connection.exportedObject = adapter + connection.remoteObjectInterface = DoryFSWorkerXPCInterface.makeCoherenceSink() + connection.interruptionHandler = Self.terminateProcess + connection.invalidationHandler = Self.terminateProcess + connection.activate() + return true + } + + private static func terminateProcess() { + // `_exit` is intentional: process termination is the deterministic cleanup boundary for + // descriptors and blocking host filesystem operations. No fallback/reconnect is allowed. + Darwin._exit(EXIT_SUCCESS) + } +} + +@main +private enum DoryFSWorkerMain { + // NSXPCListener holds its delegate weakly, so process lifetime owns the one delegate strongly. + private static let listenerDelegate = DoryFSWorkerListenerDelegate() + + static func main() { + do { + try DoryFSWorkerProcessResources.raiseFileDescriptorSoftLimit() + } catch { + // The worker contract permits package-manager-scale descriptor ownership. Continuing + // with launchd's lower inherited soft limit would advertise capacity this process + // cannot honor, so fail before accepting or bootstrapping a workspace authority. + FileHandle.standardError.write(Data( + "dory-fs-worker: descriptor admission unavailable\n".utf8 + )) + Darwin._exit(EXIT_FAILURE) + } + let listener = NSXPCListener.service() + listener.delegate = listenerDelegate + listener.resume() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryGuestMemoryShim/DoryGuestMemoryShim.c b/Packages/ContainerizationEngine/Sources/DoryGuestMemoryShim/DoryGuestMemoryShim.c new file mode 100644 index 00000000..4fc8164a --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryGuestMemoryShim/DoryGuestMemoryShim.c @@ -0,0 +1,230 @@ +#include "DoryGuestMemoryShim.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { + DoryGuestMemoryHeaderByteCount = 48, + DoryGuestMemoryCreationAttempts = 16, +}; + +static const uint8_t DoryGuestMemoryMagic[16] = { + 'D', 'O', 'R', 'Y', '-', 'G', 'U', 'E', 'S', 'T', '-', 'R', 'A', 'M', 0, 1, +}; + +static void DoryStoreLittleEndian64(uint8_t *destination, uint64_t value) { + for (int index = 0; index < 8; ++index) { + destination[index] = (uint8_t)(value >> (index * 8)); + } +} + +static uint64_t DoryLoadLittleEndian64(const uint8_t *source) { + uint64_t value = 0; + for (int index = 0; index < 8; ++index) { + value |= ((uint64_t)source[index]) << (index * 8); + } + return value; +} + +uint64_t DoryGuestMemoryBackingDataOffset(void) { + return (uint64_t)getpagesize(); +} + +static int DoryDeclaredFileSize(uint64_t guest_size, uint64_t *declared_file_size) { + uint64_t data_offset = DoryGuestMemoryBackingDataOffset(); + if (guest_size == 0 || guest_size > (uint64_t)INT64_MAX - data_offset) { + errno = EOVERFLOW; + return -1; + } + *declared_file_size = guest_size + data_offset; + return 0; +} + +static void DoryMakeHeader( + uint8_t header[DoryGuestMemoryHeaderByteCount], + uint64_t guest_size, + const DoryGuestMemoryBackingIdentity *identity +) { + memset(header, 0, DoryGuestMemoryHeaderByteCount); + memcpy(header, DoryGuestMemoryMagic, sizeof(DoryGuestMemoryMagic)); + DoryStoreLittleEndian64(header + 16, 1); + DoryStoreLittleEndian64(header + 24, guest_size); + memcpy(header + 32, identity->bytes, sizeof(identity->bytes)); +} + +int DoryCreateGuestMemoryBacking( + uint64_t guest_size, + DoryGuestMemoryBackingIdentity *identity, + uint64_t *declared_file_size +) { + if (identity == NULL || declared_file_size == NULL) { + errno = EINVAL; + return -1; + } + if (DoryDeclaredFileSize(guest_size, declared_file_size) != 0) { + return -1; + } + + int descriptor = -1; + for (int attempt = 0; attempt < DoryGuestMemoryCreationAttempts; ++attempt) { + uint64_t name_nonce = 0; + arc4random_buf(&name_nonce, sizeof(name_nonce)); + char name[32]; + int length = snprintf( + name, + sizeof(name), + "/dory-%016llx", + (unsigned long long)name_nonce + ); + if (length <= 0 || (size_t)length >= sizeof(name)) { + errno = EINVAL; + return -1; + } + descriptor = shm_open(name, O_RDWR | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR); + if (descriptor < 0) { + if (errno == EEXIST) { + continue; + } + return -1; + } + + // Do not return or perform fallible sizing/header work while a name is still visible. + if (shm_unlink(name) != 0) { + int unlink_error = errno; + close(descriptor); + errno = unlink_error; + return -1; + } + break; + } + if (descriptor < 0) { + errno = EEXIST; + return -1; + } + + int descriptor_flags = fcntl(descriptor, F_GETFD); + if (descriptor_flags < 0 + || fcntl(descriptor, F_SETFD, descriptor_flags | FD_CLOEXEC) != 0 + || ftruncate(descriptor, (off_t)*declared_file_size) != 0) { + int setup_error = errno; + close(descriptor); + errno = setup_error; + return -1; + } + + arc4random_buf(identity->bytes, sizeof(identity->bytes)); + uint8_t header[DoryGuestMemoryHeaderByteCount]; + DoryMakeHeader(header, guest_size, identity); + size_t data_offset = (size_t)DoryGuestMemoryBackingDataOffset(); + void *authority_page = mmap( + NULL, + data_offset, + PROT_READ | PROT_WRITE, + MAP_SHARED, + descriptor, + 0 + ); + if (authority_page == MAP_FAILED) { + int map_error = errno; + close(descriptor); + errno = map_error; + return -1; + } + memcpy(authority_page, header, sizeof(header)); + if (munmap(authority_page, data_offset) != 0) { + int unmap_error = errno; + close(descriptor); + errno = unmap_error; + return -1; + } + return descriptor; +} + +int DoryReadGuestMemoryBackingIdentity( + int descriptor, + uint64_t declared_file_size, + DoryGuestMemoryBackingIdentity *identity +) { + if (descriptor < 0 || identity == NULL) { + errno = EINVAL; + return 0; + } + uint64_t data_offset = DoryGuestMemoryBackingDataOffset(); + if (declared_file_size <= data_offset || declared_file_size > (uint64_t)INT64_MAX) { + errno = EINVAL; + return 0; + } + + struct stat status; + if (fstat(descriptor, &status) != 0 + || (status.st_mode & S_IFMT) != 0 + || status.st_nlink != 0 + || status.st_size < 0 + || (uint64_t)status.st_size != declared_file_size) { + errno = EINVAL; + return 0; + } + int open_flags = fcntl(descriptor, F_GETFL); + if (open_flags < 0 || (open_flags & O_ACCMODE) != O_RDWR) { + errno = EINVAL; + return 0; + } + + size_t authority_page_size = (size_t)data_offset; + void *authority_page = mmap( + NULL, + authority_page_size, + PROT_READ, + MAP_SHARED, + descriptor, + 0 + ); + if (authority_page == MAP_FAILED) { + return 0; + } + uint8_t header[DoryGuestMemoryHeaderByteCount]; + memcpy(header, authority_page, sizeof(header)); + if (munmap(authority_page, authority_page_size) != 0) { + return 0; + } + if (memcmp(header, DoryGuestMemoryMagic, sizeof(DoryGuestMemoryMagic)) != 0 + || DoryLoadLittleEndian64(header + 16) != 1 + || DoryLoadLittleEndian64(header + 24) != declared_file_size - data_offset) { + errno = EINVAL; + return 0; + } + memcpy(identity->bytes, header + 32, sizeof(identity->bytes)); + return 1; +} + +int DoryGuestMemoryBackingMatches( + int descriptor, + uint64_t declared_file_size, + const DoryGuestMemoryBackingIdentity *identity +) { + if (identity == NULL) { + errno = EINVAL; + return 0; + } + int descriptor_flags = fcntl(descriptor, F_GETFD); + if (descriptor_flags < 0 || (descriptor_flags & FD_CLOEXEC) == 0) { + errno = EINVAL; + return 0; + } + DoryGuestMemoryBackingIdentity actual_identity; + if (!DoryReadGuestMemoryBackingIdentity( + descriptor, + declared_file_size, + &actual_identity + )) { + return 0; + } + return memcmp(identity->bytes, actual_identity.bytes, sizeof(identity->bytes)) == 0; +} diff --git a/Packages/ContainerizationEngine/Sources/DoryGuestMemoryShim/include/DoryGuestMemoryShim.h b/Packages/ContainerizationEngine/Sources/DoryGuestMemoryShim/include/DoryGuestMemoryShim.h new file mode 100644 index 00000000..d2febe39 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryGuestMemoryShim/include/DoryGuestMemoryShim.h @@ -0,0 +1,44 @@ +#ifndef DORY_GUEST_MEMORY_SHIM_H +#define DORY_GUEST_MEMORY_SHIM_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + uint8_t bytes[16]; +} DoryGuestMemoryBackingIdentity; + +/// Creates, immediately unlinks, sizes, and identifies one POSIX shared-memory object. +/// Returns an O_RDWR, close-on-exec descriptor, or -1 with errno set. +int DoryCreateGuestMemoryBacking( + uint64_t guest_size, + DoryGuestMemoryBackingIdentity *identity, + uint64_t *declared_file_size +); + +/// Returns the page-aligned byte offset at which guest RAM begins in the backing object. +uint64_t DoryGuestMemoryBackingDataOffset(void); + +/// Validates an unlinked POSIX shared-memory descriptor and returns its embedded identity. +/// This accepts no regular filesystem file, even when its size and header bytes match. +int DoryReadGuestMemoryBackingIdentity( + int descriptor, + uint64_t declared_file_size, + DoryGuestMemoryBackingIdentity *identity +); + +/// Exact owner-side validation, including identity and close-on-exec state. +int DoryGuestMemoryBackingMatches( + int descriptor, + uint64_t declared_file_size, + const DoryGuestMemoryBackingIdentity *identity +); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/AgentChannel.swift b/Packages/ContainerizationEngine/Sources/DoryHV/AgentChannel.swift index d4defb3d..47d0fb32 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/AgentChannel.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/AgentChannel.swift @@ -12,15 +12,37 @@ protocol AgentControlRPC: AnyObject, Sendable { func info() throws -> DoryAgentInfo func clockSync(hostEpochNs: Int64) throws -> Bool func portsWatch() throws -> DoryPortsSnapshot + func virtioFSMount(tag: String, mountPath: String, readOnly: Bool) throws + -> DoryVirtioFSMountReceipt + func usbVhciAttach(busID: String, port: UInt32, vsockPort: UInt32, deviceID: UInt32, speed: UInt32) throws + func usbVhciDetach(busID: String, port: UInt32) throws func close() } extension DoryAgentControlHandle: AgentControlRPC {} +extension AgentControlRPC { + func virtioFSMount(tag: String, mountPath: String, readOnly: Bool) throws + -> DoryVirtioFSMountReceipt { + throw AgentProtocolError.capabilityUnavailable("virtiofs-mount", 1) + } + + func usbVhciAttach(busID: String, port: UInt32, vsockPort: UInt32, deviceID: UInt32, speed: UInt32) throws { + throw AgentProtocolError.capabilityUnavailable("usb-vhci", 1) + } + + func usbVhciDetach(busID: String, port: UInt32) throws { + throw AgentProtocolError.capabilityUnavailable("usb-vhci", 1) + } +} + public enum AgentProtocolError: Error, Equatable, Sendable { case connectionAlreadyConsumed case invalidGuestPort(UInt32) + case invalidVhciPort(Int) case socketPair(Int32) + case capabilityUnavailable(String, UInt32) + case invalidCapabilityInventory } /// A typed control channel over one connected guest vsock stream. @@ -32,18 +54,12 @@ public enum AgentProtocolError: Error, Equatable, Sendable { public final class AgentChannel: @unchecked Sendable { typealias ClientConnector = @Sendable (Int32) throws -> any AgentControlRPC - private final class ConnectionBox: @unchecked Sendable { - let connection: VsockConnection - - init(_ connection: VsockConnection) { - self.connection = connection - } - } - private let lock = NSLock() + private let relayCompletion = DispatchGroup() private let connector: ClientConnector private var connection: VsockConnection? private var relayConnection: VsockConnection? + private var relaySession: VsockUnixRelay.RelaySession? private var client: (any AgentControlRPC)? public init(connection: VsockConnection) { @@ -65,11 +81,17 @@ public final class AgentChannel: @unchecked Sendable { public func info() async throws -> AgentInfo { let raw = try await perform { try $0.info() } + guard raw.capabilitiesAreCanonical else { + throw AgentProtocolError.invalidCapabilityInventory + } return AgentInfo( protocolVersion: raw.protocolVersion, kernel: raw.kernel, agentBuild: raw.agentBuild, - uptimeSeconds: raw.uptimeSeconds + uptimeSeconds: raw.uptimeSeconds, + capabilities: raw.capabilities.map { + AgentCapability(id: $0.id, version: $0.version) + } ) } @@ -93,6 +115,57 @@ public final class AgentChannel: @unchecked Sendable { ) } + public func requireCapability(_ id: String, version: UInt32) async throws { + let info = try await info() + guard info.capabilities.contains(where: { $0.id == id && $0.version >= version }) else { + throw AgentProtocolError.capabilityUnavailable(id, version) + } + } + + public func usbVhciAttach(_ request: UsbAgentAttachRequest) async throws { + guard let port = UInt32(exactly: request.port) else { + throw AgentProtocolError.invalidVhciPort(request.port) + } + try await perform { + try $0.usbVhciAttach( + busID: request.busid, + port: port, + vsockPort: request.vsock_port, + deviceID: request.device_id, + speed: request.speed + ) + } + } + + /// Mount a published virtio-fs share through the negotiated guest-tools primitive. Capability + /// validation is part of this operation so a caller cannot accidentally invoke a mutating RPC + /// against an arbitrary generic Linux guest merely because an agent transport answered. + public func mountVirtioFS(_ request: VirtioFSMountRequest) async throws + -> VirtioFSMountReceipt { + try await requireCapability("virtiofs-mount", version: 1) + let proof = try await perform { + try $0.virtioFSMount( + tag: request.tag, + mountPath: request.mountPath, + readOnly: request.readOnly + ) + } + return VirtioFSMountReceipt( + tag: proof.tag, + mountPath: proof.mountPath, + readOnly: proof.readOnly, + alreadyMounted: proof.alreadyMounted, + mountID: proof.mountID + ) + } + + public func usbVhciDetach(_ request: UsbAgentDetachRequest) async throws { + guard let port = UInt32(exactly: request.port) else { + throw AgentProtocolError.invalidVhciPort(request.port) + } + try await perform { try $0.usbVhciDetach(busID: request.busid, port: port) } + } + private func perform( _ operation: @escaping @Sendable (any AgentControlRPC) throws -> Result ) async throws -> Result { @@ -127,9 +200,20 @@ public final class AgentChannel: @unchecked Sendable { } let rustDescriptor = descriptors[0] let relayDescriptor = descriptors[1] - let connectionBox = ConnectionBox(connection) + relayCompletion.enter() + let completion = relayCompletion + let session = VsockUnixRelay.RelaySession( + client: relayDescriptor, + connection: connection, + completion: { completion.leave() } + ) + relaySession = session + if let ownedConnection = connection as? ServiceOwnedVsockConnection, + !ownedConnection.replaceServiceStopAction({ session.requestStop() }) { + session.requestStop() + } Thread.detachNewThread { - VsockUnixRelay.serve(client: relayDescriptor, connection: connectionBox.connection) + session.run() } do { @@ -143,7 +227,7 @@ public final class AgentChannel: @unchecked Sendable { // Rust owns/closes its socketpair fd on every return path, but a read EOF is only a // half-close to the generic stream relay. Explicitly reset the vsock side so its other // pump cannot wait forever on a silent guest after handshake timeout/failure. - connection.close() + session.requestStop() throw error } } @@ -152,17 +236,23 @@ public final class AgentChannel: @unchecked Sendable { lock.lock() let unclaimedConnection = connection let activeRelayConnection = relayConnection + let activeRelaySession = relaySession let activeClient = client connection = nil relayConnection = nil + relaySession = nil client = nil lock.unlock() // Closing the Rust endpoint first lets a healthy guest observe EOF; resetting the in-process // vsock immediately afterwards deterministically wakes both detached relay pumps even if the // guest never acknowledges shutdown. activeClient?.close() + activeRelaySession?.requestStop() activeRelayConnection?.close() unclaimedConnection?.close() + if activeRelaySession != nil { + _ = relayCompletion.wait(timeout: .now() + 1) + } } } @@ -178,12 +268,20 @@ public struct AgentInfo: Codable, Equatable, Sendable { public var kernel: String public var agentBuild: String public var uptimeSeconds: UInt64 + public var capabilities: [AgentCapability] - public init(protocolVersion: UInt32, kernel: String, agentBuild: String, uptimeSeconds: UInt64) { + public init( + protocolVersion: UInt32, + kernel: String, + agentBuild: String, + uptimeSeconds: UInt64, + capabilities: [AgentCapability] = [] + ) { self.protocolVersion = protocolVersion self.kernel = kernel self.agentBuild = agentBuild self.uptimeSeconds = uptimeSeconds + self.capabilities = capabilities } enum CodingKeys: String, CodingKey { @@ -191,6 +289,17 @@ public struct AgentInfo: Codable, Equatable, Sendable { case kernel case agentBuild = "agent_build" case uptimeSeconds = "uptime_seconds" + case capabilities + } +} + +public struct AgentCapability: Codable, Equatable, Sendable { + public var id: String + public var version: UInt32 + + public init(id: String, version: UInt32) { + self.id = id + self.version = version } } @@ -202,6 +311,40 @@ public struct ClockSyncResult: Equatable, Sendable { } } +public struct VirtioFSMountRequest: Equatable, Sendable { + public var tag: String + public var mountPath: String + public var readOnly: Bool + + public init(tag: String, mountPath: String, readOnly: Bool) { + self.tag = tag + self.mountPath = mountPath + self.readOnly = readOnly + } +} + +public struct VirtioFSMountReceipt: Equatable, Sendable { + public var tag: String + public var mountPath: String + public var readOnly: Bool + public var alreadyMounted: Bool + public var mountID: UInt64 + + public init( + tag: String, + mountPath: String, + readOnly: Bool, + alreadyMounted: Bool, + mountID: UInt64 + ) { + self.tag = tag + self.mountPath = mountPath + self.readOnly = readOnly + self.alreadyMounted = alreadyMounted + self.mountID = mountID + } +} + public struct AgentListenPort: Equatable, Sendable { public var `protocol`: String public var port: UInt16 diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/AgentVsockForward.swift b/Packages/ContainerizationEngine/Sources/DoryHV/AgentVsockForward.swift index 8dcae77c..18d829c6 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/AgentVsockForward.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/AgentVsockForward.swift @@ -32,18 +32,55 @@ public struct ForwardPreamble: Equatable, Sendable { /// preamble size — the only dialer is our own dataplane, so anything else is a protocol error, /// not something to tolerate. public static func read(from fd: Int32) -> ForwardPreamble? { - guard let lengthBytes = readExactly(4, from: fd) else { return nil } + read(from: fd, deadline: nil) + } + + /// Applies one monotonic deadline to the complete length+body frame. A peer sending one byte + /// before each socket receive timeout therefore cannot retain an admission slot indefinitely. + static func read(from fd: Int32, timeout: TimeInterval) -> ForwardPreamble? { + read( + from: fd, + deadline: ProcessInfo.processInfo.systemUptime + max(0, timeout) + ) + } + + private static func read(from fd: Int32, deadline: TimeInterval?) -> ForwardPreamble? { + guard let lengthBytes = readExactly(4, from: fd, deadline: deadline) else { return nil } let length = UInt32(lengthBytes[0]) | (UInt32(lengthBytes[1]) << 8) | (UInt32(lengthBytes[2]) << 16) | (UInt32(lengthBytes[3]) << 24) guard length == UInt32(bodyByteCount) else { return nil } - guard let body = readExactly(bodyByteCount, from: fd) else { return nil } + guard let body = readExactly(bodyByteCount, from: fd, deadline: deadline) else { return nil } return decode(body) } - private static func readExactly(_ count: Int, from fd: Int32) -> [UInt8]? { + private static func readExactly( + _ count: Int, + from fd: Int32, + deadline: TimeInterval? + ) -> [UInt8]? { var bytes = [UInt8](repeating: 0, count: count) var offset = 0 while offset < count { + if let deadline { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { return nil } + let requestedMilliseconds = min( + ceil(remaining * 1_000), + Double(Int32.max) + ) + var readiness = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + let ready = poll( + &readiness, + 1, + max(1, Int32(requestedMilliseconds)) + ) + if ready == 0 { return nil } + if ready < 0 { + if errno == EINTR { continue } + return nil + } + if readiness.revents & Int16(POLLNVAL | POLLERR) != 0 { return nil } + } let got = bytes.withUnsafeMutableBytes { raw in Darwin.read(fd, raw.baseAddress!.advanced(by: offset), count - offset) } @@ -67,14 +104,25 @@ public final class AgentVsockForward: @unchecked Sendable { private let socketPath: String private let guestCID: UInt32 private let log: @Sendable (String) -> Void + private let listener: BoundedVsockSocketListener /// A dialer that connects but never completes the preamble would otherwise pin a thread forever. - private static let preambleTimeout = timeval(tv_sec: 10, tv_usec: 0) + private static let preambleTimeout: TimeInterval = 10 - public init(socketPath: String, guestCID: UInt32, log: @escaping @Sendable (String) -> Void = { _ in }) { + public init( + socketPath: String, + guestCID: UInt32, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { self.socketPath = socketPath self.guestCID = guestCID self.log = log + self.listener = BoundedVsockSocketListener( + socketPath: socketPath, + mode: 0o600, + endpointLabel: "agent vsock forward", + log: log + ) } /// The maximum UTF-8 byte length accepted by macOS for a filesystem Unix-domain socket path. @@ -85,61 +133,57 @@ public final class AgentVsockForward: @unchecked Sendable { try VsockUnixRelay.validateSocketPath(socketPath) } - private final class VsockBox: @unchecked Sendable { - let vsock: VirtioVsock - init(_ vsock: VirtioVsock) { self.vsock = vsock } - } - public func attach(to vsock: VirtioVsock) throws { - let listener = try VsockUnixRelay.makeListener(socketPath: socketPath, mode: 0o600) - let box = VsockBox(vsock) - let path = socketPath - let log = log - Thread.detachNewThread { [self] in - while true { - let client = accept(listener, nil, nil) - guard client >= 0 else { - if errno == EINTR { continue } - log("agent vsock forward accept failed on \(path): errno \(errno)") - break - } - var noSigpipe: Int32 = 1 - _ = setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noSigpipe, socklen_t(MemoryLayout.size)) - Thread.detachNewThread { - self.serve(client: client, box: box) - } + let expectedCID = guestCID + let logger = log + try listener.attach(to: vsock, service: .agentForward) { client in + guard let preamble = Self.readPreamble(client: client, log: logger) else { + return nil + } + guard preamble.direction == .hostToGuest else { + logger("agent vsock forward rejected a non-host-to-guest preamble") + return nil + } + guard preamble.cid == expectedCID else { + logger( + "agent vsock forward rejected cid \(preamble.cid) " + + "(guest is \(expectedCID))" + ) + return nil + } + do { + return try vsock.connectIfCapacity(port: preamble.port) + } catch { + logger( + "agent vsock forward rejected guest port \(preamble.port): \(error)" + ) + return nil } - close(listener) } log("agent vsock forward serving \(socketPath)") } - private func serve(client: Int32, box: VsockBox) { - guard let preamble = readPreamble(client: client) else { - close(client) - return - } - guard preamble.direction == .hostToGuest else { - log("agent vsock forward rejected a non-host-to-guest preamble") - close(client) - return - } - guard preamble.cid == guestCID else { - log("agent vsock forward rejected cid \(preamble.cid) (guest is \(guestCID))") - close(client) - return - } - VsockUnixRelay.serve(client: client, connection: box.vsock.connect(port: preamble.port)) + public func stop(timeout: TimeInterval = 1) { + listener.stop(timeout: timeout) } - private func readPreamble(client: Int32) -> ForwardPreamble? { - var timeout = Self.preambleTimeout - _ = setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) - defer { - var forever = timeval(tv_sec: 0, tv_usec: 0) - _ = setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &forever, socklen_t(MemoryLayout.size)) - } - guard let preamble = ForwardPreamble.read(from: client) else { + var activeSessionCount: Int { listener.activeSessionCount } + var serviceAdmissionSnapshot: VirtioVsockServiceAdmissionSnapshot? { + listener.serviceAdmissionSnapshot + } + + deinit { + stop() + } + + private static func readPreamble( + client: Int32, + log: @Sendable (String) -> Void + ) -> ForwardPreamble? { + guard let preamble = ForwardPreamble.read( + from: client, + timeout: preambleTimeout + ) else { log("agent vsock forward dropped a connection with a malformed preamble") return nil } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/BoundedGuestVsockService.swift b/Packages/ContainerizationEngine/Sources/DoryHV/BoundedGuestVsockService.swift new file mode 100644 index 00000000..09111c3a --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/BoundedGuestVsockService.swift @@ -0,0 +1,433 @@ +import Darwin +import Foundation + +protocol BoundedGuestVsockSession: AnyObject, Sendable { + func run() + func requestStop() +} + +protocol BoundedHostSocketConnectContext: AnyObject, Sendable { + func claimPendingDescriptor(_ descriptor: Int32) -> Bool + func releasePendingDescriptor(_ descriptor: Int32) + var shouldCancelConnect: Bool { get } +} + +/// A bounded connector can also be exercised outside a bridge session (for validation and focused +/// tests). There is no external cancellation in that case, but the same monotonic deadline remains +/// mandatory and the connector remains the sole descriptor owner until it returns success. +final class UncancelledHostSocketConnectContext: BoundedHostSocketConnectContext, + @unchecked Sendable +{ + func claimPendingDescriptor(_ descriptor: Int32) -> Bool { true } + func releasePendingDescriptor(_ descriptor: Int32) {} + var shouldCancelConnect: Bool { false } +} + +/// One bounded lifecycle for services initiated by an untrusted guest vsock request. +/// +/// Registrations are published all-or-nothing after a one-shot attach reservation. Listener +/// callbacks hold this object weakly; the lifecycle owns only idempotent unregister closures and +/// admitted sessions, so neither VirtioVsock nor a registration token can retain the bridge. +final class BoundedGuestVsockServiceLifecycle: @unchecked Sendable { + private static let maximumStopWait: TimeInterval = 5 + + private let lock = NSLock() + private let attachmentCompletion = DispatchGroup() + private let sessionCompletion = DispatchGroup() + private let endpointLabel: String + private let log: @Sendable (String) -> Void + private var attachmentReserved = false + private var attachmentCompleted = false + private var terminal = false + private var unregisterActions = [@Sendable () -> Void]() + private var sessionReservations = Set() + private var sessions = [UUID: any BoundedGuestVsockSession]() + private var admissionSnapshotProvider: + (@Sendable () -> VirtioVsockServiceAdmissionSnapshot?)? + + init( + endpointLabel: String, + log: @escaping @Sendable (String) -> Void + ) { + self.endpointLabel = endpointLabel + self.log = log + } + + func beginAttachment(to vsock: VirtioVsock) throws { + lock.lock() + defer { lock.unlock() } + guard !terminal, + !attachmentReserved, + !attachmentCompleted, + unregisterActions.isEmpty else { + throw VMError.invalidConfiguration( + "\(endpointLabel) is already attached, attaching, or stopped" + ) + } + admissionSnapshotProvider = { [weak vsock] in + vsock?.serviceAdmissionSnapshot + } + attachmentReserved = true + attachmentCompletion.enter() + } + + /// Commits already-created listener registrations. Returns false only when a concurrent stop + /// won; in that case every registration is closed before attachment completion is published. + func commitAttachment( + unregister: [@Sendable () -> Void] + ) -> Bool { + lock.lock() + guard attachmentReserved, unregisterActions.isEmpty else { + let shouldLeave = attachmentReserved + attachmentReserved = false + lock.unlock() + for action in unregister { action() } + if shouldLeave { attachmentCompletion.leave() } + return false + } + attachmentReserved = false + if terminal { + lock.unlock() + for action in unregister { action() } + attachmentCompletion.leave() + return false + } + attachmentCompleted = true + unregisterActions = unregister + lock.unlock() + attachmentCompletion.leave() + return true + } + + func cancelAttachment(unregister: [@Sendable () -> Void]) { + for action in unregister { action() } + let shouldLeave: Bool + lock.lock() + shouldLeave = attachmentReserved + attachmentReserved = false + lock.unlock() + if shouldLeave { attachmentCompletion.leave() } + } + + func admit( + _ connection: VsockConnection, + makeSession: ( + _ connection: VsockConnection, + _ completion: @escaping @Sendable () -> Void + ) -> any BoundedGuestVsockSession + ) { + guard let token = reserveSession() else { + connection.close() + return + } + let session = makeSession(connection) { [self] in + finishSession(token: token) + } + let admitted = publishSession(session, token: token) + if admitted, + let ownedConnection = connection as? ServiceOwnedVsockConnection, + !ownedConnection.replaceServiceStopAction({ session.requestStop() }) { + // Reset/quiesce won after transport admission but before the service owner published. + session.requestStop() + } else if !admitted { + session.requestStop() + } + Thread.detachNewThread { session.run() } + } + + func stop(timeout: TimeInterval = 1) { + let boundedTimeout = timeout.isFinite + ? min(max(0, timeout), Self.maximumStopWait) + : 1 + let registrations: [@Sendable () -> Void] + let activeSessions: [any BoundedGuestVsockSession] + lock.lock() + terminal = true + registrations = unregisterActions + unregisterActions.removeAll() + activeSessions = Array(sessions.values) + lock.unlock() + + // Unregister first so no new callback can start after the session snapshot. A callback + // already in flight sees terminal admission and closes its connection exactly once. + for unregister in registrations { unregister() } + for session in activeSessions { session.requestStop() } + + let deadline = DispatchTime.now() + boundedTimeout + let attachmentFinished = attachmentCompletion.wait(timeout: deadline) == .success + let sessionsFinished = sessionCompletion.wait(timeout: deadline) == .success + if !attachmentFinished || !sessionsFinished { + log( + "\(endpointLabel) teardown did not drain within \(boundedTimeout) seconds" + ) + } + } + + var activeSessionCount: Int { + lock.lock() + defer { lock.unlock() } + return sessionReservations.count + sessions.count + } + + var serviceAdmissionSnapshot: VirtioVsockServiceAdmissionSnapshot? { + lock.lock() + let provider = admissionSnapshotProvider + lock.unlock() + guard let provider else { return nil } + return provider() + } + + var isStopping: Bool { + lock.lock() + defer { lock.unlock() } + return terminal + } + + deinit { + stop() + } + + private func reserveSession() -> UUID? { + lock.lock() + defer { lock.unlock() } + guard !terminal else { return nil } + let token = UUID() + sessionReservations.insert(token) + sessionCompletion.enter() + return token + } + + private func publishSession( + _ session: any BoundedGuestVsockSession, + token: UUID + ) -> Bool { + lock.lock() + defer { lock.unlock() } + guard sessionReservations.remove(token) != nil else { return false } + sessions[token] = session + return !terminal + } + + private func finishSession(token: UUID) { + let owned: Bool + lock.lock() + owned = sessions.removeValue(forKey: token) != nil + lock.unlock() + if owned { sessionCompletion.leave() } + } +} + +/// Connects one admitted guest stream to a host socket, then delegates the established stream to +/// RelaySession. The connector borrows this object to publish its pending descriptor before any +/// nonblocking connect/poll; stop may shutdown it, but the connector remains its sole close owner +/// until ownership transfers to RelaySession. +final class GuestVsockHostSocketRelaySession: BoundedGuestVsockSession, + BoundedHostSocketConnectContext, + @unchecked Sendable +{ + typealias Connector = @Sendable (GuestVsockHostSocketRelaySession) -> Int32? + + private let lock = NSLock() + private let guestConnection: VsockConnection + private let connector: Connector + private var completion: (@Sendable () -> Void)? + private var pendingDescriptor: Int32? + private var relay: VsockUnixRelay.RelaySession? + private var started = false + private var stopRequested = false + private var finished = false + + init( + connection: VsockConnection, + connector: @escaping Connector, + completion: @escaping @Sendable () -> Void + ) { + self.guestConnection = connection + self.connector = connector + self.completion = completion + } + + func run() { + lock.lock() + guard !started else { + lock.unlock() + return + } + started = true + let shouldConnect = !stopRequested + lock.unlock() + guard shouldConnect, let descriptor = connector(self) else { + finish() + return + } + + let relay = VsockUnixRelay.RelaySession( + client: descriptor, + connection: guestConnection + ) + lock.lock() + guard pendingDescriptor == descriptor else { + lock.unlock() + relay.discardBeforeStart() + finish() + return + } + pendingDescriptor = nil + self.relay = relay + let shouldRelay = !stopRequested + lock.unlock() + + if shouldRelay { + relay.run() + } else { + relay.discardBeforeStart() + } + finish() + } + + func requestStop() { + lock.lock() + guard !stopRequested else { + lock.unlock() + return + } + stopRequested = true + if let pendingDescriptor { + // Connector remains close owner. The short poll quantum below provides the bounded wake + // even on Darwin sockets for which shutdown-before-connect returns ENOTCONN. + _ = shutdown(pendingDescriptor, SHUT_RDWR) + } + relay?.requestStop() + guestConnection.close() + lock.unlock() + } + + func claimPendingDescriptor(_ descriptor: Int32) -> Bool { + lock.lock() + defer { lock.unlock() } + guard !stopRequested, pendingDescriptor == nil, relay == nil else { return false } + pendingDescriptor = descriptor + return true + } + + func releasePendingDescriptor(_ descriptor: Int32) { + lock.lock() + if pendingDescriptor == descriptor { pendingDescriptor = nil } + lock.unlock() + } + + var shouldCancelConnect: Bool { + lock.lock() + defer { lock.unlock() } + return stopRequested + } + + private func finish() { + let orphanedDescriptor: Int32? + let callback: (@Sendable () -> Void)? + lock.lock() + guard !finished else { + lock.unlock() + return + } + finished = true + orphanedDescriptor = pendingDescriptor + pendingDescriptor = nil + relay = nil + callback = completion + completion = nil + guestConnection.close() + if let orphanedDescriptor { close(orphanedDescriptor) } + lock.unlock() + callback?() + } +} + +enum BoundedHostSocketConnector { + private static let connectPollQuantumMilliseconds: Int32 = 50 + private static let maximumConnectTimeout: TimeInterval = 30 + + static func connect( + domain: Int32, + timeout: TimeInterval, + context: any BoundedHostSocketConnectContext, + initiate: (Int32) -> Int32, + verify: (Int32) -> Bool = { _ in true } + ) -> Int32? { + let descriptor = socket(domain, SOCK_STREAM, 0) + guard descriptor >= 0 else { return nil } + let originalFlags = fcntl(descriptor, F_GETFL) + var noSigpipe: Int32 = 1 + guard originalFlags >= 0, + fcntl(descriptor, F_SETFD, FD_CLOEXEC) == 0, + setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size) + ) == 0, + fcntl(descriptor, F_SETFL, originalFlags | O_NONBLOCK) == 0, + context.claimPendingDescriptor(descriptor) else { + close(descriptor) + return nil + } + + var transferred = false + defer { + if !transferred { + context.releasePendingDescriptor(descriptor) + close(descriptor) + } + } + + let connected = initiate(descriptor) + if connected != 0 { + guard errno == EINPROGRESS else { return nil } + let boundedTimeout = timeout.isFinite + ? min(max(0, timeout), maximumConnectTimeout) + : 0 + let deadline = ProcessInfo.processInfo.systemUptime + boundedTimeout + while true { + guard !context.shouldCancelConnect else { return nil } + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { return nil } + let milliseconds = min( + connectPollQuantumMilliseconds, + max(1, Int32(ceil(remaining * 1_000))) + ) + var readiness = pollfd( + fd: descriptor, + events: Int16(POLLOUT), + revents: 0 + ) + let result = poll(&readiness, 1, milliseconds) + if result == 0 { continue } + if result < 0 { + if errno == EINTR { continue } + return nil + } + if readiness.revents & Int16(POLLNVAL) != 0 { return nil } + var socketError: Int32 = 0 + var socketErrorLength = socklen_t(MemoryLayout.size) + guard getsockopt( + descriptor, + SOL_SOCKET, + SO_ERROR, + &socketError, + &socketErrorLength + ) == 0, socketError == 0 else { + return nil + } + break + } + } + guard !context.shouldCancelConnect, + verify(descriptor), + fcntl(descriptor, F_SETFL, originalFlags) == 0 else { + return nil + } + transferred = true + return descriptor + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/BoundedVsockSocketListener.swift b/Packages/ContainerizationEngine/Sources/DoryHV/BoundedVsockSocketListener.swift new file mode 100644 index 00000000..c5d8ff1e --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/BoundedVsockSocketListener.swift @@ -0,0 +1,386 @@ +import Darwin +import Foundation + +/// One owned Unix listener with bounded, cancel-safe unix-to-vsock sessions. +/// +/// Attachment is one-shot and reserved before any pathname mutation. Every accepted descriptor +/// consumes admission capacity before its optional protocol preparation starts; preparation and +/// established relay use the same `RelaySession`, so stop can wake either phase without transferring +/// descriptor-close authority between threads. +final class BoundedVsockSocketListener: @unchecked Sendable { + private static let maximumStopWait: TimeInterval = 5 + + private let socketPath: String + private let mode: mode_t? + private let endpointLabel: String + private let log: @Sendable (String) -> Void + private let lifetime: Lifetime + private let admissionLock = NSLock() + private var admissionSnapshotProvider: + (@Sendable () -> VirtioVsockServiceAdmissionSnapshot?)? + + init( + socketPath: String, + mode: mode_t?, + endpointLabel: String, + log: @escaping @Sendable (String) -> Void + ) { + self.socketPath = socketPath + self.mode = mode + self.endpointLabel = endpointLabel + self.log = log + self.lifetime = Lifetime() + } + + func attach( + to vsock: VirtioVsock, + service: VirtioVsockService, + prepareConnection: @escaping @Sendable (Int32) -> VsockConnection? + ) throws { + // Reserve before makeOwnedListener's stale-path unlink. Repeat/concurrent/post-stop attach + // therefore fails without mutating a currently published endpoint. + guard lifetime.reserveAttachment() else { + throw VMError.invalidConfiguration( + "\(endpointLabel) listener is already attached, attaching, or stopped" + ) + } + admissionLock.lock() + admissionSnapshotProvider = { [weak vsock] in + vsock?.serviceAdmissionSnapshot + } + admissionLock.unlock() + var reservationActive = true + do { + let listener = try VsockUnixRelay.makeOwnedListener( + socketPath: socketPath, + mode: mode + ) + guard VsockUnixRelay.makeNonBlocking(listener.descriptor) else { + let code = errno + VsockUnixRelay.retireOwnedListener(listener, socketPath: socketPath) + throw UnixSocketListenerError.systemCall( + operation: "make nonblocking", + path: socketPath, + code: code + ) + } + let publication = lifetime.publishListener(listener) + reservationActive = false + switch publication { + case .serving: + break + case .stopped: + lifetime.finishListener(listener, socketPath: socketPath) + throw VMError.invalidConfiguration( + "\(endpointLabel) listener was stopped while attaching" + ) + case .rejected: + VsockUnixRelay.retireOwnedListener(listener, socketPath: socketPath) + throw VMError.invalidConfiguration( + "\(endpointLabel) listener publication was rejected" + ) + } + + let path = socketPath + let label = endpointLabel + let logger = log + let state = lifetime + Thread.detachNewThread { + defer { state.finishListener(listener, socketPath: path) } + while true { + guard !state.isStopping else { break } + var readiness = pollfd( + fd: listener.descriptor, + events: Int16(POLLIN), + revents: 0 + ) + let pollResult = poll(&readiness, 1, 100) + if pollResult == 0 { continue } + if pollResult < 0 { + if errno == EINTR { continue } + if !state.isStopping { + logger("\(label) listener poll failed on \(path): errno \(errno)") + } + break + } + guard !state.isStopping else { break } + if readiness.revents & Int16(POLLNVAL | POLLERR | POLLHUP) != 0 { + if !state.isStopping { + logger("\(label) listener became unavailable on \(path)") + } + break + } + guard readiness.revents & Int16(POLLIN) != 0 else { continue } + + let client = accept(listener.descriptor, nil, nil) + guard client >= 0 else { + if errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK { + continue + } + if !state.isStopping { + logger("\(label) accept failed on \(path): errno \(errno)") + } + break + } + guard !state.isStopping else { + close(client) + break + } + guard Self.configureAcceptedClient(client) else { + close(client) + continue + } + guard let token = state.reserveSession() else { + close(client) + continue + } + let serviceReservation: VirtioVsockServiceReservation + do { + serviceReservation = try vsock.reserveServiceSession(service) + } catch { + state.cancelSessionReservation(token: token) + close(client) + logger("\(label) rejected \(service.rawValue) session: \(error)") + continue + } + let session = VsockUnixRelay.RelaySession( + client: client, + prepareConnection: prepareConnection, + completion: { [state] in state.finishSession(token: token) } + ) + guard state.publishSession(session, token: token) else { + vsock.cancelServiceSession(serviceReservation) + session.discardBeforeStart() + continue + } + guard let serviceLease = vsock.publishServiceSession( + serviceReservation, + requestStop: { session.requestStop() } + ) else { + session.discardBeforeStart() + continue + } + guard state.bindServiceLease(serviceLease, token: token) else { + serviceLease.close() + session.discardBeforeStart() + continue + } + Thread.detachNewThread { session.run() } + } + } + } catch { + if reservationActive { lifetime.cancelAttachmentReservation() } + throw error + } + } + + func stop(timeout: TimeInterval = 1) { + let boundedTimeout = timeout.isFinite + ? min(max(0, timeout), Self.maximumStopWait) + : 1 + guard lifetime.stop(timeout: boundedTimeout) else { + log( + "\(endpointLabel) teardown did not drain within " + + "\(boundedTimeout) seconds on \(socketPath)" + ) + return + } + } + + var activeSessionCount: Int { lifetime.activeSessionCount } + var serviceAdmissionSnapshot: VirtioVsockServiceAdmissionSnapshot? { + admissionLock.lock() + let provider = admissionSnapshotProvider + admissionLock.unlock() + guard let provider else { return nil } + return provider() + } + + deinit { + stop() + } + + private static func configureAcceptedClient(_ descriptor: Int32) -> Bool { + let statusFlags = fcntl(descriptor, F_GETFL) + var peerUID: uid_t = 0 + var peerGID: gid_t = 0 + guard statusFlags >= 0, + fcntl(descriptor, F_SETFL, statusFlags & ~O_NONBLOCK) == 0, + fcntl(descriptor, F_SETFD, FD_CLOEXEC) == 0, + getpeereid(descriptor, &peerUID, &peerGID) == 0, + peerUID == geteuid() else { + return false + } + var noSigpipe: Int32 = 1 + return setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size) + ) == 0 + } + + private final class Lifetime: @unchecked Sendable { + enum ListenerPublication { + case serving + case stopped + case rejected + } + + private let lock = NSLock() + private let listenerCompletion = DispatchGroup() + private let sessionCompletion = DispatchGroup() + private var attachmentReserved = false + private var listener: VsockUnixRelay.OwnedListener? + private var terminal = false + private var sessionReservations = Set() + private struct SessionRecord { + let relay: VsockUnixRelay.RelaySession + var serviceLease: VirtioVsockServiceLease? + } + private var sessions = [UUID: SessionRecord]() + + var isStopping: Bool { + lock.lock() + defer { lock.unlock() } + return terminal + } + + var activeSessionCount: Int { + lock.lock() + defer { lock.unlock() } + return sessionReservations.count + sessions.count + } + + func reserveAttachment() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !terminal, !attachmentReserved, listener == nil else { return false } + attachmentReserved = true + listenerCompletion.enter() + return true + } + + func cancelAttachmentReservation() { + let shouldLeave: Bool + lock.lock() + shouldLeave = attachmentReserved + attachmentReserved = false + lock.unlock() + if shouldLeave { listenerCompletion.leave() } + } + + func publishListener( + _ listener: VsockUnixRelay.OwnedListener + ) -> ListenerPublication { + lock.lock() + guard attachmentReserved, self.listener == nil else { + let shouldLeave = attachmentReserved + attachmentReserved = false + lock.unlock() + if shouldLeave { listenerCompletion.leave() } + return .rejected + } + attachmentReserved = false + self.listener = listener + let publication: ListenerPublication = terminal ? .stopped : .serving + lock.unlock() + return publication + } + + func finishListener( + _ listener: VsockUnixRelay.OwnedListener, + socketPath: String + ) { + let owned: Bool + let activeSessions: [VsockUnixRelay.RelaySession] + lock.lock() + owned = self.listener?.descriptor == listener.descriptor + && self.listener?.pathIdentity == listener.pathIdentity + if owned { self.listener = nil } + terminal = true + activeSessions = sessions.values.map(\.relay) + lock.unlock() + guard owned else { return } + + // Retire pathname ownership before publishing listener completion. A successor can bind + // immediately after stop returns without a stale cleanup deleting its socket. + VsockUnixRelay.retireOwnedListener(listener, socketPath: socketPath) + for session in activeSessions { session.requestStop() } + listenerCompletion.leave() + } + + func reserveSession() -> UUID? { + lock.lock() + defer { lock.unlock() } + guard !terminal else { return nil } + let token = UUID() + sessionReservations.insert(token) + sessionCompletion.enter() + return token + } + + func cancelSessionReservation(token: UUID) { + let owned: Bool + lock.lock() + owned = sessionReservations.remove(token) != nil + lock.unlock() + if owned { sessionCompletion.leave() } + } + + func publishSession( + _ session: VsockUnixRelay.RelaySession, + token: UUID + ) -> Bool { + lock.lock() + defer { lock.unlock() } + guard sessionReservations.remove(token) != nil else { return false } + sessions[token] = SessionRecord(relay: session, serviceLease: nil) + return !terminal + } + + func bindServiceLease( + _ lease: VirtioVsockServiceLease, + token: UUID + ) -> Bool { + lock.lock() + defer { lock.unlock() } + guard var record = sessions[token], record.serviceLease == nil else { + return false + } + record.serviceLease = lease + sessions[token] = record + return true + } + + func finishSession(token: UUID) { + let record: SessionRecord? + lock.lock() + record = sessions.removeValue(forKey: token) + lock.unlock() + if let record { + record.serviceLease?.close() + sessionCompletion.leave() + } + } + + func stop(timeout: TimeInterval) -> Bool { + let activeSessions: [VsockUnixRelay.RelaySession] + lock.lock() + terminal = true + if let listener { + // Wake poll without closing; the listener thread remains the sole descriptor closer. + _ = shutdown(listener.descriptor, SHUT_RDWR) + } + activeSessions = sessions.values.map(\.relay) + lock.unlock() + for session in activeSessions { session.requestStop() } + + let deadline = DispatchTime.now() + timeout + let listenerFinished = listenerCompletion.wait(timeout: deadline) == .success + let sessionsFinished = sessionCompletion.wait(timeout: deadline) == .success + return listenerFinished && sessionsFinished + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/DaxCoherenceProbe.swift b/Packages/ContainerizationEngine/Sources/DoryHV/DaxCoherenceProbe.swift deleted file mode 100644 index c276d1e5..00000000 --- a/Packages/ContainerizationEngine/Sources/DoryHV/DaxCoherenceProbe.swift +++ /dev/null @@ -1,315 +0,0 @@ -import Darwin -import Foundation -import Hypervisor - -#if arch(arm64) -/// Track 1.7 DAX go/no-go: proves that a file-backed host mmap mapped into guest physical memory with -/// hv_vm_map stays coherent in both directions. The host writes a pattern into a MAP_SHARED file, maps -/// it at the DAX guest-physical base, and runs a three-instruction guest that reads the pattern and -/// writes a marker back. Success means guest reads see host writes AND host (plus the on-disk file) -/// sees the guest write, the exact property FUSE_SETUPMAPPING relies on. Requires the -/// com.apple.security.hypervisor entitlement; run as a signed helper, not a plain unit test. -public enum DaxCoherenceProbe { - private static let hostPattern: UInt32 = 0xDEAD_BEEF - private static let guestMarker: UInt32 = 0xCAFE_BABE - - public static func run(daxGuestBase: UInt64 = GuestLayout.daxWindowBase) throws -> String { - let mapBytes = Int(DaxWindow.pageSize) - let path = NSTemporaryDirectory() + "dory-dax-probe-\(getpid())" - let fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0o600) - guard fd >= 0 else { throw VMError.invalidConfiguration("dax probe: open failed errno \(errno)") } - defer { close(fd); unlink(path) } - guard ftruncate(fd, off_t(mapBytes)) == 0 else { - throw VMError.invalidConfiguration("dax probe: ftruncate failed errno \(errno)") - } - guard let fileRegion = mmap(nil, mapBytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0), - fileRegion != MAP_FAILED else { - throw VMError.outOfMemory("dax probe: mmap failed errno \(errno)") - } - defer { munmap(fileRegion, mapBytes) } - fileRegion.storeBytes(of: hostPattern.littleEndian, toByteOffset: 0, as: UInt32.self) - - try hvCreateVM() - defer { hv_vm_destroy() } - - let ramBase = GuestLayout.ramBase - let memory = try GuestMemory(guestBase: ramBase, size: UInt64(DaxWindow.pageSize)) - try memory.mapIntoGuest() - try memory.write(UInt32(0xB940_0020), at: ramBase) // ldr w0, [x1] - try memory.write(UInt32(0xB900_0022), at: ramBase + 4) // str w2, [x1] - try memory.write(UInt32(0xD400_0002), at: ramBase + 8) // hvc #0 - - try hvCheck( - hv_vm_map(fileRegion, daxGuestBase, mapBytes, - hv_memory_flags_t(HV_MEMORY_READ | HV_MEMORY_WRITE)), - "hv_vm_map(dax window)" - ) - - let vcpu = try VCPU() - try vcpu.write(HV_REG_CPSR, 0x3C5) - try vcpu.write(HV_REG_PC, ramBase) - try vcpu.write(HV_REG_X1, daxGuestBase) - try vcpu.write(HV_REG_X2, UInt64(guestMarker)) - - let event = try vcpu.run() - guard case .exception(let syndrome, _, _) = event, - ExceptionClass(syndrome: syndrome) == .hvc64 else { - throw VMError.unexpectedExit("dax probe: expected HVC trap, got \(event)") - } - - let guestRead = UInt32(truncatingIfNeeded: try vcpu.read(HV_REG_X0)) - guard guestRead == hostPattern else { - throw VMError.unexpectedExit( - "dax probe FAILED host->guest: guest read 0x\(String(guestRead, radix: 16)), expected 0x\(String(hostPattern, radix: 16))") - } - - let hostSeesGuestWrite = UInt32(littleEndian: fileRegion.load(fromByteOffset: 0, as: UInt32.self)) - guard hostSeesGuestWrite == guestMarker else { - throw VMError.unexpectedExit( - "dax probe FAILED guest->host: host mmap read 0x\(String(hostSeesGuestWrite, radix: 16)), expected 0x\(String(guestMarker, radix: 16))") - } - - _ = msync(fileRegion, mapBytes, MS_SYNC) - var onDisk: UInt32 = 0 - _ = withUnsafeMutableBytes(of: &onDisk) { pread(fd, $0.baseAddress, 4, 0) } - guard UInt32(littleEndian: onDisk) == guestMarker else { - throw VMError.unexpectedExit( - "dax probe FAILED persistence: on-disk word 0x\(String(UInt32(littleEndian: onDisk), radix: 16)), expected 0x\(String(guestMarker, radix: 16))") - } - - return "dax coherence passed at base 0x\(String(daxGuestBase, radix: 16)): host->guest 0x\(String(hostPattern, radix: 16)) read by guest; guest->host 0x\(String(guestMarker, radix: 16)) visible in host mmap and on disk" - } -} -#else -public enum DaxCoherenceProbe { - private static let hostPattern: UInt32 = 0xDEAD_BEEF - private static let guestMarker: UInt32 = 0xCAFE_BABE - private static let codeAddress: UInt64 = 0x1000 - private static let pml4Address: UInt64 = 0x2000 - private static let pdptAddress: UInt64 = 0x3000 - private static let lowPDAddress: UInt64 = 0x4000 - private static let daxPDAddress: UInt64 = 0x5000 - private static let ramBytes: UInt64 = 2 << 20 - - public static func run(daxGuestBase: UInt64 = GuestLayout.daxWindowBase) throws -> String { - let mapBytes = Int(DaxWindow.pageSize) - guard daxGuestBase.isMultiple(of: UInt64(2 << 20)) else { - throw VMError.invalidConfiguration("x86 dax probe base must be 2 MiB aligned") - } - guard daxGuestBase >= ramBytes else { - throw VMError.invalidConfiguration("x86 dax probe base must not overlap probe RAM") - } - - let path = NSTemporaryDirectory() + "dory-dax-probe-\(getpid())" - let fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0o600) - guard fd >= 0 else { throw VMError.invalidConfiguration("dax probe: open failed errno \(errno)") } - defer { close(fd); unlink(path) } - guard ftruncate(fd, off_t(mapBytes)) == 0 else { - throw VMError.invalidConfiguration("dax probe: ftruncate failed errno \(errno)") - } - guard let fileRegion = mmap(nil, mapBytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0), - fileRegion != MAP_FAILED else { - throw VMError.outOfMemory("dax probe: mmap failed errno \(errno)") - } - defer { munmap(fileRegion, mapBytes) } - fileRegion.storeBytes(of: hostPattern.littleEndian, toByteOffset: 0, as: UInt32.self) - - try hvCreateVM() - defer { hv_vm_destroy() } - - let memory = try GuestMemory(guestBase: 0, size: ramBytes) - try memory.mapIntoGuest() - try writeProbeCode(to: memory, daxGuestBase: daxGuestBase) - try writePageTables(to: memory, daxGuestBase: daxGuestBase) - - try hvCheck( - hv_vm_map(fileRegion, daxGuestBase, mapBytes, hv_memory_flags_t(HV_MEMORY_READ | HV_MEMORY_WRITE)), - "hv_vm_map(dax window)" - ) - - let vcpu = try VCPU() - try configureLongMode(vcpu) - - guard case .vmExit(let state) = try vcpu.run() else { - throw VMError.unexpectedExit("x86 dax probe returned without a VM exit") - } - guard X86VMExitDecoder.decode(state) == .halt else { - throw VMError.unexpectedExit( - "x86 dax probe expected HLT exit, got reason \(state.reason) qualification 0x\(String(state.qualification, radix: 16))" - ) - } - - let guestRead = UInt32(truncatingIfNeeded: try vcpu.read(HV_X86_RAX)) - guard guestRead == hostPattern else { - throw VMError.unexpectedExit( - "dax probe FAILED host->guest: guest read 0x\(String(guestRead, radix: 16)), expected 0x\(String(hostPattern, radix: 16))" - ) - } - - let hostSeesGuestWrite = UInt32(littleEndian: fileRegion.load(fromByteOffset: 0, as: UInt32.self)) - guard hostSeesGuestWrite == guestMarker else { - throw VMError.unexpectedExit( - "dax probe FAILED guest->host: host mmap read 0x\(String(hostSeesGuestWrite, radix: 16)), expected 0x\(String(guestMarker, radix: 16))" - ) - } - - _ = msync(fileRegion, mapBytes, MS_SYNC) - var onDisk: UInt32 = 0 - _ = withUnsafeMutableBytes(of: &onDisk) { pread(fd, $0.baseAddress, 4, 0) } - guard UInt32(littleEndian: onDisk) == guestMarker else { - throw VMError.unexpectedExit( - "dax probe FAILED persistence: on-disk word 0x\(String(UInt32(littleEndian: onDisk), radix: 16)), expected 0x\(String(guestMarker, radix: 16))" - ) - } - - return "dax coherence passed at base 0x\(String(daxGuestBase, radix: 16)): host->guest 0x\(String(hostPattern, radix: 16)) read by guest; guest->host 0x\(String(guestMarker, radix: 16)) visible in host mmap and on disk" - } - - private static func writeProbeCode(to memory: GuestMemory, daxGuestBase: UInt64) throws { - var code: [UInt8] = [0x48, 0xBB] // movabs rbx, imm64 - code.append(contentsOf: littleEndianBytes(daxGuestBase)) - code.append(contentsOf: [0x8B, 0x03]) // mov eax, dword ptr [rbx] - code.append(0xBA) // mov edx, imm32 - code.append(contentsOf: littleEndianBytes(guestMarker)) - code.append(contentsOf: [0x89, 0x13]) // mov dword ptr [rbx], edx - code.append(0xF4) // hlt - try memory.write(code, at: codeAddress) - } - - private static func writePageTables(to memory: GuestMemory, daxGuestBase: UInt64) throws { - let presentWrite: UInt64 = 0x003 - let hugePresentWrite: UInt64 = 0x083 - let pml4Index = pageTableIndex(daxGuestBase, shift: 39) - let pdptIndex = pageTableIndex(daxGuestBase, shift: 30) - let pdIndex = pageTableIndex(daxGuestBase, shift: 21) - - if pml4Index != 0 { - throw VMError.invalidConfiguration("x86 dax probe base must be below 512 GiB") - } - try memory.write(pml4Entry(pdptAddress, flags: presentWrite), at: pml4Address) - - try memory.write(pml4Entry(lowPDAddress, flags: presentWrite), at: pdptAddress) - try memory.write(hugePageEntry(0, flags: hugePresentWrite), at: lowPDAddress) - - if pdptIndex == 0 { - try memory.write(hugePageEntry(daxGuestBase, flags: hugePresentWrite), at: lowPDAddress + pdIndex * 8) - } else { - try memory.write(pml4Entry(daxPDAddress, flags: presentWrite), at: pdptAddress + pdptIndex * 8) - try memory.write(hugePageEntry(daxGuestBase, flags: hugePresentWrite), at: daxPDAddress + pdIndex * 8) - } - } - - private static func configureLongMode(_ vcpu: VCPU) throws { - try vcpu.write(HV_X86_RIP, codeAddress) - try vcpu.write(HV_X86_RFLAGS, 0x2) - try vcpu.write(HV_X86_RAX, 0) - try vcpu.write(HV_X86_RBX, 0) - try vcpu.write(HV_X86_RDX, 0) - try vcpu.write(HV_X86_RSP, 0x8000) - try vcpu.write(HV_X86_CR3, pml4Address) - try vcpu.write(HV_X86_CR4, 1 << 5) // PAE - try vcpu.writeVMCS(UInt32(VMCS_GUEST_IA32_EFER), 0x500) // LME | LMA - try vcpu.write(HV_X86_CR0, (1 << 31) | 0x21) // PG | PE | NE - - try writeControl(vcpu, field: UInt32(VMCS_CTRL_PIN_BASED), requested: 0) - try writeControl( - vcpu, - field: UInt32(VMCS_CTRL_CPU_BASED), - requested: UInt32(CPU_BASED_HLT | CPU_BASED_SECONDARY_CTLS) - ) - try writeControl( - vcpu, - field: UInt32(VMCS_CTRL_CPU_BASED2), - requested: UInt32(CPU_BASED2_EPT | CPU_BASED2_UNRESTRICTED) - ) - try writeControl(vcpu, field: UInt32(VMCS_CTRL_VMEXIT_CONTROLS), requested: 0) - try writeControl(vcpu, field: UInt32(VMCS_CTRL_VMENTRY_CONTROLS), requested: UInt32(VMENTRY_LOAD_EFER)) - - try writeSegment( - vcpu, - selectorField: UInt32(VMCS_GUEST_CS), - baseField: UInt32(VMCS_GUEST_CS_BASE), - limitField: UInt32(VMCS_GUEST_CS_LIMIT), - accessField: UInt32(VMCS_GUEST_CS_AR), - selector: 0x08, - accessRights: 0xA09B - ) - for (selectorField, baseField, limitField, accessField) in [ - (VMCS_GUEST_SS, VMCS_GUEST_SS_BASE, VMCS_GUEST_SS_LIMIT, VMCS_GUEST_SS_AR), - (VMCS_GUEST_DS, VMCS_GUEST_DS_BASE, VMCS_GUEST_DS_LIMIT, VMCS_GUEST_DS_AR), - (VMCS_GUEST_ES, VMCS_GUEST_ES_BASE, VMCS_GUEST_ES_LIMIT, VMCS_GUEST_ES_AR), - (VMCS_GUEST_FS, VMCS_GUEST_FS_BASE, VMCS_GUEST_FS_LIMIT, VMCS_GUEST_FS_AR), - (VMCS_GUEST_GS, VMCS_GUEST_GS_BASE, VMCS_GUEST_GS_LIMIT, VMCS_GUEST_GS_AR), - ] { - try writeSegment( - vcpu, - selectorField: UInt32(selectorField), - baseField: UInt32(baseField), - limitField: UInt32(limitField), - accessField: UInt32(accessField), - selector: 0x10, - accessRights: 0xC093 - ) - } - try vcpu.writeVMCS(UInt32(VMCS_GUEST_LDTR), 0) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_LDTR_BASE), 0) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_LDTR_LIMIT), 0) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_LDTR_AR), 0x1_0000) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_TR), 0x18) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_TR_BASE), 0) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_TR_LIMIT), 0x67) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_TR_AR), 0x8B) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_GDTR_BASE), 0) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_GDTR_LIMIT), 0) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_IDTR_BASE), 0) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_IDTR_LIMIT), 0x3FF) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_ACTIVITY_STATE), 0) - try vcpu.writeVMCS(UInt32(VMCS_GUEST_INTERRUPTIBILITY), 0) - } - - private static func writeControl(_ vcpu: VCPU, field: UInt32, requested: UInt32) throws { - var requiredOne: UInt64 = 0 - var allowedOne: UInt64 = 0 - try hvCheck( - hv_vmx_vcpu_get_cap_write_vmcs(vcpu.handle, field, &requiredOne, &allowedOne), - "hv_vmx_vcpu_get_cap_write_vmcs" - ) - try vcpu.writeVMCS(field, (UInt64(requested) | requiredOne) & allowedOne) - } - - private static func writeSegment( - _ vcpu: VCPU, - selectorField: UInt32, - baseField: UInt32, - limitField: UInt32, - accessField: UInt32, - selector: UInt16, - accessRights: UInt64 - ) throws { - try vcpu.writeVMCS(selectorField, UInt64(selector)) - try vcpu.writeVMCS(baseField, 0) - try vcpu.writeVMCS(limitField, 0xFFFF_FFFF) - try vcpu.writeVMCS(accessField, accessRights) - } - - private static func pml4Entry(_ address: UInt64, flags: UInt64) -> UInt64 { - (address & 0x000F_FFFF_FFFF_F000) | flags - } - - private static func hugePageEntry(_ address: UInt64, flags: UInt64) -> UInt64 { - (address & 0x000F_FFFF_FFE0_0000) | flags - } - - private static func pageTableIndex(_ address: UInt64, shift: UInt64) -> UInt64 { - (address >> shift) & 0x1FF - } - - private static func littleEndianBytes(_ value: UInt64) -> [UInt8] { - withUnsafeBytes(of: value.littleEndian) { Array($0) } - } - - private static func littleEndianBytes(_ value: UInt32) -> [UInt8] { - withUnsafeBytes(of: value.littleEndian) { Array($0) } - } -} -#endif diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/DockerSocketBridge.swift b/Packages/ContainerizationEngine/Sources/DoryHV/DockerSocketBridge.swift index 5e187976..d3c68c4a 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/DockerSocketBridge.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/DockerSocketBridge.swift @@ -1,4 +1,3 @@ -import Darwin import Foundation /// Serves the engine's docker socket (`engine.sock`) from dory-hv itself, relaying every connection @@ -12,10 +11,20 @@ import Foundation public final class DockerSocketBridge: @unchecked Sendable { private let socketPath: String private let log: @Sendable (String) -> Void + private let listener: BoundedVsockSocketListener - public init(socketPath: String, log: @escaping @Sendable (String) -> Void = { _ in }) { + public init( + socketPath: String, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { self.socketPath = socketPath self.log = log + self.listener = BoundedVsockSocketListener( + socketPath: socketPath, + mode: nil, + endpointLabel: "docker socket bridge", + log: log + ) } /// Lets the engine reject an impossible Docker endpoint before it creates disks or sidecars. @@ -23,38 +32,29 @@ public final class DockerSocketBridge: @unchecked Sendable { try VsockUnixRelay.validateSocketPath(socketPath) } - private final class VsockBox: @unchecked Sendable { - let vsock: VirtioVsock - init(_ vsock: VirtioVsock) { self.vsock = vsock } - } - - private final class ConnectionBox: @unchecked Sendable { - let connection: VsockConnection - init(_ connection: VsockConnection) { self.connection = connection } - } - public func attach(to vsock: VirtioVsock) throws { - let listener = try VsockUnixRelay.makeListener(socketPath: socketPath) - let box = VsockBox(vsock) - let path = socketPath - let log = log - Thread.detachNewThread { - while true { - let client = accept(listener, nil, nil) - guard client >= 0 else { - if errno == EINTR { continue } - log("docker socket bridge accept failed on \(path): errno \(errno)") - break - } - var noSigpipe: Int32 = 1 - _ = setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noSigpipe, socklen_t(MemoryLayout.size)) - let connection = ConnectionBox(box.vsock.connect(port: VsockPorts.docker)) - Thread.detachNewThread { - VsockUnixRelay.serve(client: client, connection: connection.connection) - } + let logger = log + try listener.attach(to: vsock, service: .docker) { _ in + do { + return try vsock.connectIfCapacity(port: VsockPorts.docker) + } catch { + logger("docker socket bridge rejected guest dial: \(error)") + return nil } - close(listener) } log("docker socket bridge serving \(socketPath) over vsock:\(VsockPorts.docker)") } + + public func stop(timeout: TimeInterval = 1) { + listener.stop(timeout: timeout) + } + + var activeSessionCount: Int { listener.activeSessionCount } + var serviceAdmissionSnapshot: VirtioVsockServiceAdmissionSnapshot? { + listener.serviceAdmissionSnapshot + } + + deinit { + stop() + } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/DoryHostShareCoherenceBridge.swift b/Packages/ContainerizationEngine/Sources/DoryHV/DoryHostShareCoherenceBridge.swift new file mode 100644 index 00000000..9d6d777b --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/DoryHostShareCoherenceBridge.swift @@ -0,0 +1,181 @@ +import DoryFSWorkerContracts +import Foundation + +public enum DoryHostShareCoherenceBridgeError: Error, Equatable, Sendable { + case unknownCapability + case policyViolation + case invalidGuestPath + case notificationFailure + case watcherFailure +} + +public struct DoryHostShareCoherenceEndpoint: @unchecked Sendable { + public let capabilityID: DoryFSShareCapabilityID + public let backend: VirtioFS + public let guestRoot: String + public let policy: DoryFSShareCoherencePolicy + + public init( + capabilityID: DoryFSShareCapabilityID, + backend: VirtioFS, + guestRoot: String, + policy: DoryFSShareCoherencePolicy + ) throws { + guard guestRoot.hasPrefix("/"), guestRoot != "/", + !guestRoot.hasSuffix("/"), !guestRoot.utf8.contains(0), + !guestRoot.split(separator: "/", omittingEmptySubsequences: false) + .contains("..") else { + throw DoryHostShareCoherenceBridgeError.invalidGuestPath + } + self.capabilityID = capabilityID + self.backend = backend + self.guestRoot = guestRoot + self.policy = policy + } +} + +/// Runner half of host-edit coherence. The actor preserves invalidation-before-watcher ordering; +/// the terminal latch closes every sibling VirtioFS publication gate synchronously on any loss. +public actor DoryHostShareCoherenceBridge { + private final class TerminalLatch: @unchecked Sendable { + private let lock = NSLock() + private let backends: [VirtioFS] + private let onFatal: @Sendable (String) -> Void + private var failed = false + + init( + backends: [VirtioFS], + onFatal: @escaping @Sendable (String) -> Void + ) { + self.backends = backends + self.onFatal = onFatal + } + + func fail(_ reason: String) { + let shouldReport = lock.withLock { () -> Bool in + guard !failed else { return false } + failed = true + // Close all request-publication gates while the terminal lock is held. A quiet + // sibling mount may otherwise keep serving cached pages without another RPC. + for backend in backends { backend.failStopRequestPublication() } + return true + } + if shouldReport { onFatal(reason) } + } + + var isFailed: Bool { lock.withLock { failed } } + } + + private static let reverseInvalidationDeadline: Duration = .seconds(1) + private let endpoints: [DoryFSShareCapabilityID: DoryHostShareCoherenceEndpoint] + private let guestEvents: any GuestFSEventSending + private let terminal: TerminalLatch + + public init( + endpoints: [DoryHostShareCoherenceEndpoint], + guestEvents: any GuestFSEventSending, + onFatal: @escaping @Sendable (String) -> Void + ) { + self.endpoints = Dictionary(uniqueKeysWithValues: endpoints.map { + ($0.capabilityID, $0) + }) + self.guestEvents = guestEvents + terminal = TerminalLatch( + backends: endpoints.map(\.backend), + onFatal: onFatal + ) + } + + /// May be called directly from an XPC lifecycle callback; it performs the fail-stop latch + /// synchronously and does not wait for actor scheduling. + public nonisolated func failStop(_ reason: String) { + terminal.fail(reason) + } + + public func process(_ batch: DoryFSWorkerCoherenceBatch) async throws { + guard !terminal.isFailed else { + throw DoryHostShareCoherenceBridgeError.notificationFailure + } + guard let endpoint = endpoints[batch.shareCapabilityID], + endpoint.policy != .disabled else { + terminal.fail("filesystem coherence referenced an unknown or disabled capability") + throw DoryHostShareCoherenceBridgeError.unknownCapability + } + if endpoint.policy == .invalidationOnly, !batch.nudgeRelativePaths.isEmpty { + terminal.fail("filesystem worker crossed its invalidation-only policy") + throw DoryHostShareCoherenceBridgeError.policyViolation + } + + let invalidations = batch.invalidations.map { value in + switch value { + case .inode(let nodeID, let offset, let length): + VirtioFSInvalidation.inode( + nodeID: nodeID, + offset: offset, + length: length + ) + case .entry(let parentNodeID, let name, let flags): + VirtioFSInvalidation.entry( + parentNodeID: parentNodeID, + name: name, + flags: flags + ) + case .delete(let parentNodeID, let childNodeID, let name): + VirtioFSInvalidation.delete( + parentNodeID: parentNodeID, + childNodeID: childNodeID, + name: name + ) + } + } + do { + if !invalidations.isEmpty { + try await endpoint.backend.invalidateAtomically( + invalidations, + maximumBatchSize: min( + 128, + max(1, endpoint.backend.notificationBacklogLimit) + ), + timeout: Self.reverseInvalidationDeadline + ) + } + } catch { + terminal.fail("host-share reverse invalidation failed") + throw DoryHostShareCoherenceBridgeError.notificationFailure + } + + guard !batch.nudgeRelativePaths.isEmpty else { return } + let guestPaths: [String] + do { + guestPaths = try batch.nudgeRelativePaths.map { + try Self.guestPath(root: endpoint.guestRoot, relative: $0) + } + } catch { + terminal.fail("host-share watcher path validation failed") + throw DoryHostShareCoherenceBridgeError.invalidGuestPath + } + do { + let result = try await guestEvents.send( + operationID: batch.batchID, + paths: guestPaths + ) + guard result.pathCount == UInt32(guestPaths.count), result.failed == 0 else { + throw DoryHostShareCoherenceBridgeError.watcherFailure + } + } catch { + terminal.fail("host-share watcher notification failed") + throw DoryHostShareCoherenceBridgeError.watcherFailure + } + } + + private static func guestPath(root: String, relative: String) throws -> String { + let path = relative.isEmpty ? root : root + "/" + relative + guard path.utf8.count <= GuestFSEventBatchCodec.maximumPathBytes, + path.hasPrefix("/"), !path.utf8.contains(0), + !path.split(separator: "/", omittingEmptySubsequences: false) + .contains("..") else { + throw DoryHostShareCoherenceBridgeError.invalidGuestPath + } + return path + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerBroker.swift b/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerBroker.swift new file mode 100644 index 00000000..81ee9edf --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerBroker.swift @@ -0,0 +1,961 @@ +import Darwin +import DoryGuestMemoryShim +import DoryRendererWorkerContracts +import Foundation +import Metal + +public enum DoryRendererWorkerBrokerState: Equatable, Sendable { + case active + case interrupted + case invalidated + case protocolViolation + case outcomeUnknown +} + +/// Closed, non-sensitive location at which a command's foreign-renderer outcome became unknown. +/// These values are safe to surface in runner diagnostics: they contain no renderer-provided text, +/// host path, artifact identity, or guest-controlled payload bytes. +public enum DoryRendererWorkerCommandDiagnosticStage: Equatable, Sendable { + case workerServiceReply + case brokerCommandDeadline +} + +/// Closed terminal result paired with ``DoryRendererWorkerCommandDiagnosticStage``. +public enum DoryRendererWorkerCommandDiagnosticStatus: Equatable, Sendable { + case backendOutcomeUnknown + case deadlineExpired +} + +/// Minimal audit record for an uncertain command. Operation and request identity come from the +/// command admitted by this broker; elapsed time comes from the local monotonic clock. Deliberately +/// do not add arbitrary worker/foreign-library strings to this boundary. +public struct DoryRendererWorkerCommandDiagnostic: Equatable, Sendable { + public let operation: DoryRendererWorkerOperation + public let requestID: UInt64 + public let stage: DoryRendererWorkerCommandDiagnosticStage + public let status: DoryRendererWorkerCommandDiagnosticStatus + public let elapsedNanoseconds: UInt64 + + public init( + operation: DoryRendererWorkerOperation, + requestID: UInt64, + stage: DoryRendererWorkerCommandDiagnosticStage, + status: DoryRendererWorkerCommandDiagnosticStatus, + elapsedNanoseconds: UInt64 + ) { + self.operation = operation + self.requestID = requestID + self.stage = stage + self.status = status + self.elapsedNanoseconds = elapsedNanoseconds + } +} + +public enum DoryRendererWorkerBrokerError: Error, Equatable, Sendable { + case invalidBootstrap(DoryRendererWorkerContractError) + case invalidCommand(DoryRendererWorkerContractError) + case incompleteCapabilityReceipt + case notActive(DoryRendererWorkerBrokerState) + case requestIDExhausted + case inFlightLimit(limit: Int) + case aggregateReferencedBytesLimit(limit: UInt64, requested: UInt64) + case deadlineExpired + case deadlineTooDistant(limitNanoseconds: UInt64, actualNanoseconds: UInt64) + case inputDescriptorCountMismatch(expected: Int, actual: Int) + case invalidInputDescriptor(index: Int) + case workerRejected(DoryRendererWorkerRPCFailureCode) + case channelFailure(DoryRendererWorkerChannelFailure) + case channelFailureDuring( + requestID: UInt64, + operation: DoryRendererWorkerOperation, + failure: DoryRendererWorkerChannelFailure + ) + case malformedReply(DoryRendererWorkerContractError) + case replyIdentityMismatch + case invalidReplyDescriptor(index: Int) + case workerOutcomeUnknown(DoryRendererWorkerCommandDiagnostic) +} + +public struct DoryRendererWorkerFenceReceipt: @unchecked Sendable { + public let workerGeneration: DoryRendererWorkerGeneration + public let contextID: UInt32 + public let flags: UInt32 + public let ringIndex: UInt32 + public let fenceID: UInt64 + public let completionDescriptor: FileHandle +} + +public struct DoryRendererWorkerBlobMapping: @unchecked Sendable { + public let lease: DoryRendererBlobMappingLease + public let sharedMemoryDescriptor: FileHandle +} + +public struct DoryRendererWorkerScanout: @unchecked Sendable { + public let lease: DoryRendererScanoutLease + public let sharedMemoryDescriptor: FileHandle +} + +public struct DoryRendererWorkerSharedTextureScanout: @unchecked Sendable { + public let lease: DoryRendererSharedTextureScanoutLease + public let sharedTextureHandle: MTLSharedTextureHandle +} + +public enum DoryRendererWorkerCommandResult: @unchecked Sendable { + case acknowledged + case resourceCreated(generation: UInt64) + case blobMapping(DoryRendererWorkerBlobMapping) + case fence(DoryRendererWorkerFenceReceipt) + case scanout(DoryRendererWorkerScanout) + case sharedTextureScanout(DoryRendererWorkerSharedTextureScanout) + case reset(successorGeneration: UInt64) +} + +public struct DoryRendererWorkerBrokerSnapshot: Equatable, Sendable { + public let state: DoryRendererWorkerBrokerState + public let generation: DoryRendererWorkerGeneration + public let inFlightCommands: Int + public let maximumObservedInFlightCommands: Int + public let aggregateReferencedBytes: UInt64 + public let submittedBatches: UInt64 + public let controlBytes: UInt64 + public let descriptorBackedCommandBytes: UInt64 + public let rejectedAdmissions: UInt64 + public let protocolViolations: UInt64 + public let lateReplies: UInt64 + /// Accelerated presentation has no byte-copy API. This remains zero by construction. + public let scanoutCopyBytes: UInt64 +} + +private final class DoryRendererWorkerBrokerTerminalRelay: @unchecked Sendable { + typealias Handler = @Sendable ( + DoryRendererWorkerBrokerState, + DoryRendererWorkerBrokerError + ) -> Void + + private let lock = NSLock() + private var terminal: (DoryRendererWorkerBrokerState, DoryRendererWorkerBrokerError)? + private var handlers = [Handler]() + + func install(_ handler: @escaping Handler) { + let immediate = lock.withLock { () -> ( + DoryRendererWorkerBrokerState, + DoryRendererWorkerBrokerError + )? in + if let terminal { return terminal } + handlers.append(handler) + return nil + } + if let immediate { handler(immediate.0, immediate.1) } + } + + func publish( + state: DoryRendererWorkerBrokerState, + error: DoryRendererWorkerBrokerError + ) { + let delivery = lock.withLock { () -> [Handler] in + guard terminal == nil else { return [] } + terminal = (state, error) + let delivery = handlers + handlers.removeAll(keepingCapacity: false) + return delivery + } + for handler in delivery { handler(state, error) } + } +} + +private final class DoryRendererWorkerBootstrapReplyGate: @unchecked Sendable { + private let lock = NSLock() + private var completed = false + + func claim() -> Bool { + lock.withLock { + guard !completed else { return false } + completed = true + return true + } + } +} + +/// VMM-side owner of one authenticated renderer-worker generation. +/// +/// Admission is asynchronous and bounded; the vCPU never performs a synchronous XPC round trip or +/// waits for GPU completion. `submit3D` bytes exist only in descriptor-backed regions. A command +/// deadline, malformed reply, helper interruption, or uncertain foreign outcome revokes the whole +/// generation because the broker cannot prove what renderer state the worker reached. +public actor DoryRendererWorkerBroker { + public static let maximumAdmissionDeadlineNanoseconds: UInt64 = 30_000_000_000 + public static let productionBootstrapTimeoutNanoseconds: UInt64 = 10_000_000_000 + + private struct PendingCommand { + let command: DoryRendererWorkerCommand + let admittedUptimeNanoseconds: UInt64 + let inputDescriptors: [FileHandle] + let referencedBytes: UInt64 + let continuation: CheckedContinuation + var timeoutTask: Task? + } + + /// Immutable bootstrap/receipt authority is safe to inspect without an actor hop. Mutable + /// command admission and channel lifecycle remain actor-isolated. + public nonisolated let bootstrap: DoryRendererWorkerBootstrap + public nonisolated let capabilityReceipt: DoryRendererCapabilityReceipt + + private let channel: any DoryRendererWorkerChannel + private nonisolated let terminalRelay = DoryRendererWorkerBrokerTerminalRelay() + private var state: DoryRendererWorkerBrokerState = .active + private var nextRequestID: UInt64 = 1 + private var pendingByRequestID = [UInt64: PendingCommand]() + private var aggregateReferencedBytes: UInt64 = 0 + private var maximumObservedInFlightCommands = 0 + private var submittedBatches: UInt64 = 0 + private var controlBytes: UInt64 = 0 + private var descriptorBackedCommandBytes: UInt64 = 0 + private var rejectedAdmissions: UInt64 = 0 + private var protocolViolations: UInt64 = 0 + private var lateReplies: UInt64 = 0 + + public init( + bootstrap: DoryRendererWorkerBootstrap, + capabilityReceipt: DoryRendererCapabilityReceipt, + channel: any DoryRendererWorkerChannel + ) throws { + guard capabilityReceipt.productionAccelerationIsAdmissible, + capabilityReceipt.workspaceID == bootstrap.workspaceID, + capabilityReceipt.generation == bootstrap.generation, + capabilityReceipt.sourceTuple == bootstrap.sourceTuple, + capabilityReceipt.producerFenceContract == bootstrap.producerFenceContract, + capabilityReceipt.candidateInventory == bootstrap.artifacts.candidateInventory, + capabilityReceipt.rendererWorkerExecutable + == bootstrap.artifacts.rendererWorkerExecutable else { + channel.invalidate() + throw DoryRendererWorkerBrokerError.incompleteCapabilityReceipt + } + self.bootstrap = bootstrap + self.capabilityReceipt = capabilityReceipt + self.channel = channel + channel.installLifecycleHandler { [weak self] event in + guard let self else { return } + Task { await self.receiveChannelEvent(event) } + } + } + + /// Performs one one-shot bootstrap and returns a broker only for a complete production receipt. + /// The exact bytes originate in the operation-bound launch authority; this method never derives + /// artifact identity from paths or environment variables. + public static func connect( + exactBootstrapBytes: Data, + timeoutNanoseconds: UInt64 = productionBootstrapTimeoutNanoseconds + ) async throws -> Self { + let bootstrap: DoryRendererWorkerBootstrap + do { + bootstrap = try DoryRendererWorkerBootstrapCodec.decode(exactBootstrapBytes) + } catch let error as DoryRendererWorkerContractError { + throw DoryRendererWorkerBrokerError.invalidBootstrap(error) + } + // Decode before endpoint activation so the exact signed worker slice in this generation + // becomes the audit-token requirement. The stable service identifier is discovery only. + let channel = DoryRendererWorkerXPCChannel( + codeDirectoryHash: bootstrap.artifacts.rendererWorkerCodeDirectoryHash + ) + let receiptBytes: Data + do { + receiptBytes = try await performBootstrap( + channel: channel, + exactBytes: exactBootstrapBytes, + timeoutNanoseconds: timeoutNanoseconds + ) + } catch let failure as DoryRendererWorkerChannelFailure { + channel.invalidate() + throw DoryRendererWorkerBrokerError.channelFailure(failure) + } + let receipt: DoryRendererCapabilityReceipt + do { + receipt = try DoryRendererCapabilityReceiptCodec.decode( + receiptBytes, + accepting: bootstrap + ) + } catch let error as DoryRendererWorkerContractError { + channel.invalidate() + throw DoryRendererWorkerBrokerError.invalidBootstrap(error) + } + return try Self( + bootstrap: bootstrap, + capabilityReceipt: receipt, + channel: channel + ) + } + + static func performBootstrap( + channel: any DoryRendererWorkerChannel, + exactBytes: Data, + timeoutNanoseconds: UInt64 + ) async throws -> Data { + guard timeoutNanoseconds > 0 else { + channel.invalidate() + throw DoryRendererWorkerBrokerError.deadlineExpired + } + guard timeoutNanoseconds <= maximumAdmissionDeadlineNanoseconds else { + channel.invalidate() + throw DoryRendererWorkerBrokerError.deadlineTooDistant( + limitNanoseconds: maximumAdmissionDeadlineNanoseconds, + actualNanoseconds: timeoutNanoseconds + ) + } + return try await withCheckedThrowingContinuation { continuation in + let gate = DoryRendererWorkerBootstrapReplyGate() + let timeoutTask = Task { + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + // Cancellation is also a terminal startup result. A completed reply has already + // claimed the gate, so its cancellation wakes this task harmlessly; inherited + // caller cancellation must not leave the checked continuation suspended forever. + guard gate.claim() else { return } + channel.invalidate() + continuation.resume( + throwing: DoryRendererWorkerBrokerError.deadlineExpired + ) + } + channel.bootstrap(exactBytes: exactBytes) { result in + guard gate.claim() else { return } + timeoutTask.cancel() + continuation.resume(with: result) + } + } + } + + /// Sends one typed operation. Shared region descriptors are duplicated at admission and kept + /// alive through the XPC reply, so caller-side close/reuse cannot change an admitted command. + public func execute( + operation: DoryRendererWorkerOperation, + contextID: UInt32 = 0, + resourceID: UInt32 = 0, + resourceGeneration: UInt64 = 0, + sharedRegions: [DoryRendererSharedRegionReference] = [], + descriptors: [FileHandle] = [], + payload: Data = Data(), + deadlineUptimeNanoseconds: UInt64 + ) async throws -> DoryRendererWorkerCommandResult { + guard state == .active else { throw reject(.notActive(state)) } + guard pendingByRequestID.count < bootstrap.limits.maximumInFlightCommands else { + throw reject(.inFlightLimit(limit: bootstrap.limits.maximumInFlightCommands)) + } + guard nextRequestID != 0 else { throw reject(.requestIDExhausted) } + let now = DispatchTime.now().uptimeNanoseconds + guard deadlineUptimeNanoseconds > now else { throw reject(.deadlineExpired) } + let remaining = deadlineUptimeNanoseconds - now + guard remaining <= Self.maximumAdmissionDeadlineNanoseconds else { + throw reject(.deadlineTooDistant( + limitNanoseconds: Self.maximumAdmissionDeadlineNanoseconds, + actualNanoseconds: remaining + )) + } + let requiredDescriptorCount = sharedRegions.isEmpty + ? 0 + : Int(sharedRegions.map(\.descriptorIndex).max()!) + 1 + guard descriptors.count == requiredDescriptorCount else { + throw reject(.inputDescriptorCountMismatch( + expected: requiredDescriptorCount, + actual: descriptors.count + )) + } + let referencedBytes = try Self.sumReferencedBytes(sharedRegions) + let (newAggregate, aggregateOverflow) = aggregateReferencedBytes + .addingReportingOverflow(referencedBytes) + guard !aggregateOverflow, + newAggregate <= bootstrap.limits.maximumReferencedBytes else { + throw reject(.aggregateReferencedBytesLimit( + limit: bootstrap.limits.maximumReferencedBytes, + requested: aggregateOverflow ? UInt64.max : newAggregate + )) + } + + let requestID = nextRequestID + let command: DoryRendererWorkerCommand + do { + command = try DoryRendererWorkerCommand( + generation: bootstrap.generation, + requestID: requestID, + operation: operation, + contextID: contextID, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deadlineUptimeNanoseconds: deadlineUptimeNanoseconds, + sharedRegions: sharedRegions, + payload: payload, + limits: bootstrap.limits + ) + } catch let error as DoryRendererWorkerContractError { + throw reject(.invalidCommand(error)) + } + let ownedDescriptors: [FileHandle] + do { + ownedDescriptors = try Self.duplicateAndValidate( + descriptors, + references: command.sharedRegions + ) + } catch let error as DoryRendererWorkerBrokerError { + throw reject(error) + } + let frame: Data + do { + frame = try DoryRendererWorkerCommandCodec.encode( + command, + limits: bootstrap.limits + ) + } catch let error as DoryRendererWorkerContractError { + Self.close(ownedDescriptors) + throw reject(.invalidCommand(error)) + } + + nextRequestID = requestID == UInt64.max ? 0 : requestID + 1 + aggregateReferencedBytes = newAggregate + submittedBatches = Self.saturatingAdd(submittedBatches, 1) + controlBytes = Self.saturatingAdd(controlBytes, UInt64(frame.count)) + if operation == .submit3D { + descriptorBackedCommandBytes = Self.saturatingAdd( + descriptorBackedCommandBytes, + referencedBytes + ) + } + + return try await withCheckedThrowingContinuation { continuation in + pendingByRequestID[requestID] = PendingCommand( + command: command, + admittedUptimeNanoseconds: now, + inputDescriptors: ownedDescriptors, + referencedBytes: referencedBytes, + continuation: continuation, + timeoutTask: nil + ) + maximumObservedInFlightCommands = max( + maximumObservedInFlightCommands, + pendingByRequestID.count + ) + channel.exchange(frame: frame, descriptors: ownedDescriptors) { [weak self] result in + guard let self else { + if case .success(let reply) = result { Self.close(reply.descriptors) } + return + } + Task { await self.receiveReply(result, requestID: requestID) } + } + let timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: remaining) + guard let self else { return } + await self.expire(requestID: requestID) + } + pendingByRequestID[requestID]?.timeoutTask = timeoutTask + } + } + + public func invalidate() { + transitionToTerminal(.invalidated, error: .notActive(.invalidated)) + channel.invalidate() + } + + /// Installs a one-shot terminal-generation observer without an actor hop. This is required by + /// fence owners: helper death must revoke already-armed descriptors even when no command is + /// currently awaiting an XPC reply. Installation after failure delivers the stored terminal + /// state immediately, so a worker restart cannot miss the edge. + public nonisolated func installTerminalHandler( + _ handler: @escaping @Sendable ( + DoryRendererWorkerBrokerState, + DoryRendererWorkerBrokerError + ) -> Void + ) { + terminalRelay.install(handler) + } + + public func snapshot() -> DoryRendererWorkerBrokerSnapshot { + DoryRendererWorkerBrokerSnapshot( + state: state, + generation: bootstrap.generation, + inFlightCommands: pendingByRequestID.count, + maximumObservedInFlightCommands: maximumObservedInFlightCommands, + aggregateReferencedBytes: aggregateReferencedBytes, + submittedBatches: submittedBatches, + controlBytes: controlBytes, + descriptorBackedCommandBytes: descriptorBackedCommandBytes, + rejectedAdmissions: rejectedAdmissions, + protocolViolations: protocolViolations, + lateReplies: lateReplies, + scanoutCopyBytes: 0 + ) + } + + private func receiveReply( + _ result: Result, + requestID: UInt64 + ) { + guard let pending = pendingByRequestID[requestID] else { + lateReplies = Self.saturatingAdd(lateReplies, 1) + if case .success(let reply) = result { Self.close(reply.descriptors) } + return + } + guard DispatchTime.now().uptimeNanoseconds < pending.command.deadlineUptimeNanoseconds else { + if case .success(let reply) = result { Self.close(reply.descriptors) } + expire(requestID: requestID) + return + } + switch result { + case .failure(.serviceFailure(let code)) where Self.isProvenRejection(code): + let removed = removePending(requestID) + removed?.continuation.resume(throwing: DoryRendererWorkerBrokerError.workerRejected(code)) + case .failure(.serviceFailure(.outcomeUnknown)): + transitionToTerminal( + .outcomeUnknown, + error: .workerOutcomeUnknown(commandDiagnostic( + for: pending, + stage: .workerServiceReply, + status: .backendOutcomeUnknown + )) + ) + channel.invalidate() + case .failure(let failure): + let terminal: DoryRendererWorkerBrokerState = switch failure { + case .interrupted: .interrupted + case .invalidated: .invalidated + case .malformedResult, .descriptorCountMismatch: .protocolViolation + case .unavailable, .serviceFailure: .outcomeUnknown + } + if terminal == .protocolViolation { + protocolViolations = Self.saturatingAdd(protocolViolations, 1) + } + transitionToTerminal( + terminal, + error: .channelFailureDuring( + requestID: requestID, + operation: pending.command.operation, + failure: failure + ) + ) + channel.invalidate() + case .success(let reply): + do { + let decoded = try decodeReply(reply, accepting: pending.command) + let removed = removePending(requestID) + removed?.continuation.resume(returning: decoded) + } catch let error as DoryRendererWorkerBrokerError { + Self.close(reply.descriptors) + protocolViolations = Self.saturatingAdd(protocolViolations, 1) + transitionToTerminal(.protocolViolation, error: error) + channel.invalidate() + } catch { + Self.close(reply.descriptors) + protocolViolations = Self.saturatingAdd(protocolViolations, 1) + transitionToTerminal(.protocolViolation, error: .replyIdentityMismatch) + channel.invalidate() + } + } + } + + private func decodeReply( + _ reply: DoryRendererWorkerChannelReply, + accepting command: DoryRendererWorkerCommand + ) throws -> DoryRendererWorkerCommandResult { + guard command.operation == .acquireScanoutLease + || reply.sharedTextureHandle == nil else { + throw replyMismatch() + } + switch command.operation { + case .createResource3D, .createBlob: + try Self.requireDescriptorCount(reply.descriptors, expected: 0) + guard reply.payload.count == 8 else { throw replyMismatch() } + let generation = Self.decodeUInt64(reply.payload) + guard generation != 0 else { throw replyMismatch() } + return .resourceCreated(generation: generation) + + case .mapBlob: + let lease: DoryRendererBlobMappingLease + do { + lease = try DoryRendererBlobMappingLeaseCodec.decode( + reply.payload, + limits: bootstrap.limits + ) + } catch let error as DoryRendererWorkerContractError { + throw DoryRendererWorkerBrokerError.malformedReply(error) + } + try lease.validateOutOfBandDescriptorCount(reply.descriptors.count) + guard lease.workerGeneration == bootstrap.generation, + lease.resourceID == command.resourceID, + lease.resourceGeneration == command.resourceGeneration else { + throw replyMismatch() + } + try Self.validateReturnedSharedMemory( + reply.descriptors[0], + declaredFileSize: lease.declaredFileSize, + minimumByteCount: lease.mappingByteCount, + index: 0 + ) + return .blobMapping(DoryRendererWorkerBlobMapping( + lease: lease, + sharedMemoryDescriptor: reply.descriptors[0] + )) + + case .createFence: + try Self.requireDescriptorCount(reply.descriptors, expected: 1) + let expected: DoryRendererFencePayload + let received: DoryRendererFencePayload + do { + expected = try DoryRendererFencePayload.decode(command.payload) + received = try DoryRendererFencePayload.decode(reply.payload) + } catch let error as DoryRendererWorkerContractError { + throw DoryRendererWorkerBrokerError.malformedReply(error) + } + guard received == expected else { throw replyMismatch() } + try Self.validateReturnedFence(reply.descriptors[0], index: 0) + return .fence(DoryRendererWorkerFenceReceipt( + workerGeneration: bootstrap.generation, + contextID: command.contextID, + flags: received.flags, + ringIndex: received.ringIndex, + fenceID: received.fenceID, + completionDescriptor: reply.descriptors[0] + )) + + case .acquireScanoutLease: + if let sharedTextureHandle = reply.sharedTextureHandle { + try Self.requireDescriptorCount(reply.descriptors, expected: 0) + let lease: DoryRendererSharedTextureScanoutLease + do { + lease = try DoryRendererSharedTextureScanoutLeaseCodec.decode( + reply.payload, + limits: bootstrap.limits + ) + } catch let error as DoryRendererWorkerContractError { + throw DoryRendererWorkerBrokerError.malformedReply(error) + } + guard lease.workerGeneration == bootstrap.generation, + lease.resourceID == command.resourceID, + lease.resourceGeneration == command.resourceGeneration else { + throw replyMismatch() + } + return .sharedTextureScanout(DoryRendererWorkerSharedTextureScanout( + lease: lease, + sharedTextureHandle: sharedTextureHandle + )) + } + let lease: DoryRendererScanoutLease + do { + lease = try DoryRendererScanoutLeaseCodec.decode( + reply.payload, + limits: bootstrap.limits + ) + } catch let error as DoryRendererWorkerContractError { + throw DoryRendererWorkerBrokerError.malformedReply(error) + } + try lease.validateOutOfBandDescriptorCount(reply.descriptors.count) + guard lease.workerGeneration == bootstrap.generation, + lease.resourceID == command.resourceID, + lease.resourceGeneration == command.resourceGeneration else { + throw replyMismatch() + } + try Self.validateReturnedSharedMemory( + reply.descriptors[0], + declaredFileSize: lease.declaredFileSize, + minimumByteCount: lease.storageOffset + lease.leaseByteCount, + index: 0 + ) + return .scanout(DoryRendererWorkerScanout( + lease: lease, + sharedMemoryDescriptor: reply.descriptors[0] + )) + + case .resetAfterDeviceQuiesce: + try Self.requireDescriptorCount(reply.descriptors, expected: 0) + let expected: DoryRendererResetPayload + let received: DoryRendererResetPayload + do { + expected = try DoryRendererResetPayload.decode(command.payload) + received = try DoryRendererResetPayload.decode(reply.payload) + } catch let error as DoryRendererWorkerContractError { + throw DoryRendererWorkerBrokerError.malformedReply(error) + } + guard expected == received else { throw replyMismatch() } + return .reset(successorGeneration: received.successorGeneration) + + case .createContext, .destroyContext, .attachResource, .detachResource, + .submit3D, .attachBacking, .detachBacking, .unrefResource, .unmapBlob, + .transferToHost3D, .transferFromHost3D, .releaseScanoutLease: + try Self.requireDescriptorCount(reply.descriptors, expected: 0) + guard reply.payload.isEmpty else { throw replyMismatch() } + return .acknowledged + } + } + + private func expire(requestID: UInt64) { + guard let pending = pendingByRequestID[requestID] else { return } + transitionToTerminal( + .outcomeUnknown, + error: .workerOutcomeUnknown(commandDiagnostic( + for: pending, + stage: .brokerCommandDeadline, + status: .deadlineExpired + )) + ) + channel.invalidate() + } + + private func commandDiagnostic( + for pending: PendingCommand, + stage: DoryRendererWorkerCommandDiagnosticStage, + status: DoryRendererWorkerCommandDiagnosticStatus + ) -> DoryRendererWorkerCommandDiagnostic { + let now = DispatchTime.now().uptimeNanoseconds + return DoryRendererWorkerCommandDiagnostic( + operation: pending.command.operation, + requestID: pending.command.requestID, + stage: stage, + status: status, + elapsedNanoseconds: now >= pending.admittedUptimeNanoseconds + ? now - pending.admittedUptimeNanoseconds + : 0 + ) + } + + private func receiveChannelEvent(_ event: DoryRendererWorkerChannelEvent) { + let (terminal, failure): ( + DoryRendererWorkerBrokerState, + DoryRendererWorkerChannelFailure + ) = switch event { + case .interrupted: (.interrupted, .interrupted) + case .invalidated: (.invalidated, .invalidated) + } + let error: DoryRendererWorkerBrokerError + if let requestID = pendingByRequestID.keys.min(), + let pending = pendingByRequestID[requestID] { + error = .channelFailureDuring( + requestID: requestID, + operation: pending.command.operation, + failure: failure + ) + } else { + error = .notActive(terminal) + } + transitionToTerminal(terminal, error: error) + } + + private func transitionToTerminal( + _ requestedState: DoryRendererWorkerBrokerState, + error: DoryRendererWorkerBrokerError + ) { + guard state == .active else { return } + state = requestedState + terminalRelay.publish(state: requestedState, error: error) + let pending = pendingByRequestID + pendingByRequestID.removeAll(keepingCapacity: false) + aggregateReferencedBytes = 0 + for command in pending.values { + command.timeoutTask?.cancel() + Self.close(command.inputDescriptors) + command.continuation.resume(throwing: error) + } + } + + private func removePending(_ requestID: UInt64) -> PendingCommand? { + guard let pending = pendingByRequestID.removeValue(forKey: requestID) else { return nil } + pending.timeoutTask?.cancel() + Self.close(pending.inputDescriptors) + aggregateReferencedBytes = aggregateReferencedBytes >= pending.referencedBytes + ? aggregateReferencedBytes - pending.referencedBytes + : 0 + return pending + } + + private func reject( + _ error: DoryRendererWorkerBrokerError + ) -> DoryRendererWorkerBrokerError { + rejectedAdmissions = Self.saturatingAdd(rejectedAdmissions, 1) + return error + } + + private func replyMismatch() -> DoryRendererWorkerBrokerError { + .replyIdentityMismatch + } + + private static func isProvenRejection(_ code: DoryRendererWorkerRPCFailureCode) -> Bool { + switch code { + case .deadlineExpired, .resourceExhausted, .commandRejected: + true + case .invalidEnvelope, .bootstrapRejected, .bootstrapAlreadyAttempted, + .bootstrapRequired, .capabilityUnavailable, .staleGeneration, .outcomeUnknown, + .protocolViolation, .internalFailure, .bootstrapArtifactAuthorityFailed, + .bootstrapRendererInitializationFailed, .bootstrapVenusCapabilityFailed, + .bootstrapVenusContextFailed, .bootstrapSharedMemoryExportFailed, + .bootstrapFenceExportFailed, .bootstrapCapabilityReceiptFailed, + .bootstrapVirgl2CapabilityFailed, .bootstrapVirgl2ContextFailed: + false + } + } + + private static func sumReferencedBytes( + _ regions: [DoryRendererSharedRegionReference] + ) throws -> UInt64 { + var total: UInt64 = 0 + for region in regions { + let (next, overflow) = total.addingReportingOverflow(region.length) + guard !overflow else { + throw DoryRendererWorkerBrokerError.aggregateReferencedBytesLimit( + limit: UInt64.max, + requested: UInt64.max + ) + } + total = next + } + return total + } + + private static func duplicateAndValidate( + _ descriptors: [FileHandle], + references: [DoryRendererSharedRegionReference] + ) throws -> [FileHandle] { + var owned = [FileHandle]() + owned.reserveCapacity(descriptors.count) + var identities = Set() + do { + let metadata = Dictionary(grouping: references, by: \.descriptorIndex) + for (index, descriptor) in descriptors.enumerated() { + guard let descriptorReferences = metadata[UInt16(index)], + let reference = descriptorReferences.first, + descriptorReferences.allSatisfy({ + $0.declaredFileSize == reference.declaredFileSize + }) else { + throw DoryRendererWorkerBrokerError.invalidInputDescriptor(index: index) + } + let source = descriptor.fileDescriptor + var status = stat() + guard source >= 0, + fstat(source, &status) == 0, + status.st_nlink == 0, + status.st_size >= 0, + UInt64(status.st_size) == reference.declaredFileSize else { + throw DoryRendererWorkerBrokerError.invalidInputDescriptor(index: index) + } + let descriptorIdentity: DescriptorIdentity + switch status.st_mode & S_IFMT { + case S_IFREG: + descriptorIdentity = .filesystem( + device: UInt64(status.st_dev), + inode: UInt64(status.st_ino) + ) + case 0: + guard descriptorReferences.allSatisfy({ + $0.offset >= DoryGuestMemoryBackingDataOffset() + }) else { + throw DoryRendererWorkerBrokerError.invalidInputDescriptor(index: index) + } + var identity = DoryGuestMemoryBackingIdentity() + guard DoryReadGuestMemoryBackingIdentity( + source, + reference.declaredFileSize, + &identity + ) == 1 else { + throw DoryRendererWorkerBrokerError.invalidInputDescriptor(index: index) + } + descriptorIdentity = .guestMemory( + withUnsafeBytes(of: &identity) { Data($0) } + ) + default: + throw DoryRendererWorkerBrokerError.invalidInputDescriptor(index: index) + } + let accessMode = fcntl(source, F_GETFL) & O_ACCMODE + guard (reference.access == .readOnly && accessMode == O_RDONLY) + || (reference.access == .readWrite && accessMode == O_RDWR) else { + throw DoryRendererWorkerBrokerError.invalidInputDescriptor(index: index) + } + guard identities.insert(descriptorIdentity).inserted else { + throw DoryRendererWorkerBrokerError.invalidInputDescriptor(index: index) + } + let duplicate = fcntl(source, F_DUPFD_CLOEXEC, 0) + guard duplicate >= 0 else { + throw DoryRendererWorkerBrokerError.invalidInputDescriptor(index: index) + } + owned.append(FileHandle(fileDescriptor: duplicate, closeOnDealloc: true)) + } + return owned + } catch { + close(owned) + throw error + } + } + + private enum DescriptorIdentity: Hashable { + case filesystem(device: UInt64, inode: UInt64) + case guestMemory(Data) + } + + private static func requireDescriptorCount( + _ descriptors: [FileHandle], + expected: Int + ) throws { + guard descriptors.count == expected else { + throw DoryRendererWorkerBrokerError.replyIdentityMismatch + } + } + + private static func validateReturnedSharedMemory( + _ descriptor: FileHandle, + declaredFileSize: UInt64, + minimumByteCount: UInt64, + index: Int + ) throws { + let fd = descriptor.fileDescriptor + var status = stat() + guard fd >= 0, + fstat(fd, &status) == 0, + DoryRendererSharedMemoryDescriptorPolicy.accepts(mode: status.st_mode), + status.st_nlink == 0, + status.st_size >= 0, + UInt64(status.st_size) == declaredFileSize, + minimumByteCount <= declaredFileSize, + fcntl(fd, F_GETFL) & O_ACCMODE == O_RDWR else { + throw DoryRendererWorkerBrokerError.invalidReplyDescriptor(index: index) + } + try setCloseOnExec(fd, index: index) + } + + private static func validateReturnedFence(_ descriptor: FileHandle, index: Int) throws { + let fd = descriptor.fileDescriptor + var status = stat() + guard fd >= 0, + fstat(fd, &status) == 0, + (status.st_mode & S_IFMT) != S_IFDIR else { + throw DoryRendererWorkerBrokerError.invalidReplyDescriptor(index: index) + } + try setCloseOnExec(fd, index: index) + } + + private static func requireDistinctDescriptors( + _ first: FileHandle, + _ second: FileHandle + ) throws { + var firstStatus = stat() + var secondStatus = stat() + guard fstat(first.fileDescriptor, &firstStatus) == 0, + fstat(second.fileDescriptor, &secondStatus) == 0, + firstStatus.st_dev != secondStatus.st_dev + || firstStatus.st_ino != secondStatus.st_ino else { + throw DoryRendererWorkerBrokerError.replyIdentityMismatch + } + } + + private static func setCloseOnExec(_ fd: Int32, index: Int) throws { + let flags = fcntl(fd, F_GETFD) + guard flags >= 0, fcntl(fd, F_SETFD, flags | FD_CLOEXEC) == 0 else { + throw DoryRendererWorkerBrokerError.invalidReplyDescriptor(index: index) + } + } + + private static func decodeUInt64(_ data: Data) -> UInt64 { + data.withUnsafeBytes { bytes in + UInt64(littleEndian: bytes.loadUnaligned(as: UInt64.self)) + } + } + + private static func saturatingAdd(_ lhs: UInt64, _ rhs: UInt64) -> UInt64 { + let (sum, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? UInt64.max : sum + } + + private static func close(_ descriptors: [FileHandle]) { + for descriptor in descriptors { try? descriptor.close() } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerSharedMemory.swift b/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerSharedMemory.swift new file mode 100644 index 00000000..c77adedc --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerSharedMemory.swift @@ -0,0 +1,234 @@ +import Darwin +import DoryRendererWorkerContracts +import Foundation + +/// Descriptor authorities and their ordered renderer iovec slices. One descriptor may back many +/// discontiguous guest-RAM regions; command streams instead use a separately sealed read-only +/// snapshot so a guest cannot mutate admitted bytes while the worker consumes them. +struct DoryRendererWorkerSharedRegionSet: @unchecked Sendable { + let references: [DoryRendererSharedRegionReference] + let descriptors: [FileHandle] + + static func guestBacking( + entries: [VirtioGPUMemoryEntry], + transport: VirtioMMIOTransport + ) throws -> Self { + guard !entries.isEmpty else { return Self(references: [], descriptors: []) } + let descriptor = try transport.duplicateGuestMemoryBackingDescriptor() + do { + var references = [DoryRendererSharedRegionReference]() + references.reserveCapacity(entries.count) + var expectedFileSize: UInt64? + for entry in entries { + guard entry.length > 0, let guestAddress = entry.guestAddress else { + throw VMError.invalidConfiguration( + "renderer backing is not guest-memory descriptor backed" + ) + } + let bounds = try transport.guestMemoryRegionBounds( + at: guestAddress, + count: UInt64(entry.length) + ) + if let expectedFileSize { + guard expectedFileSize == bounds.declaredFileSize else { + throw VMError.invalidConfiguration( + "renderer backing spans different guest-memory authorities" + ) + } + } else { + expectedFileSize = bounds.declaredFileSize + } + references.append(try DoryRendererSharedRegionReference( + identity: .random(), + descriptorIndex: 0, + access: .readWrite, + offset: bounds.offset, + length: bounds.length, + declaredFileSize: bounds.declaredFileSize + )) + } + return Self(references: references, descriptors: [descriptor]) + } catch { + try? descriptor.close() + throw error + } + } + + /// Copies one immutable submit stream directly from a lease-held virtqueue view into an + /// unlinked shared mapping. No `[UInt8]` or XPC `Data` ever contains command dwords. + static func immutableSubmit3D( + from access: VirtqueueLeaseAccess, + readableOffset: Int, + byteCount: Int, + maximumByteCount: Int + ) throws -> Self { + try immutableSubmit3D( + from: access.segments, + readableByteCount: access.readableByteCount, + readableOffset: readableOffset, + byteCount: byteCount, + maximumByteCount: maximumByteCount + ) + } + + static func immutableSubmit3D( + from segments: [VirtqueueSegment], + readableByteCount: Int, + readableOffset: Int, + byteCount: Int, + maximumByteCount: Int + ) throws -> Self { + guard readableOffset >= 0, + byteCount > 0, + byteCount <= maximumByteCount, + byteCount.isMultiple(of: 4), + readableOffset.isMultiple(of: 8) else { + throw VMError.invalidConfiguration("invalid descriptor-backed submit_3d range") + } + let (end, overflow) = readableOffset.addingReportingOverflow(byteCount) + guard !overflow, end <= readableByteCount else { + throw VMError.invalidConfiguration("submit_3d range exceeds its descriptor chain") + } + + let authority = try ImmutableAuthority(byteCount: byteCount) + do { + var logicalOffset = 0 + var destinationOffset = 0 + for segment in segments where !segment.isDeviceWritable { + let (segmentEnd, segmentOverflow) = logicalOffset.addingReportingOverflow( + segment.length + ) + guard segment.length >= 0, !segmentOverflow else { + throw VMError.invalidConfiguration("invalid submit_3d descriptor length") + } + defer { logicalOffset = segmentEnd } + guard segmentEnd > readableOffset, + logicalOffset < end else { continue } + let sourceStart = max(readableOffset, logicalOffset) + let sourceEnd = min(end, segmentEnd) + let take = sourceEnd - sourceStart + guard take > 0 else { continue } + authority.mapping.advanced(by: destinationOffset).copyMemory( + from: segment.pointer.advanced(by: sourceStart - logicalOffset), + byteCount: take + ) + destinationOffset += take + } + guard destinationOffset == byteCount else { + throw VMError.invalidConfiguration("incomplete submit_3d descriptor snapshot") + } + let descriptor = try authority.finish() + return Self( + references: [try DoryRendererSharedRegionReference( + identity: .random(), + descriptorIndex: 0, + access: .readOnly, + offset: 0, + length: UInt64(byteCount), + declaredFileSize: UInt64(byteCount) + )], + descriptors: [descriptor] + ) + } catch { + authority.abort() + throw error + } + } +} + +private final class ImmutableAuthority { + let mapping: UnsafeMutableRawPointer + + private let writableDescriptor: Int32 + private let readOnlyDescriptor: Int32 + private let byteCount: Int + private var finished = false + + init(byteCount: Int) throws { + let templateURL = URL( + fileURLWithPath: NSTemporaryDirectory(), + isDirectory: true + ).appendingPathComponent("dory-renderer-command.XXXXXX") + var template = templateURL.path.utf8CString + let writable = template.withUnsafeMutableBufferPointer { buffer in + mkstemp(buffer.baseAddress!) + } + guard writable >= 0 else { + throw VMError.outOfMemory("cannot create renderer command authority: errno \(errno)") + } + var readOnly: Int32 = -1 + var mapped: UnsafeMutableRawPointer? + do { + let writableFlags = fcntl(writable, F_GETFD) + guard writableFlags >= 0, + fcntl(writable, F_SETFD, writableFlags | FD_CLOEXEC) == 0, + ftruncate(writable, off_t(byteCount)) == 0 else { + throw VMError.outOfMemory( + "cannot size renderer command authority: errno \(errno)" + ) + } + readOnly = template.withUnsafeBufferPointer { buffer in + open(buffer.baseAddress!, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + } + guard readOnly >= 0 else { + throw VMError.outOfMemory( + "cannot seal renderer command authority: errno \(errno)" + ) + } + let unlinkResult = template.withUnsafeBufferPointer { buffer in + unlink(buffer.baseAddress!) + } + guard unlinkResult == 0 else { + throw VMError.outOfMemory( + "cannot unlink renderer command authority: errno \(errno)" + ) + } + mapped = mmap( + nil, + byteCount, + PROT_READ | PROT_WRITE, + MAP_SHARED, + writable, + 0 + ) + guard mapped != MAP_FAILED, mapped != nil else { + throw VMError.outOfMemory( + "cannot map renderer command authority: errno \(errno)" + ) + } + } catch { + if mapped != nil, mapped != MAP_FAILED { munmap(mapped, byteCount) } + if readOnly >= 0 { close(readOnly) } + close(writable) + _ = template.withUnsafeBufferPointer { buffer in + unlink(buffer.baseAddress!) + } + throw error + } + self.mapping = mapped! + self.writableDescriptor = writable + self.readOnlyDescriptor = readOnly + self.byteCount = byteCount + } + + func finish() throws -> FileHandle { + guard !finished, + mprotect(mapping, byteCount, PROT_READ) == 0 else { + throw VMError.invalidConfiguration("cannot seal renderer command mapping") + } + munmap(mapping, byteCount) + close(writableDescriptor) + finished = true + return FileHandle(fileDescriptor: readOnlyDescriptor, closeOnDealloc: true) + } + + func abort() { + guard !finished else { return } + munmap(mapping, byteCount) + close(writableDescriptor) + close(readOnlyDescriptor) + finished = true + } + + deinit { abort() } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerVirtioCommandLane.swift b/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerVirtioCommandLane.swift new file mode 100644 index 00000000..a8bb775e --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerVirtioCommandLane.swift @@ -0,0 +1,1886 @@ +import Darwin +import DoryRendererWorkerContracts +import Foundation + +enum DoryRendererWorkerVirtioCommandLaneState: Equatable, Sendable { + case active(deviceGeneration: UInt64) + case revoked(deviceGeneration: UInt64) + case failed(deviceGeneration: UInt64) +} + +enum DoryRendererWorkerVirtioCommandLaneError: Error, Equatable, Sendable { + case notActive(DoryRendererWorkerVirtioCommandLaneState) + case staleDeviceGeneration(expected: UInt64, actual: UInt64) + case commandQueueFull(limit: Int) + case referencedBytesLimit(limit: UInt64, requested: UInt64) + case invalidSubmitRegions + case inputDescriptorDuplicationFailed(index: Int) + case localBlobTeardownRejected + case duplicateFenceID(UInt64) + case unexpectedWorkerReply + case broker(DoryRendererWorkerBrokerError) + + /// True only when this result proves the submit did not mutate renderer state. Callers may + /// publish a guest error for these cases; every other failure must retain/revoke the chain. + var provesNoRendererMutation: Bool { + switch self { + case .commandQueueFull, + .referencedBytesLimit, + .invalidSubmitRegions, + .inputDescriptorDuplicationFailed, + .localBlobTeardownRejected, + .duplicateFenceID: + true + case .broker(let error): + switch error { + case .workerRejected(.deadlineExpired), + .workerRejected(.resourceExhausted), + .workerRejected(.commandRejected), + .inFlightLimit, + .aggregateReferencedBytesLimit, + .deadlineExpired, + .deadlineTooDistant, + .inputDescriptorCountMismatch, + .invalidInputDescriptor, + .invalidCommand: + true + default: + false + } + case .notActive, + .staleDeviceGeneration, + .unexpectedWorkerReply: + false + } + } +} + +struct DoryRendererWorkerVirtioCommandLaneSnapshot: Equatable, Sendable { + let state: DoryRendererWorkerVirtioCommandLaneState + let queuedCommands: Int + let maximumObservedQueuedCommands: Int + let queuedReferencedBytes: UInt64 + let rejectedAdmissions: UInt64 + let completedControlCommands: UInt64 + let completedResourceCommands: UInt64 + let completedSubmissions: UInt64 + let armedFences: Int + let completedFences: UInt64 + let liveScanoutLeases: Int + let acquiredScanoutLeases: UInt64 + let releasedScanoutLeases: UInt64 +} + +enum DoryRendererWorkerVirtioSubmissionDisposition: Equatable, Sendable { + /// The submit was acknowledged and its fence descriptor is armed. Guest completion + /// still belongs exclusively to the later fence-sink edge. + case fenceArmed + /// The worker proved that it rejected the submit before a renderer-visible mutation. + case provenRejected(DoryRendererWorkerVirtioCommandLaneError) + /// The submit crossed, or may have crossed, its mutation boundary without a usable fence. + case outcomeUnknown(DoryRendererWorkerVirtioCommandLaneError) +} + +enum DoryRendererWorkerScanoutAuthority: @unchecked Sendable { + case sharedMemory(DoryRendererWorkerScanout) + case sharedTexture(DoryRendererWorkerSharedTextureScanout) + + var workerGeneration: DoryRendererWorkerGeneration { + switch self { + case .sharedMemory(let value): value.lease.workerGeneration + case .sharedTexture(let value): value.lease.workerGeneration + } + } + + var resourceID: UInt32 { + switch self { + case .sharedMemory(let value): value.lease.resourceID + case .sharedTexture(let value): value.lease.resourceID + } + } + + var resourceGeneration: UInt64 { + switch self { + case .sharedMemory(let value): value.lease.resourceGeneration + case .sharedTexture(let value): value.lease.resourceGeneration + } + } + + var leaseID: DoryRendererScanoutLeaseID { + switch self { + case .sharedMemory(let value): value.lease.leaseID + case .sharedTexture(let value): value.lease.leaseID + } + } + + var releaseToken: DoryRendererScanoutReleaseToken { + switch self { + case .sharedMemory(let value): value.lease.releaseToken + case .sharedTexture(let value): value.lease.releaseToken + } + } + + var pixelFormat: DoryRendererScanoutPixelFormat { + switch self { + case .sharedMemory(let value): value.lease.pixelFormat + case .sharedTexture(let value): value.lease.pixelFormat + } + } + + var width: UInt32 { + switch self { + case .sharedMemory(let value): value.lease.width + case .sharedTexture(let value): value.lease.width + } + } + + var height: UInt32 { + switch self { + case .sharedMemory(let value): value.lease.height + case .sharedTexture(let value): value.lease.height + } + } + + func discardTransport() { + if case .sharedMemory(let value) = self { + try? value.sharedMemoryDescriptor.close() + } + } +} + +enum DoryRendererWorkerScanoutDisposition: @unchecked Sendable { + case acquired(DoryRendererWorkerScanoutAuthority) + /// The worker proved it rejected acquisition before creating a live lease. + case provenRejected(DoryRendererWorkerVirtioCommandLaneError) + /// A live lease may exist and the entire generation was revoked. + case outcomeUnknown(DoryRendererWorkerVirtioCommandLaneError) +} + +/// Ordered asynchronous virtio-gpu command/fence seam for one authenticated worker generation. +/// +/// The vCPU-facing caller performs only bounded local admission. Submit dwords stay in an +/// immutable descriptor-backed authority and commands enter one ordered task chain; no caller +/// waits synchronously for XPC or GPU completion. A worker fence descriptor is armed separately +/// and is the only authority that may publish a guest fence completion. Reset, helper death, or an +/// uncertain result revokes the complete device generation and cancels every outstanding fence. +/// +/// `VirtioGPU` selects this lane only from the authenticated worker bootstrap receipt; there is no +/// in-process fallback once a worker-backed generation has been admitted. +public final class DoryRendererWorkerVirtioCommandLane: @unchecked Sendable { + typealias Completion = @Sendable ( + Result + ) -> Void + typealias ResourceCreationCompletion = @Sendable ( + Result + ) -> Void + typealias BlobMappingCompletion = @Sendable ( + Result + ) -> Void + typealias ScanoutCompletion = @Sendable ( + DoryRendererWorkerScanoutDisposition + ) -> Void + /// Runs on the serialized command lane after admission and every predecessor, but before the + /// worker sees UNMAP_BLOB. Returning false proves that no worker command was sent. + typealias BeforeBlobUnmap = @Sendable () -> Bool + typealias FenceSink = @Sendable ( + _ deviceGeneration: UInt64, + _ contextID: UInt32, + _ ringIndex: UInt32, + _ fenceID: UInt64 + ) -> Void + typealias RuntimeFailureSink = @Sendable ( + _ deviceGeneration: UInt64, + _ error: DoryRendererWorkerVirtioCommandLaneError + ) -> Void + + private final class ArmedFence { + let source: DispatchSourceRead + + init(source: DispatchSourceRead) { + self.source = source + } + + func cancel() { source.cancel() } + } + + private struct FenceKey: Hashable { + let workerGeneration: UInt64 + let fenceID: UInt64 + } + + private struct LiveScanoutLease: Equatable, Sendable { + let leaseID: DoryRendererScanoutLeaseID + let resourceID: UInt32 + let resourceGeneration: UInt64 + } + + let capsets: [VirtioGPUCapset] + let workerGeneration: DoryRendererWorkerGeneration + /// Exact authenticated worker admission bound. VirtioGPU uses this instead of a duplicated + /// device-side literal, so a candidate can never admit more regions than its worker accepts. + let maximumSharedRegions: Int + /// Exact authenticated aggregate referenced-byte authority used for target-aware resource + /// admission before a mutating worker command crosses XPC. + let maximumReferencedBytes: UInt64 + + private let broker: DoryRendererWorkerBroker + private let maximumQueuedCommands: Int + private let maximumQueuedReferencedBytes: UInt64 + private let commandDeadlineNanoseconds: UInt64 + private let fenceQueue = DispatchQueue( + label: "dev.dory.renderer-worker.fence-completion", + qos: .userInteractive + ) + private let lock = NSLock() + private var state: DoryRendererWorkerVirtioCommandLaneState + private var commandTail: Task? + private var queuedCommands = 0 + private var maximumObservedQueuedCommands = 0 + private var queuedReferencedBytes: UInt64 = 0 + private var rejectedAdmissions: UInt64 = 0 + private var completedControlCommands: UInt64 = 0 + private var completedResourceCommands: UInt64 = 0 + private var completedSubmissions: UInt64 = 0 + private var completedFences: UInt64 = 0 + private var acquiredScanoutLeases: UInt64 = 0 + private var releasedScanoutLeases: UInt64 = 0 + private var reservedFenceIDs = Set() + private var armedFences = [FenceKey: ArmedFence]() + private var liveScanoutLeases = [DoryRendererScanoutReleaseToken: LiveScanoutLease]() + private var releasingScanoutTokens = Set() + private var fenceSink: FenceSink? + private var runtimeFailureSink: RuntimeFailureSink? + + public init( + broker: DoryRendererWorkerBroker, + deviceGeneration: UInt64, + maximumQueuedCommands: Int? = nil, + maximumQueuedReferencedBytes: UInt64? = nil, + commandDeadlineNanoseconds: UInt64 = 5_000_000_000 + ) throws { + guard deviceGeneration != 0, + broker.capabilityReceipt.productionAccelerationIsAdmissible else { + throw DoryRendererWorkerVirtioCommandLaneError.invalidSubmitRegions + } + let limits = broker.bootstrap.limits + let queueLimit = maximumQueuedCommands ?? limits.maximumInFlightCommands + let byteLimit = maximumQueuedReferencedBytes ?? limits.maximumReferencedBytes + guard queueLimit > 0, byteLimit > 0, + commandDeadlineNanoseconds > 0, + commandDeadlineNanoseconds + <= DoryRendererWorkerBroker.maximumAdmissionDeadlineNanoseconds else { + throw DoryRendererWorkerVirtioCommandLaneError.invalidSubmitRegions + } + self.broker = broker + self.workerGeneration = broker.bootstrap.generation + self.maximumSharedRegions = limits.maximumSharedRegions + self.maximumReferencedBytes = limits.maximumReferencedBytes + self.capsets = broker.capabilityReceipt.capsets.map { + VirtioGPUCapset( + id: $0.id, + maxVersion: $0.maximumVersion, + data: Array($0.data) + ) + } + self.maximumQueuedCommands = min(queueLimit, limits.maximumInFlightCommands) + self.maximumQueuedReferencedBytes = min(byteLimit, limits.maximumReferencedBytes) + self.commandDeadlineNanoseconds = commandDeadlineNanoseconds + self.state = .active(deviceGeneration: deviceGeneration) + broker.installTerminalHandler { [weak self] _, error in + self?.brokerTerminated(error) + } + } + + func installCallbacks( + fence: @escaping FenceSink, + runtimeFailure: @escaping RuntimeFailureSink + ) { + lock.withLock { + fenceSink = fence + runtimeFailureSink = runtimeFailure + } + } + + /// Exact authenticated source for GET_CAPSET_INFO/GET_CAPSET at the eventual atomic cutover. + /// No second renderer query, cache, environment setting, or legacy object participates. + func capset(id: UInt32, version: UInt32) -> VirtioGPUCapset? { + capsets.first { $0.id == id && version <= $0.maxVersion } + } + + func createContext( + contextID: UInt32, + capsetID: UInt32, + name: String, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard contextID != 0 else { throw reject(.invalidSubmitRegions) } + let payload: DoryRendererContextCreatePayload + do { + payload = try DoryRendererContextCreatePayload( + capsetID: capsetID, + name: name + ) + } catch { + throw reject(.invalidSubmitRegions) + } + try enqueueAcknowledgedControl( + operation: .createContext, + contextID: contextID, + payload: payload.encoded, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + func createResource3D( + resourceID: UInt32, + payload: DoryRendererResource3DCreatePayload, + deviceGeneration: UInt64, + completion: @escaping ResourceCreationCompletion + ) throws { + guard resourceID != 0 else { throw reject(.invalidSubmitRegions) } + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: 0, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + completion(.failure(.unexpectedWorkerReply)) + return + } + defer { self.finishAdmission(referencedBytes: 0) } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let result = try await self.broker.execute( + operation: .createResource3D, + resourceID: resourceID, + payload: payload.encoded, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .resourceCreated(let resourceGeneration) = result, + resourceGeneration != 0 else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + self.lock.withLock { + self.completedResourceCommands = Self.saturatingAdd( + self.completedResourceCommands, + 1 + ) + } + completion(.success(resourceGeneration)) + } catch let error as DoryRendererWorkerBrokerError { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.failure(.broker(error))) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } + + func destroyContext( + contextID: UInt32, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard contextID != 0 else { throw reject(.invalidSubmitRegions) } + try enqueueAcknowledgedControl( + operation: .destroyContext, + contextID: contextID, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + func attachResource( + contextID: UInt32, + resourceID: UInt32, + resourceGeneration: UInt64, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard contextID != 0, resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try enqueueAcknowledgedControl( + operation: .attachResource, + contextID: contextID, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + func detachResource( + contextID: UInt32, + resourceID: UInt32, + resourceGeneration: UInt64, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard contextID != 0, resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try enqueueAcknowledgedControl( + operation: .detachResource, + contextID: contextID, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + func attachBacking( + resourceID: UInt32, + resourceGeneration: UInt64, + regions: DoryRendererWorkerSharedRegionSet, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard resourceID != 0, + resourceGeneration != 0, + !regions.references.isEmpty, + !regions.descriptors.isEmpty, + regions.references.allSatisfy({ $0.access == .readWrite }) else { + throw reject(.invalidSubmitRegions) + } + var referencedBytes: UInt64 = 0 + for region in regions.references { + let (sum, overflow) = referencedBytes.addingReportingOverflow(region.length) + guard !overflow else { throw reject(.invalidSubmitRegions) } + referencedBytes = sum + } + let admittedReferencedBytes = referencedBytes + let ownedDescriptors: [FileHandle] + do { + ownedDescriptors = try Self.duplicate(regions.descriptors) + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + throw reject(error) + } + do { + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: admittedReferencedBytes, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + Self.close(ownedDescriptors) + completion(.failure(.unexpectedWorkerReply)) + return + } + defer { + Self.close(ownedDescriptors) + self.finishAdmission(referencedBytes: admittedReferencedBytes) + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let result = try await self.broker.execute( + operation: .attachBacking, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + sharedRegions: regions.references, + descriptors: ownedDescriptors, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .acknowledged = result else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + self.lock.withLock { + self.completedResourceCommands = Self.saturatingAdd( + self.completedResourceCommands, + 1 + ) + } + completion(.success(())) + } catch let error as DoryRendererWorkerBrokerError { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.failure(.broker(error))) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } catch { + Self.close(ownedDescriptors) + throw error + } + } + + func detachBacking( + resourceID: UInt32, + resourceGeneration: UInt64, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try enqueueAcknowledgedControl( + operation: .detachBacking, + contextID: 0, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deviceGeneration: deviceGeneration, + countsAsResourceCommand: true, + completion: completion + ) + } + + func transferToHost3D( + resourceID: UInt32, + resourceGeneration: UInt64, + contextID: UInt32, + payload: DoryRendererTransfer3DPayload, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try transfer3D( + operation: .transferToHost3D, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + contextID: contextID, + payload: payload, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + func transferFromHost3D( + resourceID: UInt32, + resourceGeneration: UInt64, + contextID: UInt32, + payload: DoryRendererTransfer3DPayload, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try transfer3D( + operation: .transferFromHost3D, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + contextID: contextID, + payload: payload, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + private func transfer3D( + operation: DoryRendererWorkerOperation, + resourceID: UInt32, + resourceGeneration: UInt64, + contextID: UInt32, + payload: DoryRendererTransfer3DPayload, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + precondition(operation == .transferToHost3D || operation == .transferFromHost3D) + try enqueueAcknowledgedControl( + operation: operation, + contextID: contextID, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + payload: payload.encoded, + deviceGeneration: deviceGeneration, + countsAsResourceCommand: true, + completion: completion + ) + } + + func unrefResource( + resourceID: UInt32, + resourceGeneration: UInt64, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try enqueueAcknowledgedControl( + operation: .unrefResource, + contextID: 0, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deviceGeneration: deviceGeneration, + countsAsResourceCommand: true, + completion: completion + ) + } + + func createBlob( + resourceID: UInt32, + contextID: UInt32, + payload: DoryRendererBlobCreatePayload, + regions: DoryRendererWorkerSharedRegionSet, + deviceGeneration: UInt64, + completion: @escaping ResourceCreationCompletion + ) throws { + guard resourceID != 0, + regions.references.allSatisfy({ $0.access == .readWrite }), + regions.references.isEmpty == regions.descriptors.isEmpty else { + throw reject(.invalidSubmitRegions) + } + var summedReferencedBytes: UInt64 = 0 + for region in regions.references { + let (sum, overflow) = summedReferencedBytes.addingReportingOverflow(region.length) + guard !overflow else { throw reject(.invalidSubmitRegions) } + summedReferencedBytes = sum + } + let referencedBytes = summedReferencedBytes + let ownedDescriptors: [FileHandle] + do { + ownedDescriptors = try Self.duplicate(regions.descriptors) + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + throw reject(error) + } + do { + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: referencedBytes, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + Self.close(ownedDescriptors) + completion(.failure(.unexpectedWorkerReply)) + return + } + defer { + Self.close(ownedDescriptors) + self.finishAdmission(referencedBytes: referencedBytes) + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let result = try await self.broker.execute( + operation: .createBlob, + contextID: contextID, + resourceID: resourceID, + sharedRegions: regions.references, + descriptors: ownedDescriptors, + payload: payload.encoded, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .resourceCreated(let resourceGeneration) = result, + resourceGeneration != 0 else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + self.lock.withLock { + self.completedResourceCommands = Self.saturatingAdd( + self.completedResourceCommands, + 1 + ) + } + completion(.success(resourceGeneration)) + } catch let error as DoryRendererWorkerBrokerError { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.failure(.broker(error))) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } catch { + Self.close(ownedDescriptors) + throw error + } + } + + func mapBlob( + resourceID: UInt32, + resourceGeneration: UInt64, + deviceGeneration: UInt64, + completion: @escaping BlobMappingCompletion + ) throws { + guard resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: 0, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + completion(.failure(.unexpectedWorkerReply)) + return + } + defer { self.finishAdmission(referencedBytes: 0) } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let result = try await self.broker.execute( + operation: .mapBlob, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .blobMapping(let mapping) = result else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + try? mapping.sharedMemoryDescriptor.close() + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + self.lock.withLock { + self.completedResourceCommands = Self.saturatingAdd( + self.completedResourceCommands, + 1 + ) + } + completion(.success(mapping)) + } catch let error as DoryRendererWorkerBrokerError { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.failure(.broker(error))) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } + + /// Enforces the cross-process blob lifetime order in one serialized authority: + /// + /// 1. every prior worker command completes; + /// 2. the VMM removes the guest HV mapping, munmaps its SHM view, and closes its descriptor; + /// 3. only then may the worker release its renderer-side mapping. + /// + /// Admission failure never invokes `beforeWorkerUnmap`, so the caller may safely retain its + /// local mapping and reject the guest command. Any failure after the closure ran must be treated + /// as an uncertain mapping outcome by the caller, even when the worker proves it did not mutate. + func unmapBlob( + resourceID: UInt32, + resourceGeneration: UInt64, + deviceGeneration: UInt64, + beforeWorkerUnmap: @escaping BeforeBlobUnmap, + completion: @escaping Completion + ) throws { + guard resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: 0, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + completion(.failure(.unexpectedWorkerReply)) + return + } + defer { self.finishAdmission(referencedBytes: 0) } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + guard beforeWorkerUnmap() else { + completion(.failure(.localBlobTeardownRejected)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let result = try await self.broker.execute( + operation: .unmapBlob, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .acknowledged = result else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + self.lock.withLock { + self.completedResourceCommands = Self.saturatingAdd( + self.completedResourceCommands, + 1 + ) + } + completion(.success(())) + } catch let error as DoryRendererWorkerBrokerError { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.failure(.broker(error))) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } + + /// Acquires one SHM scanout lease after the authenticated managed guest's KMS producer wait + /// and RESOURCE_FLUSH boundary. No duplicate renderer fence or per-frame wait is introduced. + func acquireScanoutLease( + resourceID: UInt32, + resourceGeneration: UInt64, + width: UInt32, + height: UInt32, + virglFormat: UInt32, + stride: UInt32, + storageOffset: UInt32, + deviceGeneration: UInt64, + completion: @escaping ScanoutCompletion + ) throws { + guard resourceID != 0, resourceGeneration != 0 else { + throw reject(.invalidSubmitRegions) + } + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: 0, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + completion(.outcomeUnknown(.unexpectedWorkerReply)) + return + } + defer { self.finishAdmission(referencedBytes: 0) } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.outcomeUnknown(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let acquirePayload = try DoryRendererScanoutAcquirePayload( + width: width, + height: height, + virglFormat: virglFormat, + stride: stride, + storageOffset: storageOffset + ) + let acquireResult = try await self.broker.execute( + operation: .acquireScanoutLease, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + payload: acquirePayload.encoded, + deadlineUptimeNanoseconds: self.deadline() + ) + let scanout: DoryRendererWorkerScanoutAuthority + switch acquireResult { + case .scanout(let value): + scanout = .sharedMemory(value) + case .sharedTextureScanout(let value): + scanout = .sharedTexture(value) + default: + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.outcomeUnknown(.unexpectedWorkerReply)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + scanout.discardTransport() + completion(.outcomeUnknown(self.inactiveError(actual: deviceGeneration))) + return + } + let accepted = self.lock.withLock { () -> Bool in + guard case .active(let activeGeneration) = self.state, + activeGeneration == deviceGeneration, + self.liveScanoutLeases[scanout.releaseToken] == nil else { + return false + } + self.liveScanoutLeases[scanout.releaseToken] = LiveScanoutLease( + leaseID: scanout.leaseID, + resourceID: resourceID, + resourceGeneration: resourceGeneration + ) + self.acquiredScanoutLeases = Self.saturatingAdd( + self.acquiredScanoutLeases, + 1 + ) + self.completedResourceCommands = Self.saturatingAdd( + self.completedResourceCommands, + 1 + ) + return true + } + guard accepted else { + scanout.discardTransport() + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.outcomeUnknown(.unexpectedWorkerReply)) + return + } + completion(.acquired(scanout)) + } catch let error as DoryRendererWorkerBrokerError { + let laneError = DoryRendererWorkerVirtioCommandLaneError.broker(error) + if laneError.provesNoRendererMutation { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.provenRejected(laneError)) + } else { + self.failGeneration(deviceGeneration: deviceGeneration, error: laneError) + completion(.outcomeUnknown(laneError)) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + self.failGeneration(deviceGeneration: deviceGeneration, error: error) + completion(.outcomeUnknown(error)) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.outcomeUnknown(.unexpectedWorkerReply)) + } + } + } + + /// Releases one exact live token. Replay and mismatched resource generations fail before XPC. + func releaseScanoutLease( + _ lease: DoryRendererScanoutLease, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + try releaseScanoutLease( + workerGeneration: lease.workerGeneration, + resourceID: lease.resourceID, + resourceGeneration: lease.resourceGeneration, + leaseID: lease.leaseID, + releaseToken: lease.releaseToken, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + func releaseScanoutLease( + _ lease: DoryRendererSharedTextureScanoutLease, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + try releaseScanoutLease( + workerGeneration: lease.workerGeneration, + resourceID: lease.resourceID, + resourceGeneration: lease.resourceGeneration, + leaseID: lease.leaseID, + releaseToken: lease.releaseToken, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + private func releaseScanoutLease( + workerGeneration leaseWorkerGeneration: DoryRendererWorkerGeneration, + resourceID: UInt32, + resourceGeneration: UInt64, + leaseID: DoryRendererScanoutLeaseID, + releaseToken: DoryRendererScanoutReleaseToken, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + try lock.withLock { + guard case .active(let activeGeneration) = state else { + throw rejectWhileLocked(.notActive(state)) + } + guard activeGeneration == deviceGeneration else { + throw rejectWhileLocked(.staleDeviceGeneration( + expected: activeGeneration, + actual: deviceGeneration + )) + } + guard leaseWorkerGeneration == workerGeneration, + liveScanoutLeases[releaseToken] == LiveScanoutLease( + leaseID: leaseID, + resourceID: resourceID, + resourceGeneration: resourceGeneration + ), + releasingScanoutTokens.insert(releaseToken).inserted else { + throw rejectWhileLocked(.invalidSubmitRegions) + } + } + do { + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: 0, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + completion(.failure(.unexpectedWorkerReply)) + return + } + defer { self.finishAdmission(referencedBytes: 0) } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let result = try await self.broker.execute( + operation: .releaseScanoutLease, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + payload: releaseToken.commandPayload, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .acknowledged = result else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + let removed = self.lock.withLock { () -> Bool in + self.releasingScanoutTokens.remove(releaseToken) + guard self.liveScanoutLeases.removeValue( + forKey: releaseToken + ) != nil else { return false } + self.releasedScanoutLeases = Self.saturatingAdd( + self.releasedScanoutLeases, + 1 + ) + self.completedResourceCommands = Self.saturatingAdd( + self.completedResourceCommands, + 1 + ) + return true + } + guard removed else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + completion(.success(())) + } catch let error as DoryRendererWorkerBrokerError { + let laneError = DoryRendererWorkerVirtioCommandLaneError.broker(error) + if laneError.provesNoRendererMutation { + _ = self.lock.withLock { + self.releasingScanoutTokens.remove(releaseToken) + } + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + } else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: laneError + ) + } + completion(.failure(laneError)) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } catch { + _ = lock.withLock { releasingScanoutTokens.remove(releaseToken) } + throw error + } + } + + func submit3D( + contextID: UInt32, + regions: DoryRendererWorkerSharedRegionSet, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard contextID != 0, + regions.references.count == 1, + regions.descriptors.count == 1, + regions.references[0].access == .readOnly, + regions.references[0].length > 0, + regions.references[0].length.isMultiple(of: 4) else { + throw reject(.invalidSubmitRegions) + } + let ownedDescriptors: [FileHandle] + do { + ownedDescriptors = try Self.duplicate(regions.descriptors) + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + throw reject(error) + } + let referencedBytes = regions.references[0].length + do { + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: referencedBytes, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + Self.close(ownedDescriptors) + return + } + defer { + Self.close(ownedDescriptors) + self.finishAdmission(referencedBytes: referencedBytes) + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let result = try await self.broker.execute( + operation: .submit3D, + contextID: contextID, + sharedRegions: regions.references, + descriptors: ownedDescriptors, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .acknowledged = result else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + self.lock.withLock { + self.completedSubmissions = Self.saturatingAdd( + self.completedSubmissions, + 1 + ) + } + completion(.success(())) + } catch let error as DoryRendererWorkerBrokerError { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.failure(.broker(error))) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } catch { + Self.close(ownedDescriptors) + throw error + } + } + + /// Atomically admits one descriptor-backed submit and its fence registration into the + /// lane's ordered queue. Treating this as one local admission is essential: once the worker + /// acknowledges the submit, backpressure must not prevent the completion boundary itself from + /// being created. Any failure after that acknowledgement is outcome-unknown and revokes the + /// complete worker/device generation. + func submit3DThenCreateFence( + contextID: UInt32, + regions: DoryRendererWorkerSharedRegionSet, + ringIndex: UInt32, + fenceID: UInt64, + contextFence: Bool, + deviceGeneration: UInt64, + completion: @escaping @Sendable ( + DoryRendererWorkerVirtioSubmissionDisposition + ) -> Void + ) throws { + guard contextID != 0, + fenceID != 0, + (contextFence + ? ringIndex <= DoryRendererFencePayload.maximumRingIndex + : ringIndex == 0), + regions.references.count == 1, + regions.descriptors.count == 1, + regions.references[0].access == .readOnly, + regions.references[0].length > 0, + regions.references[0].length.isMultiple(of: 4) else { + throw reject(.invalidSubmitRegions) + } + let ownedDescriptors: [FileHandle] + do { + ownedDescriptors = try Self.duplicate(regions.descriptors) + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + throw reject(error) + } + let referencedBytes = regions.references[0].length + do { + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: referencedBytes, + reservingFenceID: fenceID + ) { [weak self] in + guard let self else { + Self.close(ownedDescriptors) + completion(.outcomeUnknown(.unexpectedWorkerReply)) + return + } + defer { + Self.close(ownedDescriptors) + self.finishAdmission(referencedBytes: referencedBytes) + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + self.releaseFenceReservation(fenceID) + completion(.outcomeUnknown( + self.inactiveError(actual: deviceGeneration) + )) + return + } + + var submitWasAcknowledged = false + do { + let submit = try await self.broker.execute( + operation: .submit3D, + contextID: contextID, + sharedRegions: regions.references, + descriptors: ownedDescriptors, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .acknowledged = submit else { + self.releaseFenceReservation(fenceID) + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.outcomeUnknown(.unexpectedWorkerReply)) + return + } + submitWasAcknowledged = true + self.lock.withLock { + self.completedSubmissions = Self.saturatingAdd( + self.completedSubmissions, + 1 + ) + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + self.releaseFenceReservation(fenceID) + completion(.outcomeUnknown( + self.inactiveError(actual: deviceGeneration) + )) + return + } + + let payload = try DoryRendererFencePayload( + flags: contextFence ? DoryRendererFencePayload.contextTimeline : 0, + ringIndex: contextFence ? ringIndex : 0, + fenceID: fenceID + ) + let fence = try await self.broker.execute( + operation: .createFence, + contextID: contextID, + payload: payload.encoded, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .fence(let receipt) = fence, + receipt.workerGeneration == self.workerGeneration, + receipt.contextID == contextID, + receipt.flags == payload.flags, + receipt.ringIndex == payload.ringIndex, + receipt.fenceID == fenceID else { + self.releaseFenceReservation(fenceID) + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.outcomeUnknown(.unexpectedWorkerReply)) + return + } + try self.arm( + receipt: receipt, + deviceGeneration: deviceGeneration, + completionContextID: contextFence ? contextID : 0, + completionRingIndex: contextFence ? ringIndex : 0 + ) + completion(.fenceArmed) + } catch let error as DoryRendererWorkerBrokerError { + self.releaseFenceReservation(fenceID) + let laneError = DoryRendererWorkerVirtioCommandLaneError.broker(error) + if !submitWasAcknowledged, laneError.provesNoRendererMutation { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.provenRejected(laneError)) + } else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: laneError + ) + completion(.outcomeUnknown(laneError)) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + self.releaseFenceReservation(fenceID) + self.failGeneration(deviceGeneration: deviceGeneration, error: error) + completion(.outcomeUnknown(error)) + } catch { + self.releaseFenceReservation(fenceID) + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.outcomeUnknown(.unexpectedWorkerReply)) + } + } + } catch { + Self.close(ownedDescriptors) + throw error + } + } + + /// Enqueues a global fence behind all prior renderer mutations. Completion reports that the + /// worker returned a validated descriptor; `fenceSink` fires only when that descriptor signals. + func createGlobalFence( + fenceID: UInt64, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard fenceID != 0 else { throw reject(.invalidSubmitRegions) } + try createFence( + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + /// Enqueues a context fence behind all prior renderer mutations. Completion reports that the + /// worker returned a validated descriptor; `fenceSink` fires only when that descriptor signals. + func createContextFence( + contextID: UInt32, + ringIndex: UInt32, + fenceID: UInt64, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + guard contextID != 0, + fenceID != 0, + ringIndex <= DoryRendererFencePayload.maximumRingIndex else { + throw reject(.invalidSubmitRegions) + } + try createFence( + contextID: contextID, + ringIndex: ringIndex, + fenceID: fenceID, + contextFence: true, + deviceGeneration: deviceGeneration, + completion: completion + ) + } + + private func createFence( + contextID: UInt32, + ringIndex: UInt32, + fenceID: UInt64, + contextFence: Bool, + deviceGeneration: UInt64, + completion: @escaping Completion + ) throws { + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: 0, + reservingFenceID: fenceID + ) { [weak self] in + guard let self else { return } + defer { self.finishAdmission(referencedBytes: 0) } + guard self.isActive(deviceGeneration: deviceGeneration) else { + self.releaseFenceReservation(fenceID) + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let payload = try DoryRendererFencePayload( + flags: contextFence ? DoryRendererFencePayload.contextTimeline : 0, + ringIndex: contextFence ? ringIndex : 0, + fenceID: fenceID + ) + let result = try await self.broker.execute( + operation: .createFence, + contextID: contextID, + payload: payload.encoded, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .fence(let receipt) = result, + receipt.workerGeneration == self.workerGeneration, + receipt.contextID == contextID, + receipt.flags == payload.flags, + receipt.ringIndex == payload.ringIndex, + receipt.fenceID == fenceID else { + self.releaseFenceReservation(fenceID) + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + try self.arm( + receipt: receipt, + deviceGeneration: deviceGeneration, + completionContextID: contextFence ? contextID : 0, + completionRingIndex: contextFence ? ringIndex : 0 + ) + completion(.success(())) + } catch let error as DoryRendererWorkerBrokerError { + self.releaseFenceReservation(fenceID) + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.failure(.broker(error))) + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + self.releaseFenceReservation(fenceID) + self.failGeneration(deviceGeneration: deviceGeneration, error: error) + completion(.failure(error)) + } catch { + self.releaseFenceReservation(fenceID) + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } + + func revoke(deviceGeneration: UInt64) { + let cancellation: [ArmedFence] = lock.withLock { + guard case .active(let activeGeneration) = state, + activeGeneration == deviceGeneration else { return [] } + state = .revoked(deviceGeneration: activeGeneration) + reservedFenceIDs.removeAll(keepingCapacity: false) + liveScanoutLeases.removeAll(keepingCapacity: false) + releasingScanoutTokens.removeAll(keepingCapacity: false) + let fences = Array(armedFences.values) + armedFences.removeAll(keepingCapacity: false) + return fences + } + for fence in cancellation { fence.cancel() } + Task { await broker.invalidate() } + } + + /// Moves an authenticated but completely unused worker lane onto the transport generation + /// created by an initial virtio device reset. Linux writes Status=0 while probing a device; + /// that protocol transition does not invalidate renderer state when no command, resource, + /// fence, or scanout lease has ever crossed the worker boundary. Once any admission has + /// occurred, reset must retain the normal fail-closed generation-replacement path. + func rebindPristineDeviceGeneration( + from sourceGeneration: UInt64, + to successorGeneration: UInt64 + ) -> Bool { + guard sourceGeneration != 0, + successorGeneration != 0, + sourceGeneration != successorGeneration else { return false } + return lock.withLock { + guard state == .active(deviceGeneration: sourceGeneration), + commandTail == nil, + queuedCommands == 0, + maximumObservedQueuedCommands == 0, + queuedReferencedBytes == 0, + rejectedAdmissions == 0, + completedControlCommands == 0, + completedResourceCommands == 0, + completedSubmissions == 0, + armedFences.isEmpty, + reservedFenceIDs.isEmpty, + completedFences == 0, + liveScanoutLeases.isEmpty, + releasingScanoutTokens.isEmpty, + acquiredScanoutLeases == 0, + releasedScanoutLeases == 0 else { return false } + state = .active(deviceGeneration: successorGeneration) + return true + } + } + + /// Runner-owned terminal cutover boundary. The launch owner is in the `dory-hv` executable + /// module, so it cannot call the lane's internal reset machinery directly. This wrapper keeps + /// the only public operation terminal and generation-bound; it does not expose command or + /// fence mutation APIs across the module boundary. + public func invalidate(deviceGeneration: UInt64) { + revoke(deviceGeneration: deviceGeneration) + } + + func snapshot() -> DoryRendererWorkerVirtioCommandLaneSnapshot { + lock.withLock { + DoryRendererWorkerVirtioCommandLaneSnapshot( + state: state, + queuedCommands: queuedCommands, + maximumObservedQueuedCommands: maximumObservedQueuedCommands, + queuedReferencedBytes: queuedReferencedBytes, + rejectedAdmissions: rejectedAdmissions, + completedControlCommands: completedControlCommands, + completedResourceCommands: completedResourceCommands, + completedSubmissions: completedSubmissions, + armedFences: armedFences.count, + completedFences: completedFences, + liveScanoutLeases: liveScanoutLeases.count, + acquiredScanoutLeases: acquiredScanoutLeases, + releasedScanoutLeases: releasedScanoutLeases + ) + } + } + + private func enqueue( + deviceGeneration: UInt64, + referencedBytes: UInt64, + reservingFenceID fenceID: UInt64?, + operation: @escaping @Sendable () async -> Void + ) throws { + try lock.withLock { + guard case .active(let activeGeneration) = state else { + throw rejectWhileLocked(.notActive(state)) + } + guard activeGeneration == deviceGeneration else { + throw rejectWhileLocked(.staleDeviceGeneration( + expected: activeGeneration, + actual: deviceGeneration + )) + } + guard queuedCommands < maximumQueuedCommands else { + throw rejectWhileLocked(.commandQueueFull(limit: maximumQueuedCommands)) + } + let (newBytes, overflow) = queuedReferencedBytes.addingReportingOverflow( + referencedBytes + ) + guard !overflow, newBytes <= maximumQueuedReferencedBytes else { + throw rejectWhileLocked(.referencedBytesLimit( + limit: maximumQueuedReferencedBytes, + requested: overflow ? UInt64.max : newBytes + )) + } + if let fenceID, !reservedFenceIDs.insert(fenceID).inserted { + throw rejectWhileLocked(.duplicateFenceID(fenceID)) + } + queuedCommands += 1 + maximumObservedQueuedCommands = max(maximumObservedQueuedCommands, queuedCommands) + queuedReferencedBytes = newBytes + let predecessor = commandTail + commandTail = Task { + if let predecessor { await predecessor.value } + await operation() + } + } + } + + private func enqueueAcknowledgedControl( + operation: DoryRendererWorkerOperation, + contextID: UInt32, + resourceID: UInt32 = 0, + resourceGeneration: UInt64 = 0, + payload: Data = Data(), + deviceGeneration: UInt64, + countsAsResourceCommand: Bool = false, + completion: @escaping Completion + ) throws { + try enqueue( + deviceGeneration: deviceGeneration, + referencedBytes: 0, + reservingFenceID: nil + ) { [weak self] in + guard let self else { + completion(.failure(.unexpectedWorkerReply)) + return + } + defer { self.finishAdmission(referencedBytes: 0) } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + do { + let result = try await self.broker.execute( + operation: operation, + contextID: contextID, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + payload: payload, + deadlineUptimeNanoseconds: self.deadline() + ) + guard case .acknowledged = result else { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + return + } + guard self.isActive(deviceGeneration: deviceGeneration) else { + completion(.failure(self.inactiveError(actual: deviceGeneration))) + return + } + self.lock.withLock { + if countsAsResourceCommand { + self.completedResourceCommands = Self.saturatingAdd( + self.completedResourceCommands, + 1 + ) + } else { + self.completedControlCommands = Self.saturatingAdd( + self.completedControlCommands, + 1 + ) + } + } + completion(.success(())) + } catch let error as DoryRendererWorkerBrokerError { + self.handleBrokerError(error, deviceGeneration: deviceGeneration) + completion(.failure(.broker(error))) + } catch { + self.failGeneration( + deviceGeneration: deviceGeneration, + error: .unexpectedWorkerReply + ) + completion(.failure(.unexpectedWorkerReply)) + } + } + } + + private func finishAdmission(referencedBytes: UInt64) { + lock.withLock { + queuedCommands = max(0, queuedCommands - 1) + queuedReferencedBytes = queuedReferencedBytes >= referencedBytes + ? queuedReferencedBytes - referencedBytes + : 0 + } + } + + private func arm( + receipt: DoryRendererWorkerFenceReceipt, + deviceGeneration: UInt64, + completionContextID: UInt32, + completionRingIndex: UInt32 + ) throws { + let sourceDescriptor = fcntl( + receipt.completionDescriptor.fileDescriptor, + F_DUPFD_CLOEXEC, + 0 + ) + try? receipt.completionDescriptor.close() + guard sourceDescriptor >= 0 else { + throw DoryRendererWorkerVirtioCommandLaneError + .inputDescriptorDuplicationFailed(index: 0) + } + let source = DispatchSource.makeReadSource( + fileDescriptor: sourceDescriptor, + queue: fenceQueue + ) + let key = FenceKey( + workerGeneration: workerGeneration.rawValue, + fenceID: receipt.fenceID + ) + let armed = ArmedFence(source: source) + source.setEventHandler { [weak self] in + self?.fenceBecameReady( + key: key, + deviceGeneration: deviceGeneration, + contextID: completionContextID, + ringIndex: completionRingIndex, + fenceID: receipt.fenceID + ) + } + source.setCancelHandler { Darwin.close(sourceDescriptor) } + let accepted = lock.withLock { () -> Bool in + guard case .active(let currentGeneration) = state, + currentGeneration == deviceGeneration, + armedFences[key] == nil else { return false } + armedFences[key] = armed + return true + } + guard accepted else { + source.cancel() + throw inactiveError(actual: deviceGeneration) + } + source.resume() + } + + private func fenceBecameReady( + key: FenceKey, + deviceGeneration: UInt64, + contextID: UInt32, + ringIndex: UInt32, + fenceID: UInt64 + ) { + let delivery: (ArmedFence, FenceSink)? = lock.withLock { + guard case .active(let currentGeneration) = state, + currentGeneration == deviceGeneration, + key.workerGeneration == workerGeneration.rawValue, + let armed = armedFences.removeValue(forKey: key) else { return nil } + reservedFenceIDs.remove(fenceID) + completedFences = Self.saturatingAdd(completedFences, 1) + guard let fenceSink else { + return (armed, { _, _, _, _ in }) + } + return (armed, fenceSink) + } + guard let delivery else { return } + delivery.0.cancel() + delivery.1(deviceGeneration, contextID, ringIndex, fenceID) + } + + private func releaseFenceReservation(_ fenceID: UInt64) { + _ = lock.withLock { reservedFenceIDs.remove(fenceID) } + } + + private func handleBrokerError( + _ error: DoryRendererWorkerBrokerError, + deviceGeneration: UInt64 + ) { + switch error { + case .workerRejected(.deadlineExpired), + .workerRejected(.resourceExhausted), + .workerRejected(.commandRejected), + .inFlightLimit, + .aggregateReferencedBytesLimit, + .deadlineExpired: + return + default: + failGeneration(deviceGeneration: deviceGeneration, error: .broker(error)) + } + } + + private func brokerTerminated(_ error: DoryRendererWorkerBrokerError) { + let generation: UInt64? = lock.withLock { + guard case .active(let generation) = state else { return nil } + return generation + } + guard let generation else { return } + failGeneration(deviceGeneration: generation, error: .broker(error)) + } + + private func failGeneration( + deviceGeneration: UInt64, + error: DoryRendererWorkerVirtioCommandLaneError + ) { + let transition: (fences: [ArmedFence], sink: RuntimeFailureSink?)? = lock.withLock { + guard case .active(let currentGeneration) = state, + currentGeneration == deviceGeneration else { return nil } + state = .failed(deviceGeneration: currentGeneration) + reservedFenceIDs.removeAll(keepingCapacity: false) + liveScanoutLeases.removeAll(keepingCapacity: false) + releasingScanoutTokens.removeAll(keepingCapacity: false) + let fences = Array(armedFences.values) + armedFences.removeAll(keepingCapacity: false) + return (fences, runtimeFailureSink) + } + guard let transition else { return } + for fence in transition.fences { fence.cancel() } + transition.sink?(deviceGeneration, error) + Task { await broker.invalidate() } + } + + private func isActive(deviceGeneration: UInt64) -> Bool { + lock.withLock { + guard case .active(let currentGeneration) = state else { return false } + return currentGeneration == deviceGeneration + } + } + + private func inactiveError( + actual deviceGeneration: UInt64 + ) -> DoryRendererWorkerVirtioCommandLaneError { + lock.withLock { + if case .active(let expected) = state, expected != deviceGeneration { + return .staleDeviceGeneration(expected: expected, actual: deviceGeneration) + } + return .notActive(state) + } + } + + private func deadline() -> UInt64 { + let now = DispatchTime.now().uptimeNanoseconds + let (deadline, overflow) = now.addingReportingOverflow(commandDeadlineNanoseconds) + return overflow ? UInt64.max : deadline + } + + private func reject( + _ error: DoryRendererWorkerVirtioCommandLaneError + ) -> DoryRendererWorkerVirtioCommandLaneError { + lock.withLock { rejectWhileLocked(error) } + } + + private func rejectWhileLocked( + _ error: DoryRendererWorkerVirtioCommandLaneError + ) -> DoryRendererWorkerVirtioCommandLaneError { + rejectedAdmissions = Self.saturatingAdd(rejectedAdmissions, 1) + return error + } + + private static func duplicate(_ descriptors: [FileHandle]) throws -> [FileHandle] { + var owned = [FileHandle]() + owned.reserveCapacity(descriptors.count) + do { + for (index, descriptor) in descriptors.enumerated() { + let duplicate = fcntl(descriptor.fileDescriptor, F_DUPFD_CLOEXEC, 0) + guard duplicate >= 0 else { + throw DoryRendererWorkerVirtioCommandLaneError + .inputDescriptorDuplicationFailed(index: index) + } + owned.append(FileHandle(fileDescriptor: duplicate, closeOnDealloc: true)) + } + return owned + } catch { + close(owned) + throw error + } + } + + private static func close(_ descriptors: [FileHandle]) { + for descriptor in descriptors { try? descriptor.close() } + } + + private static func saturatingAdd(_ lhs: UInt64, _ rhs: UInt64) -> UInt64 { + let (sum, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? UInt64.max : sum + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerXPCChannel.swift b/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerXPCChannel.swift new file mode 100644 index 00000000..0e957a42 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/DoryRendererWorkerXPCChannel.swift @@ -0,0 +1,278 @@ +import DoryRendererWorkerContracts +import DoryRendererWorkerMetalTransport +import Foundation +import Metal + +public enum DoryRendererWorkerChannelEvent: Equatable, Sendable { + case interrupted + case invalidated +} + +public enum DoryRendererWorkerChannelFailure: Error, Equatable, Sendable { + case unavailable + case interrupted + case invalidated + case serviceFailure(DoryRendererWorkerRPCFailureCode) + case malformedResult(DoryRendererWorkerContractError) + case descriptorCountMismatch(expected: Int, actual: Int) +} + +public struct DoryRendererWorkerChannelReply: @unchecked Sendable { + public let payload: Data + public let descriptors: [FileHandle] + public let sharedTextureHandle: MTLSharedTextureHandle? + + public init( + payload: Data, + descriptors: [FileHandle], + sharedTextureHandle: MTLSharedTextureHandle? = nil + ) { + self.payload = payload + self.descriptors = descriptors + self.sharedTextureHandle = sharedTextureHandle + } +} + +/// Transport seam for one authenticated renderer-worker generation. An interruption is terminal: +/// callers must bootstrap a new signed worker and generation instead of reconnecting to an +/// unknown foreign-renderer state. +public protocol DoryRendererWorkerChannel: AnyObject, Sendable { + func installLifecycleHandler( + _ handler: @escaping @Sendable (DoryRendererWorkerChannelEvent) -> Void + ) + func bootstrap( + exactBytes: Data, + completion: @escaping @Sendable ( + Result + ) -> Void + ) + func exchange( + frame: Data, + descriptors: [FileHandle], + completion: @escaping @Sendable ( + Result + ) -> Void + ) + func invalidate() +} + +/// Runner-local NSXPC adapter. The service name selects a launchd endpoint; the worker's audit +/// token code requirement is the peer authentication authority. No PID, path, environment value, +/// or reconnect heuristic participates in that decision. +public final class DoryRendererWorkerXPCChannel: + NSObject, + DoryRendererWorkerChannel, + @unchecked Sendable +{ + private enum State { + case active + case interrupted + case invalidated + } + + private final class ReplyOnce: @unchecked Sendable { + private let lock = NSLock() + private var completed = false + + func claim() -> Bool { + lock.withLock { + guard !completed else { return false } + completed = true + return true + } + } + } + + private let connection: NSXPCConnection + private let stateLock = NSLock() + private var state: State = .active + private var lifecycleHandlers = [@Sendable (DoryRendererWorkerChannelEvent) -> Void]() + + public init(codeDirectoryHash: DoryCodeDirectoryHash) { + connection = NSXPCConnection(serviceName: DoryRendererWorkerIdentity.serviceName) + super.init() + connection.remoteObjectInterface = DoryRendererWorkerXPCInterface.make() + connection.interruptionHandler = { [weak self] in + self?.transition(to: .interrupted) + } + connection.invalidationHandler = { [weak self] in + self?.transition(to: .invalidated) + } + connection.setCodeSigningRequirement( + DoryRendererWorkerIdentity.exactWorkerCodeSigningRequirement( + codeDirectoryHash: codeDirectoryHash + ) + ) + connection.activate() + } + + public func installLifecycleHandler( + _ handler: @escaping @Sendable (DoryRendererWorkerChannelEvent) -> Void + ) { + let immediate: DoryRendererWorkerChannelEvent? = stateLock.withLock { + switch state { + case .active: + lifecycleHandlers.append(handler) + return nil + case .interrupted: + return .interrupted + case .invalidated: + return .invalidated + } + } + if let immediate { handler(immediate) } + } + + public func bootstrap( + exactBytes: Data, + completion: @escaping @Sendable ( + Result + ) -> Void + ) { + guard isActive else { + completion(.failure(.unavailable)) + return + } + let once = ReplyOnce() + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ [weak self] _ in + guard once.claim() else { return } + completion(.failure(.interrupted)) + self?.transition(to: .interrupted) + }) as? DoryRendererWorkerXPCProtocol else { + completion(.failure(.unavailable)) + transition(to: .interrupted) + return + } + proxy.bootstrap(exactBytes) { [weak self] bytes in + guard once.claim() else { return } + do { + switch try DoryRendererWorkerRPCResultCodec.decode(bytes) { + case let .success(payload, descriptorCount): + guard descriptorCount == 0 else { + completion(.failure(.descriptorCountMismatch( + expected: 0, + actual: Int(descriptorCount) + ))) + self?.invalidate() + return + } + completion(.success(payload)) + case .failure(let code): + completion(.failure(.serviceFailure(code))) + } + } catch let error as DoryRendererWorkerContractError { + completion(.failure(.malformedResult(error))) + self?.invalidate() + } catch { + completion(.failure(.unavailable)) + self?.invalidate() + } + } + } + + public func exchange( + frame: Data, + descriptors: [FileHandle], + completion: @escaping @Sendable ( + Result + ) -> Void + ) { + guard isActive else { + completion(.failure(.unavailable)) + return + } + let once = ReplyOnce() + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ [weak self] _ in + guard once.claim() else { return } + completion(.failure(.interrupted)) + self?.transition(to: .interrupted) + }) as? DoryRendererWorkerXPCProtocol else { + completion(.failure(.unavailable)) + transition(to: .interrupted) + return + } + proxy.exchange(frame, descriptors: descriptors) { + [weak self] bytes, replyDescriptors, sharedTextureHandle in + guard once.claim() else { + Self.close(replyDescriptors) + return + } + do { + switch try DoryRendererWorkerRPCResultCodec.decode(bytes) { + case let .success(payload, descriptorCount): + guard Int(descriptorCount) == replyDescriptors.count else { + Self.close(replyDescriptors) + completion(.failure(.descriptorCountMismatch( + expected: Int(descriptorCount), + actual: replyDescriptors.count + ))) + self?.invalidate() + return + } + completion(.success(DoryRendererWorkerChannelReply( + payload: payload, + descriptors: replyDescriptors, + sharedTextureHandle: sharedTextureHandle + ))) + case .failure(let code): + guard replyDescriptors.isEmpty, sharedTextureHandle == nil else { + Self.close(replyDescriptors) + completion(.failure(.descriptorCountMismatch( + expected: 0, + actual: replyDescriptors.count + ))) + self?.invalidate() + return + } + completion(.failure(.serviceFailure(code))) + } + } catch let error as DoryRendererWorkerContractError { + Self.close(replyDescriptors) + completion(.failure(.malformedResult(error))) + self?.invalidate() + } catch { + Self.close(replyDescriptors) + completion(.failure(.unavailable)) + self?.invalidate() + } + } + } + + public func invalidate() { + transition(to: .invalidated) + connection.invalidate() + } + + private var isActive: Bool { + stateLock.withLock { + if case .active = state { return true } + return false + } + } + + private func transition(to requested: State) { + let delivery: ( + handlers: [@Sendable (DoryRendererWorkerChannelEvent) -> Void], + event: DoryRendererWorkerChannelEvent + )? = stateLock.withLock { + guard case .active = state else { return nil } + state = requested + let handlers = lifecycleHandlers + lifecycleHandlers.removeAll(keepingCapacity: false) + switch requested { + case .active: + return nil + case .interrupted: + return (handlers, .interrupted) + case .invalidated: + return (handlers, .invalidated) + } + } + guard let delivery else { return } + for handler in delivery.handlers { handler(delivery.event) } + } + + private static func close(_ descriptors: [FileHandle]) { + for descriptor in descriptors { try? descriptor.close() } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DaxWindow.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DaxWindow.swift deleted file mode 100644 index ccccef3c..00000000 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DaxWindow.swift +++ /dev/null @@ -1,129 +0,0 @@ -import Foundation - -public enum DaxWindowError: Error, Equatable { - case invalidWindow - case unaligned - case outOfBounds - case overlap - case missingMapping - case mappingFailed(String) - case unmappingFailed(String) -} - -public struct DaxMapping: Equatable, Sendable { - public var fileHandle: UInt64 - public var fileOffset: UInt64 - public var memoryOffset: UInt64 - public var length: UInt64 - public var flags: UInt64 - - public init(fileHandle: UInt64, fileOffset: UInt64, memoryOffset: UInt64, length: UInt64, flags: UInt64 = 0) { - self.fileHandle = fileHandle - self.fileOffset = fileOffset - self.memoryOffset = memoryOffset - self.length = length - self.flags = flags - } -} - -public protocol DaxMappingBackend: AnyObject, Sendable { - func map(_ mapping: DaxMapping, fileDescriptor: Int32, guestAddress: UInt64) throws - func unmap(_ mapping: DaxMapping, guestAddress: UInt64) throws -} - -public final class DaxWindow: @unchecked Sendable { - public static let defaultSize: UInt64 = 4 * 1024 * 1024 * 1024 - public static let pageSize: UInt64 = HostPage.size - - public let guestBase: UInt64 - public let length: UInt64 - private let backend: DaxMappingBackend? - private var mappings: [DaxMapping] = [] - private let lock = NSLock() - - public init(guestBase: UInt64, length: UInt64 = DaxWindow.defaultSize, backend: DaxMappingBackend? = nil) throws { - guard length > 0, guestBase.isMultiple(of: Self.pageSize), length.isMultiple(of: Self.pageSize) else { - throw DaxWindowError.invalidWindow - } - self.guestBase = guestBase - self.length = length - self.backend = backend - } - - public var activeMappings: [DaxMapping] { - lock.lock() - defer { lock.unlock() } - return mappings.sorted { $0.memoryOffset < $1.memoryOffset } - } - - public func setup(_ request: FuseSetupMappingIn, fileDescriptor: Int32? = nil) throws -> DaxMapping { - let mapping = DaxMapping( - fileHandle: request.fileHandle, - fileOffset: request.fileOffset, - memoryOffset: request.memoryOffset, - length: request.length, - flags: request.flags - ) - try validate(mapping) - if backend != nil, fileDescriptor == nil { - throw DaxWindowError.mappingFailed("missing file descriptor") - } - lock.lock() - defer { lock.unlock() } - guard !mappings.contains(where: { rangesOverlap($0.memoryOffset, $0.length, mapping.memoryOffset, mapping.length) }) else { - throw DaxWindowError.overlap - } - if let backend, let fileDescriptor { - try backend.map(mapping, fileDescriptor: fileDescriptor, guestAddress: try guestAddress(forMemoryOffset: mapping.memoryOffset)) - } - mappings.append(mapping) - return mapping - } - - public func remove(_ request: FuseRemoveMappingIn) throws { - lock.lock() - defer { lock.unlock() } - for entry in request.mappings { - guard entry.memoryOffset.isMultiple(of: Self.pageSize), - entry.length > 0, - entry.length.isMultiple(of: Self.pageSize) else { - throw DaxWindowError.unaligned - } - let overlapping = mappings.indices.filter { - rangesOverlap(mappings[$0].memoryOffset, mappings[$0].length, entry.memoryOffset, entry.length) - } - for index in overlapping.reversed() { - let mapping = mappings[index] - if let backend { - try backend.unmap(mapping, guestAddress: try guestAddress(forMemoryOffset: mapping.memoryOffset)) - } - mappings.remove(at: index) - } - } - } - - public func guestAddress(forMemoryOffset offset: UInt64) throws -> UInt64 { - guard offset < length else { throw DaxWindowError.outOfBounds } - return guestBase + offset - } - - private func validate(_ mapping: DaxMapping) throws { - guard mapping.fileOffset.isMultiple(of: Self.pageSize), - mapping.memoryOffset.isMultiple(of: Self.pageSize), - mapping.length > 0, - mapping.length.isMultiple(of: Self.pageSize) else { - throw DaxWindowError.unaligned - } - guard mapping.memoryOffset < length, - mapping.length <= length, - mapping.memoryOffset <= length - mapping.length else { - throw DaxWindowError.outOfBounds - } - } - - private func rangesOverlap(_ aStart: UInt64, _ aLength: UInt64, _ bStart: UInt64, _ bLength: UInt64) -> Bool { - let aEnd = aStart + aLength - let bEnd = bStart + bLength - return aStart < bEnd && bStart < aEnd - } -} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerAdmission.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerAdmission.swift new file mode 100644 index 00000000..f3393833 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerAdmission.swift @@ -0,0 +1,478 @@ +import DoryFSWorkerContracts +import Foundation + +/// The exact request-memory ceilings that apply to one share after intersecting its bootstrap +/// authority with the worker-wide envelope. Long-lived HostFS resource limits remain worker-side; +/// these values are the complete VMM admission contract held through used-ring publication. +public struct DoryFSWorkerEffectiveAdmissionLimits: Equatable, Sendable { + public let maximumRequestBytes: Int + public let maximumResponseBytes: Int + public let maximumInFlightRequests: Int + public let maximumAggregateRequestBytes: Int + public let maximumAggregateResponseBytes: Int + + init( + worker: DoryFSWorkerLimits, + share: DoryFSShareResourceLimits + ) { + maximumRequestBytes = min( + worker.maximumRequestBytes, + share.maximumAggregateRequestBytes + ) + maximumResponseBytes = min( + worker.maximumResponseBytes, + share.maximumAggregateResponseBytes + ) + maximumInFlightRequests = min( + worker.maximumInFlightRequests, + share.maximumInFlightRequests + ) + maximumAggregateRequestBytes = min( + worker.maximumAggregateRequestBytes, + share.maximumAggregateRequestBytes + ) + maximumAggregateResponseBytes = min( + worker.maximumAggregateResponseBytes, + share.maximumAggregateResponseBytes + ) + } +} + +struct DoryFSWorkerAdmissionShape: Equatable, Sendable { + let requestBytes: Int + let responseBytes: Int +} + +struct DoryFSWorkerAdmissionWaiterID: Hashable, Sendable { + private let rawValue: UUID + + init() { + rawValue = UUID() + } +} + +enum DoryFSWorkerFrontendAdmissionResult: Sendable { + case admitted(DoryFSWorkerAdmissionLease) + case deferred + case rejected(DoryFSWorkerBrokerError) +} + +/// Completes a deferred frontend admission exactly once. A workspace terminal event must be +/// observable here: silently deleting a waiter would leave its virtqueue available forever while +/// the frontend continued to believe a capacity grant was pending. +enum DoryFSWorkerFrontendAdmissionResolution: Sendable { + case granted(DoryFSWorkerAdmissionLease) + case terminated(DoryFSWorkerBrokerError) +} + +/// One workspace reservation. The authority owns the counters; both explicit release and deinit +/// are idempotent so a reset or abandoned deferred queue cannot strand workspace capacity. +final class DoryFSWorkerAdmissionLease: @unchecked Sendable { + fileprivate let identifier: UUID + fileprivate let shareCapabilityID: DoryFSShareCapabilityID + let shape: DoryFSWorkerAdmissionShape + fileprivate let authority: DoryFSWorkerWorkspaceAdmissionAuthority + + fileprivate init( + identifier: UUID, + shareCapabilityID: DoryFSShareCapabilityID, + shape: DoryFSWorkerAdmissionShape, + authority: DoryFSWorkerWorkspaceAdmissionAuthority + ) { + self.identifier = identifier + self.shareCapabilityID = shareCapabilityID + self.shape = shape + self.authority = authority + } + + func release() { + authority.release(identifier: identifier) + } + + var isValid: Bool { + authority.containsReservation(identifier: identifier) + } + + deinit { + release() + } +} + +struct DoryFSWorkerWorkspaceAdmissionSnapshot: Equatable, Sendable { + let inFlightRequests: Int + let peakInFlightRequests: Int + let aggregateRequestBytes: Int + let aggregateResponseBytes: Int + let deferredWaiters: Int +} + +/// Synchronous workspace-wide admission used before a virtqueue pop. Every broker created from one +/// bootstrap shares this authority. Capacity released by either share is reserved for eligible +/// waiters in FIFO order before their callbacks run, preventing direct kicks from stealing a fair +/// reschedule. A temporarily share-blocked waiter does not head-of-line block another share that is +/// eligible under the workspace envelope. +final class DoryFSWorkerWorkspaceAdmissionAuthority: @unchecked Sendable { + private struct Usage { + var inFlightRequests = 0 + var peakInFlightRequests = 0 + var aggregateRequestBytes = 0 + var aggregateResponseBytes = 0 + } + + private struct Reservation { + let shareCapabilityID: DoryFSShareCapabilityID + let shape: DoryFSWorkerAdmissionShape + } + + private struct Waiter { + let identifier: DoryFSWorkerAdmissionWaiterID + let shareCapabilityID: DoryFSShareCapabilityID + let shape: DoryFSWorkerAdmissionShape + let onResolved: @Sendable (DoryFSWorkerFrontendAdmissionResolution) -> Void + } + + private struct Delivery { + let callback: @Sendable (DoryFSWorkerFrontendAdmissionResolution) -> Void + let resolution: DoryFSWorkerFrontendAdmissionResolution + } + + private let workerLimits: DoryFSWorkerLimits + private let shareLimits: [DoryFSShareCapabilityID: DoryFSShareResourceLimits] + private let lock = NSLock() + private var workspaceUsage = Usage() + private var shareUsage = [DoryFSShareCapabilityID: Usage]() + private var reservations = [UUID: Reservation]() + private var waiters = [Waiter]() + private var waiterIDs = Set() + private var active = true + + init( + workerLimits: DoryFSWorkerLimits, + shareLimits: [DoryFSShareCapabilityID: DoryFSShareResourceLimits] + ) { + precondition(!shareLimits.isEmpty) + self.workerLimits = workerLimits + self.shareLimits = shareLimits + } + + func effectiveLimits( + for shareCapabilityID: DoryFSShareCapabilityID + ) -> DoryFSWorkerEffectiveAdmissionLimits? { + guard let share = shareLimits[shareCapabilityID] else { return nil } + return DoryFSWorkerEffectiveAdmissionLimits(worker: workerLimits, share: share) + } + + func resourceLimits( + for shareCapabilityID: DoryFSShareCapabilityID + ) -> DoryFSShareResourceLimits? { + shareLimits[shareCapabilityID] + } + + func request( + shareCapabilityID: DoryFSShareCapabilityID, + shape: DoryFSWorkerAdmissionShape, + waiterID: DoryFSWorkerAdmissionWaiterID, + onResolved: @escaping @Sendable (DoryFSWorkerFrontendAdmissionResolution) -> Void + ) -> DoryFSWorkerFrontendAdmissionResult { + var deliveries = [Delivery]() + let result: DoryFSWorkerFrontendAdmissionResult = lock.withLock { + guard active else { return .rejected(.invalidAdmissionAuthority) } + if let rejection = permanentRejectionLocked( + shareCapabilityID: shareCapabilityID, + shape: shape + ) { + return .rejected(rejection) + } + if waiters.isEmpty, fitsLocked( + shareCapabilityID: shareCapabilityID, + shape: shape + ) { + return .admitted(reserveLocked( + shareCapabilityID: shareCapabilityID, + shape: shape + )) + } + if waiterIDs.insert(waiterID).inserted { + waiters.append(Waiter( + identifier: waiterID, + shareCapabilityID: shareCapabilityID, + shape: shape, + onResolved: onResolved + )) + } + deliveries = grantEligibleWaitersLocked() + return .deferred + } + deliver(deliveries) + return result + } + + func acquireImmediately( + shareCapabilityID: DoryFSShareCapabilityID, + shape: DoryFSWorkerAdmissionShape + ) -> Result { + lock.withLock { + guard active else { return .failure(.invalidAdmissionAuthority) } + if let rejection = permanentRejectionLocked( + shareCapabilityID: shareCapabilityID, + shape: shape + ) { + return .failure(rejection) + } + guard fitsLocked(shareCapabilityID: shareCapabilityID, shape: shape) else { + return .failure(saturationErrorLocked( + shareCapabilityID: shareCapabilityID, + shape: shape + )) + } + return .success(reserveLocked( + shareCapabilityID: shareCapabilityID, + shape: shape + )) + } + } + + func cancel(waiterID: DoryFSWorkerAdmissionWaiterID) { + lock.withLock { + guard waiterIDs.remove(waiterID) != nil else { return } + waiters.removeAll { $0.identifier == waiterID } + } + } + + func invalidate(error: DoryFSWorkerBrokerError) { + let deliveries: [Delivery] = lock.withLock { + guard active else { return [] } + active = false + let deliveries = waiters.map { + Delivery(callback: $0.onResolved, resolution: .terminated(error)) + } + waiters.removeAll(keepingCapacity: false) + waiterIDs.removeAll(keepingCapacity: false) + reservations.removeAll(keepingCapacity: false) + workspaceUsage = Usage() + shareUsage.removeAll(keepingCapacity: false) + return deliveries + } + deliver(deliveries) + } + + func validates( + _ lease: DoryFSWorkerAdmissionLease, + shareCapabilityID: DoryFSShareCapabilityID, + shape: DoryFSWorkerAdmissionShape + ) -> Bool { + guard lease.authority === self, + lease.shareCapabilityID == shareCapabilityID, + lease.shape == shape else { return false } + return lock.withLock { + guard let reservation = reservations[lease.identifier] else { return false } + return reservation.shareCapabilityID == shareCapabilityID + && reservation.shape == shape + } + } + + func snapshot( + for shareCapabilityID: DoryFSShareCapabilityID? = nil + ) -> DoryFSWorkerWorkspaceAdmissionSnapshot { + lock.withLock { + let usage = shareCapabilityID.flatMap { shareUsage[$0] } ?? workspaceUsage + let deferred = shareCapabilityID.map { capability in + waiters.lazy.filter { $0.shareCapabilityID == capability }.count + } ?? waiters.count + return DoryFSWorkerWorkspaceAdmissionSnapshot( + inFlightRequests: usage.inFlightRequests, + peakInFlightRequests: usage.peakInFlightRequests, + aggregateRequestBytes: usage.aggregateRequestBytes, + aggregateResponseBytes: usage.aggregateResponseBytes, + deferredWaiters: deferred + ) + } + } + + fileprivate func release(identifier: UUID) { + let deliveries: [Delivery] = lock.withLock { + guard let reservation = reservations.removeValue(forKey: identifier) else { return [] } + releaseUsageLocked( + &workspaceUsage, + shape: reservation.shape + ) + guard var usage = shareUsage[reservation.shareCapabilityID] else { + preconditionFailure("missing filesystem share admission usage") + } + releaseUsageLocked(&usage, shape: reservation.shape) + shareUsage[reservation.shareCapabilityID] = usage + return grantEligibleWaitersLocked() + } + deliver(deliveries) + } + + fileprivate func containsReservation(identifier: UUID) -> Bool { + lock.withLock { active && reservations[identifier] != nil } + } + + private func permanentRejectionLocked( + shareCapabilityID: DoryFSShareCapabilityID, + shape: DoryFSWorkerAdmissionShape + ) -> DoryFSWorkerBrokerError? { + guard let effective = effectiveLimits(for: shareCapabilityID) else { + return .invalidAdmissionAuthority + } + guard shape.requestBytes >= 0, + shape.requestBytes <= effective.maximumRequestBytes else { + return .requestTooLarge( + limit: effective.maximumRequestBytes, + actual: shape.requestBytes + ) + } + guard shape.responseBytes >= 0, + shape.responseBytes <= effective.maximumResponseBytes else { + return .responseCapacityTooLarge( + limit: effective.maximumResponseBytes, + actual: shape.responseBytes + ) + } + return nil + } + + private func fitsLocked( + shareCapabilityID: DoryFSShareCapabilityID, + shape: DoryFSWorkerAdmissionShape + ) -> Bool { + guard let effective = effectiveLimits(for: shareCapabilityID) else { return false } + let share = shareUsage[shareCapabilityID] ?? Usage() + return adding(shape.requestBytes, to: workspaceUsage.aggregateRequestBytes) + .map { $0 <= workerLimits.maximumAggregateRequestBytes } == true + && adding(shape.responseBytes, to: workspaceUsage.aggregateResponseBytes) + .map { $0 <= workerLimits.maximumAggregateResponseBytes } == true + && workspaceUsage.inFlightRequests < workerLimits.maximumInFlightRequests + && adding(shape.requestBytes, to: share.aggregateRequestBytes) + .map { $0 <= effective.maximumAggregateRequestBytes } == true + && adding(shape.responseBytes, to: share.aggregateResponseBytes) + .map { $0 <= effective.maximumAggregateResponseBytes } == true + && share.inFlightRequests < effective.maximumInFlightRequests + } + + private func saturationErrorLocked( + shareCapabilityID: DoryFSShareCapabilityID, + shape: DoryFSWorkerAdmissionShape + ) -> DoryFSWorkerBrokerError { + guard let effective = effectiveLimits(for: shareCapabilityID) else { + return .invalidAdmissionAuthority + } + let share = shareUsage[shareCapabilityID] ?? Usage() + if workspaceUsage.inFlightRequests >= workerLimits.maximumInFlightRequests + || share.inFlightRequests >= effective.maximumInFlightRequests { + return .inFlightLimit(limit: effective.maximumInFlightRequests) + } + let workspaceRequest = adding( + shape.requestBytes, + to: workspaceUsage.aggregateRequestBytes + ) ?? Int.max + let shareRequest = adding(shape.requestBytes, to: share.aggregateRequestBytes) ?? Int.max + if workspaceRequest > workerLimits.maximumAggregateRequestBytes + || shareRequest > effective.maximumAggregateRequestBytes { + return .aggregateRequestLimit( + limit: min( + workerLimits.maximumAggregateRequestBytes, + effective.maximumAggregateRequestBytes + ), + requested: max(workspaceRequest, shareRequest) + ) + } + let workspaceResponse = adding( + shape.responseBytes, + to: workspaceUsage.aggregateResponseBytes + ) ?? Int.max + let shareResponse = adding(shape.responseBytes, to: share.aggregateResponseBytes) ?? Int.max + return .aggregateResponseLimit( + limit: min( + workerLimits.maximumAggregateResponseBytes, + effective.maximumAggregateResponseBytes + ), + requested: max(workspaceResponse, shareResponse) + ) + } + + private func reserveLocked( + shareCapabilityID: DoryFSShareCapabilityID, + shape: DoryFSWorkerAdmissionShape + ) -> DoryFSWorkerAdmissionLease { + precondition(fitsLocked(shareCapabilityID: shareCapabilityID, shape: shape)) + reserveUsageLocked(&workspaceUsage, shape: shape) + var usage = shareUsage[shareCapabilityID] ?? Usage() + reserveUsageLocked(&usage, shape: shape) + shareUsage[shareCapabilityID] = usage + let identifier = UUID() + reservations[identifier] = Reservation( + shareCapabilityID: shareCapabilityID, + shape: shape + ) + return DoryFSWorkerAdmissionLease( + identifier: identifier, + shareCapabilityID: shareCapabilityID, + shape: shape, + authority: self + ) + } + + private func grantEligibleWaitersLocked() -> [Delivery] { + guard active else { return [] } + var deliveries = [Delivery]() + while let index = waiters.firstIndex(where: { + fitsLocked(shareCapabilityID: $0.shareCapabilityID, shape: $0.shape) + }) { + let waiter = waiters.remove(at: index) + waiterIDs.remove(waiter.identifier) + deliveries.append(Delivery( + callback: waiter.onResolved, + resolution: .granted(reserveLocked( + shareCapabilityID: waiter.shareCapabilityID, + shape: waiter.shape + )) + )) + } + return deliveries + } + + private func deliver(_ deliveries: [Delivery]) { + for delivery in deliveries { + DispatchQueue.global(qos: .userInitiated).async { + if case .granted(let lease) = delivery.resolution, + !lease.isValid { + return + } + delivery.callback(delivery.resolution) + } + } + } + + private func reserveUsageLocked( + _ usage: inout Usage, + shape: DoryFSWorkerAdmissionShape + ) { + usage.inFlightRequests += 1 + usage.peakInFlightRequests = max( + usage.peakInFlightRequests, + usage.inFlightRequests + ) + usage.aggregateRequestBytes += shape.requestBytes + usage.aggregateResponseBytes += shape.responseBytes + } + + private func releaseUsageLocked( + _ usage: inout Usage, + shape: DoryFSWorkerAdmissionShape + ) { + precondition(usage.inFlightRequests > 0) + precondition(usage.aggregateRequestBytes >= shape.requestBytes) + precondition(usage.aggregateResponseBytes >= shape.responseBytes) + usage.inFlightRequests -= 1 + usage.aggregateRequestBytes -= shape.requestBytes + usage.aggregateResponseBytes -= shape.responseBytes + } + + private func adding(_ increment: Int, to value: Int) -> Int? { + let (sum, overflow) = value.addingReportingOverflow(increment) + return increment >= 0 && !overflow ? sum : nil + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerBroker.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerBroker.swift new file mode 100644 index 00000000..fba990b2 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerBroker.swift @@ -0,0 +1,964 @@ +import DoryFSWorkerContracts +import Foundation + +/// Events supplied by a future XPC adapter's `interruptionHandler` and `invalidationHandler`. +/// Either event is fail-stop for one broker generation; reconnection requires a new broker and a +/// newly bootstrapped worker authority rather than silently reusing lost handle identity. +public enum DoryFSWorkerChannelEvent: Equatable, Sendable { + case interrupted + case invalidated +} + +public enum DoryFSWorkerChannelFailure: Error, Equatable, Sendable { + case unavailable + case interrupted + case invalidated + case serviceFailure(DoryFSWorkerRPCFailureCode) +} + +/// Transport-neutral boundary implemented by a future signed NSXPC/App Sandbox adapter. Each call +/// carries one exact bounded frame. This protocol does not launch a process and an implementation +/// backed by an ordinary child process is not a security boundary. +public protocol DoryFSWorkerChannel: AnyObject, Sendable { + func installLifecycleHandler( + _ handler: @escaping @Sendable (DoryFSWorkerChannelEvent) -> Void + ) + func send( + frame: Data, + completion: @escaping @Sendable (Result) -> Void + ) + func sendOneWay(frame: Data) + func invalidate() +} + +public enum DoryFSWorkerBrokerState: Equatable, Sendable { + case active + case draining + case drained + case interrupted + case invalidated + case protocolViolation +} + +public enum DoryFSWorkerBrokerError: Error, Equatable, Sendable { + case notActive(DoryFSWorkerBrokerState) + case requestTooLarge(limit: Int, actual: Int) + case responseCapacityTooLarge(limit: Int, actual: Int) + case operationDeadlineExpired + case operationDeadlineTooDistant(limitNanoseconds: UInt64, actualNanoseconds: UInt64) + case drainDeadlineExpired + case drainDeadlineTooDistant(limitNanoseconds: UInt64, actualNanoseconds: UInt64) + case inFlightLimit(limit: Int) + case aggregateRequestLimit(limit: Int, requested: Int) + case aggregateResponseLimit(limit: Int, requested: Int) + case invalidAdmissionAuthority + case duplicateCorrelationID(UInt64) + case invalidCorrelationID + case requestIDExhausted + case unknownCorrelationID(UInt64) + case requestDeadlineExpired(correlationID: UInt64) + case channelFailure(DoryFSWorkerChannelFailure) + case channelInterrupted + case channelInvalidated + case workerRejected(DoryFSWorkerRejectionCode) + case malformedReply(DoryFSWorkerContractError) + case replyIdentityMismatch + case responseTooLarge(limit: Int, actual: Int) + case drainReplyMismatch + case connectionTeardownWithInFlight( + inFlightRequests: Int, + pendingPublications: Int + ) +} + +public struct DoryFSWorkerBrokerSnapshot: Equatable, Sendable { + public let state: DoryFSWorkerBrokerState + public let generation: DoryFSWorkerGeneration + public let inFlightRequests: Int + public let pendingPublications: Int + public let aggregateRequestBytes: Int + public let aggregateResponseReservations: Int + public let rejectedAdmissions: UInt64 + public let completedRequests: UInt64 + public let lateReplies: UInt64 + public let protocolViolations: UInt64 + public let sentInterrupts: UInt64 +} + +/// Host-owned response plus the exact acknowledgement token that retains worker-side grants. +/// The frontend must commit only after the used-ring publication succeeds, or discard on every +/// pre-publication failure. Dropping this value without either acknowledgement fail-stops the +/// broker at the original operation deadline. +public struct DoryFSWorkerExecution: Equatable, Sendable { + public let response: Data + public let publication: DoryFSWorkerPublication + public let acknowledgementDeadlineUptimeNanoseconds: UInt64 + + public init( + response: Data, + publication: DoryFSWorkerPublication, + acknowledgementDeadlineUptimeNanoseconds: UInt64 + ) { + self.response = response + self.publication = publication + self.acknowledgementDeadlineUptimeNanoseconds = + acknowledgementDeadlineUptimeNanoseconds + } +} + +/// VMM-side authority for one immutable share capability and one worker generation. +/// +/// The actor owns every in-flight reservation and validates a complete reply before returning it to +/// the frontend. A deadline does not reclaim capacity and continue using an unresponsive worker: +/// it invalidates the whole channel, because the abandoned worker may still hold descriptors or be +/// completing a mutation. The future supervisor must then terminate that signed worker process. +public actor DoryFSWorkerBroker { + /// Minimal identity retained after the exact request frame has been sent. Keeping the complete + /// `DoryFSWorkerRequest` here also retained its payload beside the encoded XPC frame until + /// publication acknowledgement, doubling large-request residency for no authority benefit. + private struct RequestIdentity { + let requestID: UInt64 + let correlationID: UInt64 + let deadlineUptimeNanoseconds: UInt64 + } + + private struct PendingRequest { + let identity: RequestIdentity + let admissionLease: DoryFSWorkerAdmissionLease + let continuation: CheckedContinuation + var timeoutTask: Task? + } + + private struct PendingPublication { + let identity: RequestIdentity + let admissionLease: DoryFSWorkerAdmissionLease + var timeoutTask: Task? + } + + private struct PendingDrain { + let deadlineUptimeNanoseconds: UInt64 + let continuation: CheckedContinuation + var requestSent: Bool + var timeoutTask: Task? + } + + public nonisolated let limits: DoryFSWorkerLimits + public nonisolated let shareResourceLimits: DoryFSShareResourceLimits + public nonisolated let effectiveAdmissionLimits: DoryFSWorkerEffectiveAdmissionLimits + public nonisolated let shareCapabilityID: DoryFSShareCapabilityID + public nonisolated let generation: DoryFSWorkerGeneration + + private let channel: any DoryFSWorkerChannel + private nonisolated let admissionAuthority: DoryFSWorkerWorkspaceAdmissionAuthority + private var state: DoryFSWorkerBrokerState = .active + private var nextRequestID: UInt64 = 1 + private var pendingByRequestID = [UInt64: PendingRequest]() + private var pendingPublicationsByRequestID = [UInt64: PendingPublication]() + private var requestIDByCorrelationID = [UInt64: UInt64]() + private var pendingDrain: PendingDrain? + private var rejectedAdmissions: UInt64 = 0 + private var completedRequests: UInt64 = 0 + private var lateReplies: UInt64 = 0 + private var protocolViolations: UInt64 = 0 + private var sentInterrupts: UInt64 = 0 + + public init( + shareCapabilityID: DoryFSShareCapabilityID, + generation: DoryFSWorkerGeneration, + limits: DoryFSWorkerLimits = .production, + shareResourceLimits: DoryFSShareResourceLimits = .production, + channel: any DoryFSWorkerChannel + ) { + let authority = DoryFSWorkerWorkspaceAdmissionAuthority( + workerLimits: limits, + shareLimits: [shareCapabilityID: shareResourceLimits] + ) + self.shareCapabilityID = shareCapabilityID + self.generation = generation + self.limits = limits + self.shareResourceLimits = shareResourceLimits + self.effectiveAdmissionLimits = authority.effectiveLimits(for: shareCapabilityID)! + self.admissionAuthority = authority + self.channel = channel + channel.installLifecycleHandler { [weak self] event in + guard let self else { return } + Task { await self.receiveChannelEvent(event) } + } + } + + init( + shareCapabilityID: DoryFSShareCapabilityID, + generation: DoryFSWorkerGeneration, + limits: DoryFSWorkerLimits, + shareResourceLimits: DoryFSShareResourceLimits, + admissionAuthority: DoryFSWorkerWorkspaceAdmissionAuthority, + channel: any DoryFSWorkerChannel + ) { + guard let effective = admissionAuthority.effectiveLimits(for: shareCapabilityID), + admissionAuthority.resourceLimits(for: shareCapabilityID) == shareResourceLimits else { + preconditionFailure("filesystem broker share is absent from admission authority") + } + self.shareCapabilityID = shareCapabilityID + self.generation = generation + self.limits = limits + self.shareResourceLimits = shareResourceLimits + self.effectiveAdmissionLimits = effective + self.admissionAuthority = admissionAuthority + self.channel = channel + channel.installLifecycleHandler { [weak self] event in + guard let self else { return } + Task { await self.receiveChannelEvent(event) } + } + } + + nonisolated func requestFrontendAdmission( + shape: DoryFSWorkerAdmissionShape, + waiterID: DoryFSWorkerAdmissionWaiterID, + onResolved: @escaping @Sendable (DoryFSWorkerFrontendAdmissionResolution) -> Void + ) -> DoryFSWorkerFrontendAdmissionResult { + admissionAuthority.request( + shareCapabilityID: shareCapabilityID, + shape: shape, + waiterID: waiterID, + onResolved: onResolved + ) + } + + nonisolated func cancelFrontendAdmission( + waiterID: DoryFSWorkerAdmissionWaiterID + ) { + admissionAuthority.cancel(waiterID: waiterID) + } + + nonisolated var workspaceAdmissionSnapshot: DoryFSWorkerWorkspaceAdmissionSnapshot { + admissionAuthority.snapshot() + } + + /// Admits one immutable request frame. `correlationID` is the guest-visible FUSE unique value; + /// the broker allocates a separate never-reused request ID so a late callback cannot alias a + /// later FUSE request that happens to reuse its unique value. + public func execute( + correlationID: UInt64, + opcodeClass: DoryFSWorkerOpcodeClass, + request: Data, + responseCapacity: Int, + deadlineUptimeNanoseconds: UInt64 + ) async throws -> DoryFSWorkerExecution { + try await executeAdmitted( + correlationID: correlationID, + opcodeClass: opcodeClass, + request: request, + responseCapacity: responseCapacity, + deadlineUptimeNanoseconds: deadlineUptimeNanoseconds, + suppliedAdmissionLease: nil + ) + } + + func execute( + correlationID: UInt64, + opcodeClass: DoryFSWorkerOpcodeClass, + request: Data, + responseCapacity: Int, + deadlineUptimeNanoseconds: UInt64, + admissionLease: DoryFSWorkerAdmissionLease + ) async throws -> DoryFSWorkerExecution { + try await executeAdmitted( + correlationID: correlationID, + opcodeClass: opcodeClass, + request: request, + responseCapacity: responseCapacity, + deadlineUptimeNanoseconds: deadlineUptimeNanoseconds, + suppliedAdmissionLease: admissionLease + ) + } + + private func executeAdmitted( + correlationID: UInt64, + opcodeClass: DoryFSWorkerOpcodeClass, + request: Data, + responseCapacity: Int, + deadlineUptimeNanoseconds: UInt64, + suppliedAdmissionLease: DoryFSWorkerAdmissionLease? + ) async throws -> DoryFSWorkerExecution { + // Preserve the broker's exact terminal cause. A terminal transition invalidates the + // workspace authority as part of the same actor turn, so consulting that authority first + // would collapse `.interrupted`, `.drained`, and protocol-failure states into the less + // useful `invalidAdmissionAuthority` error. + guard state == .active else { + suppliedAdmissionLease?.release() + throw reject(.notActive(state)) + } + let shape = DoryFSWorkerAdmissionShape( + requestBytes: request.count, + responseBytes: responseCapacity + ) + let admissionLease: DoryFSWorkerAdmissionLease + if let suppliedAdmissionLease { + admissionLease = suppliedAdmissionLease + } else { + switch admissionAuthority.acquireImmediately( + shareCapabilityID: shareCapabilityID, + shape: shape + ) { + case .success(let lease): + admissionLease = lease + case .failure(let error): + throw reject(error) + } + } + guard admissionAuthority.validates( + admissionLease, + shareCapabilityID: shareCapabilityID, + shape: shape + ) else { + admissionLease.release() + throw reject(.invalidAdmissionAuthority) + } + guard correlationID != 0 else { + admissionLease.release() + throw reject(.invalidCorrelationID) + } + guard request.count <= limits.maximumRequestBytes else { + admissionLease.release() + throw reject(.requestTooLarge(limit: limits.maximumRequestBytes, actual: request.count)) + } + guard responseCapacity >= 0, + responseCapacity <= limits.maximumResponseBytes, + let responseCapacity32 = UInt32(exactly: responseCapacity) else { + admissionLease.release() + throw reject(.responseCapacityTooLarge( + limit: limits.maximumResponseBytes, + actual: responseCapacity + )) + } + let now = DispatchTime.now().uptimeNanoseconds + let remaining: UInt64 + do { + remaining = try validateOperationDeadline(deadlineUptimeNanoseconds, now: now) + } catch { + admissionLease.release() + throw error + } + guard requestIDByCorrelationID[correlationID] == nil else { + admissionLease.release() + throw reject(.duplicateCorrelationID(correlationID)) + } + guard nextRequestID != 0 else { + admissionLease.release() + throw reject(.requestIDExhausted) + } + + let requestID = nextRequestID + nextRequestID = requestID == UInt64.max ? 0 : requestID + 1 + let frame: Data + do { + let envelope = try DoryFSWorkerRequest( + generation: generation, + shareCapabilityID: shareCapabilityID, + requestID: requestID, + correlationID: correlationID, + opcodeClass: opcodeClass, + responseCapacity: responseCapacity32, + deadlineUptimeNanoseconds: deadlineUptimeNanoseconds, + payload: request + ) + frame = try DoryFSWorkerFrameCodec.encode( + .execute(envelope), + maximumFrameBytes: limits.maximumFrameBytes + ) + } catch { + admissionLease.release() + throw error + } + + return try await withCheckedThrowingContinuation { continuation in + pendingByRequestID[requestID] = PendingRequest( + identity: RequestIdentity( + requestID: requestID, + correlationID: correlationID, + deadlineUptimeNanoseconds: deadlineUptimeNanoseconds + ), + admissionLease: admissionLease, + continuation: continuation, + timeoutTask: nil + ) + requestIDByCorrelationID[correlationID] = requestID + + channel.send(frame: frame) { [weak self] result in + guard let self else { return } + Task { await self.receiveReply(result, expectedRequestID: requestID) } + } + let timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: remaining) + guard let self else { return } + await self.expireRequest(requestID) + } + pendingByRequestID[requestID]?.timeoutTask = timeoutTask + } + } + + /// Sends a priority cancellation for the request with the given guest correlation ID. The + /// reservation remains owned until the worker replies or the channel is invalidated. + @discardableResult + public func interrupt( + correlationID: UInt64, + deadlineUptimeNanoseconds: UInt64 + ) throws -> Bool { + guard state == .active || state == .draining else { + throw DoryFSWorkerBrokerError.notActive(state) + } + let now = DispatchTime.now().uptimeNanoseconds + _ = try validateOperationDeadline(deadlineUptimeNanoseconds, now: now) + guard let requestID = requestIDByCorrelationID[correlationID], + let pending = pendingByRequestID[requestID] else { + return false + } + let interrupt = try DoryFSWorkerInterrupt( + generation: generation, + shareCapabilityID: shareCapabilityID, + targetRequestID: requestID, + targetCorrelationID: pending.identity.correlationID, + deadlineUptimeNanoseconds: deadlineUptimeNanoseconds + ) + let frame = try DoryFSWorkerFrameCodec.encode( + .interrupt(interrupt), + maximumFrameBytes: limits.maximumFrameBytes + ) + sentInterrupts = saturatingAdd(sentInterrupts, 1) + channel.sendOneWay(frame: frame) + return true + } + + /// Commits worker-side lookup/handle grants only after the exact virtqueue chain has been + /// published into the used ring. + public func commitPublication(_ publication: DoryFSWorkerPublication) throws { + try acknowledgePublication(publication, committed: true) + } + + /// Rolls back worker-side grants when the host response never became guest-visible. + public func discardPublication(_ publication: DoryFSWorkerPublication) throws { + try acknowledgePublication(publication, committed: false) + } + + /// Stops new admission, waits for all admitted work, then requires an authenticated drained + /// acknowledgement for this exact share generation. Timeout is fail-stop. + public func drain(deadlineUptimeNanoseconds: UInt64) async throws { + guard state == .active else { throw DoryFSWorkerBrokerError.notActive(state) } + let now = DispatchTime.now().uptimeNanoseconds + let remaining = try validateDrainDeadline(deadlineUptimeNanoseconds, now: now) + state = .draining + try await withCheckedThrowingContinuation { continuation in + pendingDrain = PendingDrain( + deadlineUptimeNanoseconds: deadlineUptimeNanoseconds, + continuation: continuation, + requestSent: false, + timeoutTask: nil + ) + sendDrainIfReady() + let timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: remaining) + guard let self else { return } + await self.expireDrain() + } + pendingDrain?.timeoutTask = timeoutTask + } + } + + /// Invalidates this generation and its channel. The broker is deliberately not restartable; + /// the supervisor must create a new worker, capability bootstrap, and broker generation. + public func invalidate() { + guard state != .invalidated else { return } + sendInvalidationFrame() + transitionToTerminal(.invalidated, error: .channelInvalidated) + channel.invalidate() + } + + /// Retires one share after its exact FUSE_DESTROY response was published and committed. The + /// workspace channel is deliberately retained because sibling shares use the same signed XPC + /// worker and may still be completing their own teardown. Any residual request/publication is + /// an ordering violation and remains fail-stop for the complete channel. + func completeConnectionTeardown() throws { + guard state == .active else { throw DoryFSWorkerBrokerError.notActive(state) } + guard pendingByRequestID.isEmpty, pendingPublicationsByRequestID.isEmpty else { + let error = DoryFSWorkerBrokerError.connectionTeardownWithInFlight( + inFlightRequests: pendingByRequestID.count, + pendingPublications: pendingPublicationsByRequestID.count + ) + sendInvalidationFrame() + transitionToTerminal(.invalidated, error: error) + channel.invalidate() + throw error + } + state = .drained + } + + public func snapshot() -> DoryFSWorkerBrokerSnapshot { + let admission = admissionAuthority.snapshot(for: shareCapabilityID) + return DoryFSWorkerBrokerSnapshot( + state: state, + generation: generation, + inFlightRequests: admission.inFlightRequests, + pendingPublications: pendingPublicationsByRequestID.count, + aggregateRequestBytes: admission.aggregateRequestBytes, + aggregateResponseReservations: admission.aggregateResponseBytes, + rejectedAdmissions: rejectedAdmissions, + completedRequests: completedRequests, + lateReplies: lateReplies, + protocolViolations: protocolViolations, + sentInterrupts: sentInterrupts + ) + } + + private func receiveReply( + _ result: Result, + expectedRequestID: UInt64 + ) { + guard let pending = pendingByRequestID[expectedRequestID] else { + lateReplies = saturatingAdd(lateReplies, 1) + return + } + guard DispatchTime.now().uptimeNanoseconds + < pending.identity.deadlineUptimeNanoseconds else { + expireRequest(expectedRequestID) + return + } + switch result { + case .failure(.interrupted): + transitionToTerminal(.interrupted, error: .channelInterrupted) + channel.invalidate() + case .failure(.invalidated): + transitionToTerminal(.invalidated, error: .channelInvalidated) + case .failure(let failure): + let current = removePendingRequest(expectedRequestID) + current?.continuation.resume( + throwing: DoryFSWorkerBrokerError.channelFailure(failure) + ) + transitionToTerminal(.invalidated, error: .channelInvalidated) + channel.invalidate() + case .success(let bytes): + let serviceFrame: DoryFSWorkerServiceFrame + do { + serviceFrame = try DoryFSWorkerFrameCodec.decodeServiceFrame( + bytes, + maximumFrameBytes: limits.maximumFrameBytes + ) + } catch let error as DoryFSWorkerContractError { + failProtocolViolation(currentRequestID: expectedRequestID, error: .malformedReply(error)) + return + } catch { + failProtocolViolation( + currentRequestID: expectedRequestID, + error: .replyIdentityMismatch + ) + return + } + guard case .reply(let reply) = serviceFrame, + reply.generation == generation, + reply.shareCapabilityID == shareCapabilityID, + reply.requestID == expectedRequestID, + reply.correlationID == pending.identity.correlationID else { + failProtocolViolation( + currentRequestID: expectedRequestID, + error: .replyIdentityMismatch + ) + return + } + switch reply.outcome { + case .rejected(let rejection): + finishRejectedRequest( + expectedRequestID, + error: .workerRejected(rejection) + ) + case .completed(let response): + let responseLimit = min( + pending.admissionLease.shape.responseBytes, + limits.maximumResponseBytes + ) + guard response.count <= responseLimit else { + failProtocolViolation( + currentRequestID: expectedRequestID, + error: .responseTooLarge(limit: responseLimit, actual: response.count) + ) + return + } + finishCompletedRequest( + expectedRequestID, + response: response + ) + } + } + } + + private func finishRejectedRequest( + _ requestID: UInt64, + error: DoryFSWorkerBrokerError + ) { + guard let pending = removePendingRequest(requestID) else { return } + pending.continuation.resume(throwing: error) + sendDrainIfReady() + } + + private func finishCompletedRequest( + _ requestID: UInt64, + response: Data + ) { + guard let pending = pendingByRequestID.removeValue(forKey: requestID) else { return } + pending.timeoutTask?.cancel() + let now = DispatchTime.now().uptimeNanoseconds + guard now < pending.identity.deadlineUptimeNanoseconds else { + releaseReservation( + identity: pending.identity, + admissionLease: pending.admissionLease + ) + pending.continuation.resume( + throwing: DoryFSWorkerBrokerError.requestDeadlineExpired( + correlationID: pending.identity.correlationID + ) + ) + sendInvalidationFrame() + transitionToTerminal(.invalidated, error: .channelInvalidated) + channel.invalidate() + return + } + let publication: DoryFSWorkerPublication + do { + publication = try DoryFSWorkerPublication( + generation: generation, + shareCapabilityID: shareCapabilityID, + requestID: pending.identity.requestID, + correlationID: pending.identity.correlationID + ) + } catch { + releaseReservation( + identity: pending.identity, + admissionLease: pending.admissionLease + ) + pending.continuation.resume( + throwing: DoryFSWorkerBrokerError.replyIdentityMismatch + ) + failProtocolViolation(currentRequestID: nil, error: .replyIdentityMismatch) + return + } + let remaining = pending.identity.deadlineUptimeNanoseconds - now + pendingPublicationsByRequestID[requestID] = PendingPublication( + identity: pending.identity, + admissionLease: pending.admissionLease, + timeoutTask: nil + ) + let timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: remaining) + guard let self else { return } + await self.expirePublication(requestID) + } + pendingPublicationsByRequestID[requestID]?.timeoutTask = timeoutTask + completedRequests = saturatingAdd(completedRequests, 1) + pending.continuation.resume(returning: DoryFSWorkerExecution( + response: response, + publication: publication, + acknowledgementDeadlineUptimeNanoseconds: + pending.identity.deadlineUptimeNanoseconds + )) + } + + private func removePendingRequest(_ requestID: UInt64) -> PendingRequest? { + guard let pending = pendingByRequestID.removeValue(forKey: requestID) else { return nil } + pending.timeoutTask?.cancel() + releaseReservation( + identity: pending.identity, + admissionLease: pending.admissionLease + ) + return pending + } + + private func releaseReservation( + identity: RequestIdentity, + admissionLease: DoryFSWorkerAdmissionLease + ) { + requestIDByCorrelationID.removeValue(forKey: identity.correlationID) + admissionLease.release() + } + + private func expireRequest(_ requestID: UInt64) { + guard let pending = pendingByRequestID[requestID] else { return } + if let interrupt = try? DoryFSWorkerInterrupt( + generation: generation, + shareCapabilityID: shareCapabilityID, + targetRequestID: requestID, + targetCorrelationID: pending.identity.correlationID, + deadlineUptimeNanoseconds: pending.identity.deadlineUptimeNanoseconds + ), let frame = try? DoryFSWorkerFrameCodec.encode( + .interrupt(interrupt), + maximumFrameBytes: limits.maximumFrameBytes + ) { + sentInterrupts = saturatingAdd(sentInterrupts, 1) + channel.sendOneWay(frame: frame) + } + let expired = removePendingRequest(requestID) + expired?.continuation.resume(throwing: DoryFSWorkerBrokerError.requestDeadlineExpired( + correlationID: pending.identity.correlationID + )) + sendInvalidationFrame() + transitionToTerminal(.invalidated, error: .channelInvalidated) + channel.invalidate() + } + + private func acknowledgePublication( + _ publication: DoryFSWorkerPublication, + committed: Bool + ) throws { + guard state == .active || state == .draining else { + throw DoryFSWorkerBrokerError.notActive(state) + } + guard publication.generation == generation, + publication.shareCapabilityID == shareCapabilityID, + let pending = pendingPublicationsByRequestID[publication.requestID], + pending.identity.correlationID == publication.correlationID else { + throw DoryFSWorkerBrokerError.replyIdentityMismatch + } + guard DispatchTime.now().uptimeNanoseconds + < pending.identity.deadlineUptimeNanoseconds else { + expirePublication(publication.requestID) + throw DoryFSWorkerBrokerError.requestDeadlineExpired( + correlationID: publication.correlationID + ) + } + let frame = try DoryFSWorkerFrameCodec.encode( + committed + ? .commitPublication(publication) + : .discardPublication(publication), + maximumFrameBytes: limits.maximumFrameBytes + ) + guard let removed = pendingPublicationsByRequestID.removeValue( + forKey: publication.requestID + ) else { + throw DoryFSWorkerBrokerError.replyIdentityMismatch + } + removed.timeoutTask?.cancel() + releaseReservation( + identity: removed.identity, + admissionLease: removed.admissionLease + ) + channel.sendOneWay(frame: frame) + sendDrainIfReady() + } + + private func expirePublication(_ requestID: UInt64) { + guard let pending = pendingPublicationsByRequestID.removeValue( + forKey: requestID + ) else { return } + pending.timeoutTask?.cancel() + releaseReservation( + identity: pending.identity, + admissionLease: pending.admissionLease + ) + if let publication = try? DoryFSWorkerPublication( + generation: generation, + shareCapabilityID: shareCapabilityID, + requestID: pending.identity.requestID, + correlationID: pending.identity.correlationID + ), let frame = try? DoryFSWorkerFrameCodec.encode( + .discardPublication(publication), + maximumFrameBytes: limits.maximumFrameBytes + ) { + channel.sendOneWay(frame: frame) + } + sendInvalidationFrame() + transitionToTerminal(.invalidated, error: .channelInvalidated) + channel.invalidate() + } + + private func sendDrainIfReady() { + guard state == .draining, + pendingByRequestID.isEmpty, + pendingPublicationsByRequestID.isEmpty, + var drain = pendingDrain, + !drain.requestSent else { return } + drain.requestSent = true + pendingDrain = drain + do { + let frame = try DoryFSWorkerFrameCodec.encode( + .drain(try DoryFSWorkerDrain( + generation: generation, + shareCapabilityID: shareCapabilityID, + deadlineUptimeNanoseconds: drain.deadlineUptimeNanoseconds + )), + maximumFrameBytes: limits.maximumFrameBytes + ) + channel.send(frame: frame) { [weak self] result in + guard let self else { return } + Task { await self.receiveDrainReply(result) } + } + } catch { + failProtocolViolation(currentRequestID: nil, error: .drainReplyMismatch) + } + } + + private func receiveDrainReply(_ result: Result) { + guard state == .draining, let drain = pendingDrain else { + lateReplies = saturatingAdd(lateReplies, 1) + return + } + guard DispatchTime.now().uptimeNanoseconds < drain.deadlineUptimeNanoseconds else { + expireDrain() + return + } + switch result { + case .failure(.interrupted): + transitionToTerminal(.interrupted, error: .channelInterrupted) + channel.invalidate() + case .failure(.invalidated): + transitionToTerminal(.invalidated, error: .channelInvalidated) + case .failure(let failure): + pendingDrain = nil + state = .invalidated + admissionAuthority.invalidate(error: .channelFailure(failure)) + drain.timeoutTask?.cancel() + drain.continuation.resume(throwing: DoryFSWorkerBrokerError.channelFailure(failure)) + sendInvalidationFrame() + channel.invalidate() + case .success(let bytes): + do { + let frame = try DoryFSWorkerFrameCodec.decodeServiceFrame( + bytes, + maximumFrameBytes: limits.maximumFrameBytes + ) + guard case .drained(let ack) = frame, + ack.generation == generation, + ack.shareCapabilityID == shareCapabilityID else { + throw DoryFSWorkerBrokerError.drainReplyMismatch + } + pendingDrain = nil + state = .drained + drain.timeoutTask?.cancel() + drain.continuation.resume() + } catch { + failProtocolViolation(currentRequestID: nil, error: .drainReplyMismatch) + } + } + } + + private func expireDrain() { + guard state == .draining, let drain = pendingDrain else { return } + pendingDrain = nil + state = .invalidated + admissionAuthority.invalidate(error: .drainDeadlineExpired) + drain.timeoutTask?.cancel() + let requests = removeAllPendingRequests() + removeAllPendingPublications() + for pending in requests { + pending.continuation.resume(throwing: DoryFSWorkerBrokerError.channelInvalidated) + } + drain.continuation.resume(throwing: DoryFSWorkerBrokerError.drainDeadlineExpired) + sendInvalidationFrame() + channel.invalidate() + } + + private func receiveChannelEvent(_ event: DoryFSWorkerChannelEvent) { + switch event { + case .interrupted: + transitionToTerminal(.interrupted, error: .channelInterrupted) + channel.invalidate() + case .invalidated: + transitionToTerminal(.invalidated, error: .channelInvalidated) + } + } + + private func failProtocolViolation( + currentRequestID: UInt64?, + error: DoryFSWorkerBrokerError + ) { + protocolViolations = saturatingAdd(protocolViolations, 1) + if let currentRequestID, let current = removePendingRequest(currentRequestID) { + current.continuation.resume(throwing: error) + } + transitionToTerminal(.protocolViolation, error: .channelInvalidated) + sendInvalidationFrame() + channel.invalidate() + } + + private func transitionToTerminal( + _ newState: DoryFSWorkerBrokerState, + error: DoryFSWorkerBrokerError + ) { + guard state == .active || state == .draining || state == .drained else { return } + admissionAuthority.invalidate(error: error) + state = newState + let requests = removeAllPendingRequests() + removeAllPendingPublications() + let drain = pendingDrain + pendingDrain = nil + drain?.timeoutTask?.cancel() + for pending in requests { + pending.continuation.resume(throwing: error) + } + drain?.continuation.resume(throwing: error) + } + + private func removeAllPendingRequests() -> [PendingRequest] { + let requests = Array(pendingByRequestID.values) + for request in requests { + request.timeoutTask?.cancel() + request.admissionLease.release() + } + pendingByRequestID.removeAll(keepingCapacity: true) + requestIDByCorrelationID.removeAll(keepingCapacity: true) + return requests + } + + private func removeAllPendingPublications() { + let publications = Array(pendingPublicationsByRequestID.values) + for publication in publications { + publication.timeoutTask?.cancel() + publication.admissionLease.release() + } + pendingPublicationsByRequestID.removeAll(keepingCapacity: true) + requestIDByCorrelationID.removeAll(keepingCapacity: true) + } + + private func validateOperationDeadline(_ deadline: UInt64, now: UInt64) throws -> UInt64 { + guard deadline > now else { throw reject(.operationDeadlineExpired) } + let remaining = deadline - now + guard remaining <= limits.maximumOperationNanoseconds else { + throw reject(.operationDeadlineTooDistant( + limitNanoseconds: limits.maximumOperationNanoseconds, + actualNanoseconds: remaining + )) + } + return remaining + } + + private func sendInvalidationFrame() { + let invalidation = DoryFSWorkerInvalidation( + generation: generation, + shareCapabilityID: shareCapabilityID + ) + if let frame = try? DoryFSWorkerFrameCodec.encode( + .invalidate(invalidation), + maximumFrameBytes: limits.maximumFrameBytes + ) { + channel.sendOneWay(frame: frame) + } + } + + private func validateDrainDeadline(_ deadline: UInt64, now: UInt64) throws -> UInt64 { + guard deadline > now else { throw DoryFSWorkerBrokerError.drainDeadlineExpired } + let remaining = deadline - now + guard remaining <= limits.maximumDrainNanoseconds else { + throw DoryFSWorkerBrokerError.drainDeadlineTooDistant( + limitNanoseconds: limits.maximumDrainNanoseconds, + actualNanoseconds: remaining + ) + } + return remaining + } + + private func reject(_ error: DoryFSWorkerBrokerError) -> DoryFSWorkerBrokerError { + rejectedAdmissions = saturatingAdd(rejectedAdmissions, 1) + return error + } + + private func saturatingAdd(_ value: UInt64, _ increment: UInt64) -> UInt64 { + let (sum, overflow) = value.addingReportingOverflow(increment) + return overflow ? UInt64.max : sum + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerCoherenceSink.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerCoherenceSink.swift new file mode 100644 index 00000000..28893857 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerCoherenceSink.swift @@ -0,0 +1,394 @@ +import DoryFSWorkerContracts +import Foundation + +public enum DoryFSWorkerCoherenceSinkError: Error, Equatable, Sendable { + case malformedFrame + case staleGeneration + case unknownCapability + case handlerUnavailable + case batchSequenceViolation + case conflictingReplay + case inFlightLimit(limit: Int) + case deliveryFailed +} + +public struct DoryFSWorkerCoherenceSinkStatistics: Equatable, Sendable { + public let receivedBatchCount: UInt64 + public let replayedBatchCount: UInt64 + public let completedBatchCount: UInt64 + public let failedBatchCount: UInt64 + public let inFlightBatchCount: Int + public let peakInFlightBatchCount: Int + public let receivedBytes: UInt64 + public let acknowledgementBytes: UInt64 + public let totalLatencyNanoseconds: UInt64 + public let maximumLatencyNanoseconds: UInt64 + public let terminalFailureLatched: Bool +} + +/// Data-only reverse XPC receiver. It binds every batch to the bootstrapped generation and +/// capability set, retains a bounded exact replay ledger, and emits an ACK only after the runner's +/// invalidation + guest-watcher transaction has completed. +final class DoryFSWorkerCoherenceXPCSink: + NSObject, + DoryFSWorkerCoherenceSinkXPCProtocol, + @unchecked Sendable +{ + typealias Handler = @Sendable (DoryFSWorkerCoherenceBatch) async throws -> Void + typealias Failure = @Sendable (DoryFSWorkerCoherenceSinkError) -> Void + + private final class HandlerBox: @unchecked Sendable { + private let lock = NSLock() + private var handler: Handler? + + func install(_ handler: @escaping Handler) -> Bool { + lock.withLock { + guard self.handler == nil else { return false } + self.handler = handler + return true + } + } + + func current() -> Handler? { lock.withLock { handler } } + } + + private final class StatisticsBox: @unchecked Sendable { + private let lock = NSLock() + private var received: UInt64 = 0 + private var replayed: UInt64 = 0 + private var completed: UInt64 = 0 + private var failed: UInt64 = 0 + private var inFlight = 0 + private var peakInFlight = 0 + private var receivedBytes: UInt64 = 0 + private var acknowledgementBytes: UInt64 = 0 + private var totalLatencyNanoseconds: UInt64 = 0 + private var maximumLatencyNanoseconds: UInt64 = 0 + private var terminal = false + + func begin(byteCount: Int, replay: Bool) { + lock.withLock { + received = Self.add(received, 1) + receivedBytes = Self.add(receivedBytes, UInt64(clamping: byteCount)) + if replay { replayed = Self.add(replayed, 1) } + } + } + + func beginUnique() { + lock.withLock { + inFlight += 1 + peakInFlight = max(peakInFlight, inFlight) + } + } + + func finish(replyBytes: Int, latencyNanoseconds: UInt64, succeeded: Bool) { + lock.withLock { + inFlight = max(0, inFlight - 1) + totalLatencyNanoseconds = Self.add(totalLatencyNanoseconds, latencyNanoseconds) + maximumLatencyNanoseconds = max(maximumLatencyNanoseconds, latencyNanoseconds) + if succeeded { + completed = Self.add(completed, 1) + acknowledgementBytes = Self.add( + acknowledgementBytes, + UInt64(clamping: replyBytes) + ) + } else { + failed = Self.add(failed, 1) + } + } + } + + func failTerminal() { + lock.withLock { + terminal = true + failed = Self.add(failed, 1) + } + } + + var snapshot: DoryFSWorkerCoherenceSinkStatistics { + lock.withLock { + DoryFSWorkerCoherenceSinkStatistics( + receivedBatchCount: received, + replayedBatchCount: replayed, + completedBatchCount: completed, + failedBatchCount: failed, + inFlightBatchCount: inFlight, + peakInFlightBatchCount: peakInFlight, + receivedBytes: receivedBytes, + acknowledgementBytes: acknowledgementBytes, + totalLatencyNanoseconds: totalLatencyNanoseconds, + maximumLatencyNanoseconds: maximumLatencyNanoseconds, + terminalFailureLatched: terminal + ) + } + } + + private static func add(_ left: UInt64, _ right: UInt64) -> UInt64 { + let (value, overflow) = left.addingReportingOverflow(right) + return overflow ? UInt64.max : value + } + } + + private actor Processor { + private struct Key: Hashable, Sendable { + let generation: UInt64 + let capability: UUID + let batchID: UInt64 + } + + private enum Outcome: Sendable { + case success(Data) + case failure + } + + private enum Entry: Sendable { + case running(exactFrame: Data, task: Task) + case completed(exactFrame: Data, acknowledgement: Data) + } + + private static let completedLedgerLimit = 64 + private static let inFlightLimit = 8 + + private let expectedGeneration: DoryFSWorkerGeneration + private let capabilities: Set + private let handlerBox: HandlerBox + private let statistics: StatisticsBox + private let onFailure: Failure + private var entries = [Key: Entry]() + private var completedOrder = [Key]() + private var inFlightCount = 0 + private var lastAcceptedBatchID: UInt64? + private var terminal = false + + init( + expectedGeneration: DoryFSWorkerGeneration, + capabilities: Set, + handlerBox: HandlerBox, + statistics: StatisticsBox, + onFailure: @escaping Failure + ) { + self.expectedGeneration = expectedGeneration + self.capabilities = capabilities + self.handlerBox = handlerBox + self.statistics = statistics + self.onFailure = onFailure + } + + func receive(_ exactFrame: Data) async -> Data { + guard !terminal else { return Data() } + let batch: DoryFSWorkerCoherenceBatch + do { + batch = try DoryFSWorkerCoherenceCodec.decodeBatch(exactFrame) + } catch { + fail(.malformedFrame) + return Data() + } + guard batch.generation == expectedGeneration else { + fail(.staleGeneration) + return Data() + } + guard capabilities.contains(batch.shareCapabilityID) else { + fail(.unknownCapability) + return Data() + } + let key = Key( + generation: batch.generation.rawValue, + capability: batch.shareCapabilityID.rawValue, + batchID: batch.batchID + ) + if let existing = entries[key] { + statistics.begin(byteCount: exactFrame.count, replay: true) + switch existing { + case .running(let retainedFrame, let task): + guard retainedFrame == exactFrame else { + fail(.conflictingReplay) + return Data() + } + return await reply(for: await task.value, key: key, exactFrame: exactFrame) + case .completed(let retainedFrame, let acknowledgement): + guard retainedFrame == exactFrame else { + fail(.conflictingReplay) + return Data() + } + return acknowledgement + } + } + + guard sequenceAccepts(batch.batchID) else { + fail(.batchSequenceViolation) + return Data() + } + guard inFlightCount < Self.inFlightLimit else { + fail(.inFlightLimit(limit: Self.inFlightLimit)) + return Data() + } + guard let handler = handlerBox.current() else { + fail(.handlerUnavailable) + return Data() + } + + statistics.begin(byteCount: exactFrame.count, replay: false) + statistics.beginUnique() + inFlightCount += 1 + lastAcceptedBatchID = batch.batchID + let started = ContinuousClock.now + let task = Task.detached(priority: .userInitiated) { () -> Outcome in + do { + try await handler(batch) + let acknowledgement = try DoryFSWorkerCoherenceAcknowledgement( + accepting: batch + ) + return .success(DoryFSWorkerCoherenceCodec.encode(acknowledgement)) + } catch { + return .failure + } + } + entries[key] = .running(exactFrame: exactFrame, task: task) + let outcome = await task.value + let elapsed = started.duration(to: .now) + let nanoseconds = Self.nanoseconds(elapsed) + inFlightCount = max(0, inFlightCount - 1) + switch outcome { + case .success(let acknowledgement): + if case .running = entries[key] { + entries[key] = .completed( + exactFrame: exactFrame, + acknowledgement: acknowledgement + ) + completedOrder.append(key) + evictCompletedIfNeeded() + } + statistics.finish( + replyBytes: acknowledgement.count, + latencyNanoseconds: nanoseconds, + succeeded: true + ) + return acknowledgement + case .failure: + entries.removeValue(forKey: key) + statistics.finish( + replyBytes: 0, + latencyNanoseconds: nanoseconds, + succeeded: false + ) + fail(.deliveryFailed, recordStatistics: false) + return Data() + } + } + + private func reply( + for outcome: Outcome, + key: Key, + exactFrame: Data + ) async -> Data { + switch outcome { + case .success(let acknowledgement): + // The original waiter normally records the completed ledger first. Actor + // reentrancy allows a replay waiter to resume first, so make completion idempotent. + if case .running = entries[key] { + entries[key] = .completed( + exactFrame: exactFrame, + acknowledgement: acknowledgement + ) + completedOrder.append(key) + evictCompletedIfNeeded() + } + return acknowledgement + case .failure: + return Data() + } + } + + private func sequenceAccepts(_ batchID: UInt64) -> Bool { + guard let previous = lastAcceptedBatchID else { return batchID != 0 } + let expected = previous == UInt64.max ? 1 : previous + 1 + return batchID == expected + } + + private func evictCompletedIfNeeded() { + while completedOrder.count > Self.completedLedgerLimit { + let oldest = completedOrder.removeFirst() + if case .completed? = entries[oldest] { + entries.removeValue(forKey: oldest) + } + } + } + + private func fail( + _ error: DoryFSWorkerCoherenceSinkError, + recordStatistics: Bool = true + ) { + guard !terminal else { return } + terminal = true + if recordStatistics { statistics.failTerminal() } + onFailure(error) + } + + private static func nanoseconds(_ duration: Duration) -> UInt64 { + let components = duration.components + guard components.seconds >= 0 else { return 0 } + let seconds = UInt64(clamping: components.seconds) + let attoseconds = max(Int64(0), components.attoseconds) + let nanos = UInt64(attoseconds / 1_000_000_000) + let (scaled, overflow) = seconds.multipliedReportingOverflow(by: 1_000_000_000) + if overflow { return UInt64.max } + let (total, additionOverflow) = scaled.addingReportingOverflow(nanos) + return additionOverflow ? UInt64.max : total + } + } + + private let handlerBox = HandlerBox() + private let statisticsBox = StatisticsBox() + private let processor: Processor + + init( + expectedGeneration: DoryFSWorkerGeneration, + capabilities: Set, + onFailure: @escaping Failure + ) { + processor = Processor( + expectedGeneration: expectedGeneration, + capabilities: capabilities, + handlerBox: handlerBox, + statistics: statisticsBox, + onFailure: onFailure + ) + super.init() + } + + @discardableResult + func installHandler(_ handler: @escaping Handler) -> Bool { + handlerBox.install(handler) + } + + var statistics: DoryFSWorkerCoherenceSinkStatistics { statisticsBox.snapshot } + + func deliverCoherence(_ frame: Data, withReply reply: @escaping (Data) -> Void) { + let once = DoryFSWorkerCoherenceReplyOnce(reply) + Task { + let response = await processor.receive(frame) + once.send(response) + } + } + + func deliverForTesting(_ frame: Data) async -> Data { + await processor.receive(frame) + } +} + +private final class DoryFSWorkerCoherenceReplyOnce: @unchecked Sendable { + private let lock = NSLock() + private var reply: ((Data) -> Void)? + + init(_ reply: @escaping (Data) -> Void) { + self.reply = reply + } + + func send(_ data: Data) { + let callback = lock.withLock { () -> ((Data) -> Void)? in + defer { reply = nil } + return reply + } + callback?(data) + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerXPCChannel.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerXPCChannel.swift new file mode 100644 index 00000000..19af5ea1 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DoryFSWorkerXPCChannel.swift @@ -0,0 +1,576 @@ +import DoryFSWorkerContracts +import Foundation + +public enum DoryFSWorkerWorkspaceClientError: Error, Equatable, Sendable { + case invalidBootstrap(DoryFSWorkerBootstrapError) + case bootstrapRejected(DoryFSWorkerBootstrapRejectionReason) + case channelFailure(DoryFSWorkerChannelFailure) + case malformedResult(DoryFSWorkerRPCResultError) + case receiptMismatch + case unknownShare(DoryFSShareCapabilityID) + case bootstrapTimedOut + case coherenceStatusTimedOut + case malformedCoherenceStatus +} + +/// One authenticated connection to the runner-local signed filesystem service. The class unwraps +/// only the exact RPC-result envelope; `DoryFSWorkerBroker` remains responsible for validating the +/// inner service frame against its generation, capability, request, correlation, and limits. +public final class DoryFSWorkerXPCChannel: + NSObject, + DoryFSWorkerChannel, + @unchecked Sendable +{ + private static let workerCodeSigningRequirement = + #"anchor apple generic and identifier "com.pythonxi.Dory.HVRunner.FSWorker" and certificate leaf[subject.OU] = "864H636QW4""# + + private enum State { + case active + case interrupted + case invalidated + case closed + } + + private final class ReplyOnce: @unchecked Sendable { + private let lock = NSLock() + private var completed = false + + func claim() -> Bool { + lock.withLock { + guard !completed else { return false } + completed = true + return true + } + } + } + + private final class CoherenceFailureRelay: @unchecked Sendable { + private let lock = NSLock() + private var handler: (@Sendable (DoryFSWorkerCoherenceSinkError) -> Void)? + private var pending: DoryFSWorkerCoherenceSinkError? + + func install( + _ handler: @escaping @Sendable (DoryFSWorkerCoherenceSinkError) -> Void + ) { + let immediate = lock.withLock { () -> DoryFSWorkerCoherenceSinkError? in + self.handler = handler + defer { pending = nil } + return pending + } + if let immediate { handler(immediate) } + } + + func report(_ error: DoryFSWorkerCoherenceSinkError) { + let callback = lock.withLock { () -> (@Sendable ( + DoryFSWorkerCoherenceSinkError + ) -> Void)? in + guard pending == nil else { return nil } + pending = error + return handler + } + callback?(error) + } + } + + private let connection: NSXPCConnection + private let coherenceSink: DoryFSWorkerCoherenceXPCSink + private let coherenceFailureRelay: CoherenceFailureRelay + private let stateLock = NSLock() + private var state: State = .active + private var lifecycleHandlers = [@Sendable (DoryFSWorkerChannelEvent) -> Void]() + + init( + expectedGeneration: DoryFSWorkerGeneration, + capabilities: Set + ) { + let failureRelay = CoherenceFailureRelay() + coherenceFailureRelay = failureRelay + coherenceSink = DoryFSWorkerCoherenceXPCSink( + expectedGeneration: expectedGeneration, + capabilities: capabilities, + onFailure: { failureRelay.report($0) } + ) + connection = NSXPCConnection(serviceName: DoryFSWorkerXPC.serviceName) + super.init() + failureRelay.install { [weak self] _ in self?.invalidate() } + connection.remoteObjectInterface = DoryFSWorkerXPCInterface.make() + connection.exportedInterface = DoryFSWorkerXPCInterface.makeCoherenceSink() + connection.exportedObject = coherenceSink + connection.interruptionHandler = { [weak self] in + self?.transition(to: .interrupted) + } + connection.invalidationHandler = { [weak self] in + self?.transition(to: .invalidated) + } + connection.setCodeSigningRequirement(Self.workerCodeSigningRequirement) + connection.resume() + } + + @discardableResult + public func installCoherenceHandler( + _ handler: @escaping @Sendable (DoryFSWorkerCoherenceBatch) async throws -> Void + ) -> Bool { + coherenceSink.installHandler(handler) + } + + public var coherenceStatistics: DoryFSWorkerCoherenceSinkStatistics { + coherenceSink.statistics + } + + public func coherenceStatus( + timeout: TimeInterval = 1 + ) throws -> DoryFSWorkerCoherenceStatus { + let state = BlockingCoherenceStatus() + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ [weak self] _ in + state.resolve(nil) + self?.transition(to: .interrupted) + }) as? DoryFSWorkerXPCProtocol else { + throw DoryFSWorkerWorkspaceClientError.malformedCoherenceStatus + } + proxy.coherenceStatus { bytes in state.resolve(bytes) } + let result = state.condition.withLock { () -> (Bool, Data?) in + let deadline = Date(timeIntervalSinceNow: max(0, timeout)) + while !state.completed, state.condition.wait(until: deadline) {} + return (state.completed, state.bytes) + } + guard result.0 else { + throw DoryFSWorkerWorkspaceClientError.coherenceStatusTimedOut + } + guard let bytes = result.1, + let status = try? DoryFSWorkerCoherenceStatusCodec.decode(bytes) else { + throw DoryFSWorkerWorkspaceClientError.malformedCoherenceStatus + } + return status + } + + public func activateCoherence( + timeout: TimeInterval = Double( + DoryFSWorkerCoherenceTiming.activationRequestNanoseconds + ) / 1_000_000_000 + ) throws -> DoryFSWorkerCoherenceStatus { + let state = BlockingCoherenceStatus() + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ [weak self] _ in + state.resolve(nil) + self?.transition(to: .interrupted) + }) as? DoryFSWorkerXPCProtocol else { + throw DoryFSWorkerWorkspaceClientError.malformedCoherenceStatus + } + proxy.activateCoherence { bytes in state.resolve(bytes) } + let result = state.condition.withLock { () -> (Bool, Data?) in + let deadline = Date(timeIntervalSinceNow: max(0, timeout)) + while !state.completed, state.condition.wait(until: deadline) {} + return (state.completed, state.bytes) + } + guard result.0 else { + invalidate() + throw DoryFSWorkerWorkspaceClientError.coherenceStatusTimedOut + } + guard let bytes = result.1, + let status = try? DoryFSWorkerCoherenceStatusCodec.decode(bytes) else { + invalidate() + throw DoryFSWorkerWorkspaceClientError.malformedCoherenceStatus + } + return status + } + + public func prepareCoherence( + timeout: TimeInterval = Double( + DoryFSWorkerCoherenceTiming.preparationRequestNanoseconds + ) / 1_000_000_000 + ) throws -> DoryFSWorkerCoherenceStatus { + let state = BlockingCoherenceStatus() + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ [weak self] _ in + state.resolve(nil) + self?.transition(to: .interrupted) + }) as? DoryFSWorkerXPCProtocol else { + throw DoryFSWorkerWorkspaceClientError.malformedCoherenceStatus + } + proxy.prepareCoherence { bytes in state.resolve(bytes) } + let result = state.condition.withLock { () -> (Bool, Data?) in + let deadline = Date(timeIntervalSinceNow: max(0, timeout)) + while !state.completed, state.condition.wait(until: deadline) {} + return (state.completed, state.bytes) + } + guard result.0 else { + invalidate() + throw DoryFSWorkerWorkspaceClientError.coherenceStatusTimedOut + } + guard let bytes = result.1, + let status = try? DoryFSWorkerCoherenceStatusCodec.decode(bytes) else { + invalidate() + throw DoryFSWorkerWorkspaceClientError.malformedCoherenceStatus + } + return status + } + + private final class BlockingCoherenceStatus: @unchecked Sendable { + let condition = NSCondition() + var completed = false + var bytes: Data? + + func resolve(_ bytes: Data?) { + condition.withLock { + guard !completed else { return } + completed = true + self.bytes = bytes + condition.broadcast() + } + } + } + + public func installLifecycleHandler( + _ handler: @escaping @Sendable (DoryFSWorkerChannelEvent) -> Void + ) { + let immediate: DoryFSWorkerChannelEvent? = stateLock.withLock { + switch state { + case .active: + lifecycleHandlers.append(handler) + return nil + case .interrupted: + return .interrupted + case .invalidated: + return .invalidated + case .closed: + return nil + } + } + if let immediate { handler(immediate) } + } + + public func bootstrap( + exactBytes: Data, + rootDescriptors: [FileHandle], + completion: @escaping @Sendable ( + Result + ) -> Void + ) { + guard stateLock.withLock({ if case .active = state { true } else { false } }) else { + completion(.failure(.channelFailure(.unavailable))) + return + } + let once = ReplyOnce() + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ [weak self] _ in + guard once.claim() else { return } + completion(.failure(.channelFailure(.unavailable))) + self?.transition(to: .interrupted) + }) as? DoryFSWorkerXPCProtocol else { + completion(.failure(.channelFailure(.unavailable))) + transition(to: .interrupted) + return + } + proxy.bootstrap(exactBytes, rootDescriptors: rootDescriptors) { [weak self] bytes in + guard once.claim() else { return } + switch Self.unwrapBootstrapResult(bytes) { + case .success(let payload): + completion(.success(payload)) + case .failure(let error): + completion(.failure(error)) + self?.invalidate() + } + } + } + + public func send( + frame: Data, + completion: @escaping @Sendable ( + Result + ) -> Void + ) { + guard stateLock.withLock({ if case .active = state { true } else { false } }) else { + completion(.failure(.unavailable)) + return + } + let once = ReplyOnce() + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ [weak self] _ in + guard once.claim() else { return } + completion(.failure(.unavailable)) + self?.transition(to: .interrupted) + }) as? DoryFSWorkerXPCProtocol else { + completion(.failure(.unavailable)) + transition(to: .interrupted) + return + } + proxy.exchange(frame) { [weak self] bytes in + guard once.claim() else { return } + do { + switch try DoryFSWorkerRPCResultCodec.decode(bytes) { + case .success(let payload): + completion(.success(payload)) + case .failure(let code): + completion(.failure(.serviceFailure(code))) + } + } catch { + completion(.failure(.unavailable)) + self?.invalidate() + } + } + } + + public func sendOneWay(frame: Data) { + guard stateLock.withLock({ if case .active = state { true } else { false } }), + let proxy = connection.remoteObjectProxyWithErrorHandler({ [weak self] _ in + self?.transition(to: .interrupted) + }) as? DoryFSWorkerXPCProtocol else { return } + proxy.sendOneWay(frame) + } + + public func invalidate() { + transition(to: .invalidated) + connection.invalidate() + } + + /// Retires an intentionally completed worker without translating normal XPC invalidation into + /// a guest crash. Unexpected interruption and all failure-driven invalidation still use the + /// lifecycle path above and remain fail-stop. + public func close() { + let shouldClose = stateLock.withLock { () -> Bool in + guard case .active = state else { return false } + state = .closed + lifecycleHandlers.removeAll(keepingCapacity: false) + return true + } + if shouldClose { connection.invalidate() } + } + + private func transition(to requested: State) { + let delivery: ( + handlers: [@Sendable (DoryFSWorkerChannelEvent) -> Void], + event: DoryFSWorkerChannelEvent + )? = stateLock.withLock { + guard case .active = state else { return nil } + state = requested + let handlers = lifecycleHandlers + lifecycleHandlers.removeAll(keepingCapacity: false) + switch requested { + case .active: + return nil + case .interrupted: + return (handlers, .interrupted) + case .invalidated: + return (handlers, .invalidated) + case .closed: + return nil + } + } + if let delivery { + for handler in delivery.handlers { handler(delivery.event) } + } + } + + static func unwrapBootstrapResult( + _ bytes: Data + ) -> Result { + do { + switch try DoryFSWorkerRPCResultCodec.decode(bytes) { + case .success(let payload): + return .success(payload) + case .failure(let code): + if let reason = code.bootstrapRejectionReason { + return .failure(.bootstrapRejected(reason)) + } + return .failure(.channelFailure(.serviceFailure(code))) + } + } catch let error as DoryFSWorkerRPCResultError { + return .failure(.malformedResult(error)) + } catch { + return .failure(.channelFailure(.unavailable)) + } + } +} + +/// Validates a one-shot bootstrap receipt before exposing any share broker. All brokers retain the +/// same workspace channel, so interruption, malformed service data, or one share's fail-stop +/// invalidation tears down the complete worker authority generation. +public final class DoryFSWorkerWorkspaceClient: @unchecked Sendable { + private final class BlockingBootstrap: @unchecked Sendable { + let condition = NSCondition() + var result: Result? + } + public let bootstrap: DoryFSWorkerBootstrap + private let channel: DoryFSWorkerXPCChannel + private let admissionAuthority: DoryFSWorkerWorkspaceAdmissionAuthority + + private init( + bootstrap: DoryFSWorkerBootstrap, + channel: DoryFSWorkerXPCChannel + ) { + self.bootstrap = bootstrap + self.channel = channel + self.admissionAuthority = DoryFSWorkerWorkspaceAdmissionAuthority( + workerLimits: bootstrap.workerLimits, + shareLimits: Dictionary( + uniqueKeysWithValues: bootstrap.shares.map { + ($0.capabilityID, $0.resourceLimits) + } + ) + ) + } + + public static func connect( + exactBootstrapBytes: Data, + rootDescriptors: [FileHandle] + ) async throws -> Self { + let bootstrap: DoryFSWorkerBootstrap + do { + bootstrap = try DoryFSWorkerBootstrapCodec.decode(exactBootstrapBytes) + } catch let error as DoryFSWorkerBootstrapError { + throw DoryFSWorkerWorkspaceClientError.invalidBootstrap(error) + } + let channel = DoryFSWorkerXPCChannel( + expectedGeneration: bootstrap.generation, + capabilities: Set(bootstrap.shares.map(\.capabilityID)) + ) + let receiptBytes: Data = try await withCheckedThrowingContinuation { continuation in + channel.bootstrap( + exactBytes: exactBootstrapBytes, + rootDescriptors: rootDescriptors + ) { result in + continuation.resume(with: result) + } + } + let receipt: DoryFSWorkerBootstrapReceipt + do { + receipt = try DoryFSWorkerBootstrapCodec.decodeReceipt(receiptBytes) + } catch { + channel.invalidate() + throw DoryFSWorkerWorkspaceClientError.receiptMismatch + } + guard receipt == DoryFSWorkerBootstrapReceipt(accepting: bootstrap) else { + channel.invalidate() + throw DoryFSWorkerWorkspaceClientError.receiptMismatch + } + return Self(bootstrap: bootstrap, channel: channel) + } + + public static func connectBlocking( + exactBootstrapBytes: Data, + rootDescriptors: [FileHandle], + timeout: TimeInterval = 30 + ) throws -> Self { + let bootstrap: DoryFSWorkerBootstrap + do { + bootstrap = try DoryFSWorkerBootstrapCodec.decode(exactBootstrapBytes) + } catch let error as DoryFSWorkerBootstrapError { + throw DoryFSWorkerWorkspaceClientError.invalidBootstrap(error) + } + let channel = DoryFSWorkerXPCChannel( + expectedGeneration: bootstrap.generation, + capabilities: Set(bootstrap.shares.map(\.capabilityID)) + ) + let blocking = BlockingBootstrap() + channel.bootstrap( + exactBytes: exactBootstrapBytes, + rootDescriptors: rootDescriptors + ) { result in + blocking.condition.withLock { + guard blocking.result == nil else { return } + blocking.result = result + blocking.condition.broadcast() + } + } + let result: Result? = + blocking.condition.withLock { + let deadline = Date(timeIntervalSinceNow: max(0, timeout)) + while blocking.result == nil, blocking.condition.wait(until: deadline) {} + return blocking.result + } + guard let result else { + channel.invalidate() + throw DoryFSWorkerWorkspaceClientError.bootstrapTimedOut + } + let receiptBytes = try result.get() + guard let receipt = try? DoryFSWorkerBootstrapCodec.decodeReceipt(receiptBytes), + receipt == DoryFSWorkerBootstrapReceipt(accepting: bootstrap) else { + channel.invalidate() + throw DoryFSWorkerWorkspaceClientError.receiptMismatch + } + return Self(bootstrap: bootstrap, channel: channel) + } + + public func broker( + for capabilityID: DoryFSShareCapabilityID + ) throws -> DoryFSWorkerBroker { + guard let share = bootstrap.shares.first(where: { $0.capabilityID == capabilityID }) else { + throw DoryFSWorkerWorkspaceClientError.unknownShare(capabilityID) + } + return DoryFSWorkerBroker( + shareCapabilityID: capabilityID, + generation: bootstrap.generation, + limits: bootstrap.workerLimits, + shareResourceLimits: share.resourceLimits, + admissionAuthority: admissionAuthority, + channel: channel + ) + } + + @discardableResult + public func installCoherenceHandler( + _ handler: @escaping @Sendable (DoryFSWorkerCoherenceBatch) async throws -> Void + ) -> Bool { + channel.installCoherenceHandler(handler) + } + + public func installLifecycleHandler( + _ handler: @escaping @Sendable (DoryFSWorkerChannelEvent) -> Void + ) { + channel.installLifecycleHandler(handler) + } + + public var coherenceStatistics: DoryFSWorkerCoherenceSinkStatistics { + channel.coherenceStatistics + } + + public func coherenceStatus( + timeout: TimeInterval = 1 + ) throws -> DoryFSWorkerCoherenceStatus { + try channel.coherenceStatus(timeout: timeout) + } + + public func prepareCoherence( + timeout: TimeInterval = Double( + DoryFSWorkerCoherenceTiming.preparationRequestNanoseconds + ) / 1_000_000_000 + ) throws { + let status = try channel.prepareCoherence(timeout: timeout) + let configuredCoherenceShareCount = UInt32(bootstrap.shares.filter { + $0.coherencePolicy != .disabled + }.count) + let preparedStateIsValid = configuredCoherenceShareCount == 0 + ? status.running && status.observationStreamCount == 0 + : !status.running && status.observationStreamCount > 0 + guard status.generation == bootstrap.generation, + status.configuredShareCount == configuredCoherenceShareCount, + status.requiredObservationShareCount == status.observedRequiredShareCount, + preparedStateIsValid else { + channel.invalidate() + throw DoryFSWorkerWorkspaceClientError.malformedCoherenceStatus + } + } + + public func activateCoherence( + timeout: TimeInterval = Double( + DoryFSWorkerCoherenceTiming.activationRequestNanoseconds + ) / 1_000_000_000 + ) throws { + let status = try channel.activateCoherence(timeout: timeout) + let configuredCoherenceShareCount = UInt32(bootstrap.shares.filter { + $0.coherencePolicy != .disabled + }.count) + guard status.generation == bootstrap.generation, + status.running, + status.configuredShareCount == configuredCoherenceShareCount, + configuredCoherenceShareCount == 0 || status.observationStreamCount > 0, + status.requiredObservationShareCount == status.observedRequiredShareCount else { + channel.invalidate() + throw DoryFSWorkerWorkspaceClientError.malformedCoherenceStatus + } + } + + public func invalidate() { + channel.invalidate() + } + + public func close() { + channel.close() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FileBackedDaxMappingBackend.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FileBackedDaxMappingBackend.swift deleted file mode 100644 index d186fa4e..00000000 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FileBackedDaxMappingBackend.swift +++ /dev/null @@ -1,83 +0,0 @@ -import Darwin -import Foundation -import Hypervisor - -public final class FileBackedDaxMappingBackend: DaxMappingBackend, @unchecked Sendable { - private struct Region { - var hostAddress: UnsafeMutableRawPointer - var length: Int - } - - private let lock = NSLock() - private var regions: [Key: Region] = [:] - - public init() {} - - public func map(_ mapping: DaxMapping, fileDescriptor: Int32, guestAddress: UInt64) throws { - let length = try intLength(mapping.length) - let protections = protections(for: mapping.flags) - let hostAddress = mmap(nil, length, protections, MAP_SHARED, fileDescriptor, off_t(mapping.fileOffset)) - guard let hostAddress, hostAddress != MAP_FAILED else { - throw DaxWindowError.mappingFailed("mmap failed: errno \(errno)") - } - - let hvFlags = hv_memory_flags_t(hvFlags(for: mapping.flags)) - let result = hv_vm_map(hostAddress, guestAddress, length, hvFlags) - guard result == HV_SUCCESS else { - munmap(hostAddress, length) - throw DaxWindowError.mappingFailed("hv_vm_map(host=\(hostAddress) gpa=0x\(String(guestAddress, radix: 16)) len=0x\(String(length, radix: 16)) flags=0x\(String(hvFlags, radix: 16))) -> 0x\(String(UInt32(bitPattern: result), radix: 16))") - } - - lock.withLock { - regions[Key(memoryOffset: mapping.memoryOffset, length: mapping.length)] = Region(hostAddress: hostAddress, length: length) - } - } - - public func unmap(_ mapping: DaxMapping, guestAddress: UInt64) throws { - let key = Key(memoryOffset: mapping.memoryOffset, length: mapping.length) - guard let region = lock.withLock({ regions.removeValue(forKey: key) }) else { - throw DaxWindowError.unmappingFailed("mapping not found") - } - let unmapResult = hv_vm_unmap(guestAddress, region.length) - let munmapResult = munmap(region.hostAddress, region.length) - guard unmapResult == HV_SUCCESS, munmapResult == 0 else { - throw DaxWindowError.unmappingFailed("hv_vm_unmap \(unmapResult), munmap errno \(errno)") - } - } - - private func intLength(_ value: UInt64) throws -> Int { - guard value <= UInt64(Int.max) else { - throw DaxWindowError.mappingFailed("mapping length overflows Int") - } - return Int(value) - } - - private func protections(for flags: UInt64) -> Int32 { - // Map the host region read+write regardless of the guest's requested access. Apple's - // hv_vm_map rejects a host region that is not writable (HV_ERROR); the guest's stage-2 - // protection below still restricts the guest to what it asked for, so a read-only DAX - // mapping cannot be used by the guest to modify the host file. - return PROT_READ | PROT_WRITE - } - - private func hvFlags(for flags: UInt64) -> UInt32 { - var hvFlags = HV_MEMORY_READ - if flags & FuseSetupMappingFlag.write.rawValue != 0 { - hvFlags |= HV_MEMORY_WRITE - } - return UInt32(hvFlags) - } - - private struct Key: Hashable { - var memoryOffset: UInt64 - var length: UInt64 - } -} - -private extension NSLock { - func withLock(_ body: () throws -> R) rethrows -> R { - lock() - defer { unlock() } - return try body() - } -} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFSEventRelay.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFSEventRelay.swift deleted file mode 100644 index 0b035b17..00000000 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFSEventRelay.swift +++ /dev/null @@ -1,805 +0,0 @@ -import CoreServices -import Darwin -import Foundation - -public struct HostFSEventChange: Sendable, Equatable { - public var hostPath: String - public var guestPath: String - public var flags: UInt32 - public var eventID: UInt64 - /// Only the pathname reported by FSEvents may invalidate data pages. Namespace fanout to a - /// surviving hard-link alias carries metadata/nlink semantics, never permission to discard - /// that alias's potentially dirty cache. - public var permitsContentInvalidation: Bool - /// The stream reported a changed directory rather than one exact file. The coherence - /// coordinator expands this only over HostFS bindings already known in that directory. - public var isDirectoryAggregate: Bool - - public init( - hostPath: String, - guestPath: String, - flags: UInt32, - eventID: UInt64, - permitsContentInvalidation: Bool = true, - isDirectoryAggregate: Bool = false - ) { - self.hostPath = URL(fileURLWithPath: hostPath).standardizedFileURL.path - self.guestPath = guestPath - self.flags = flags - self.eventID = eventID - self.permitsContentInvalidation = permitsContentInvalidation - self.isDirectoryAggregate = isDirectoryAggregate - } - - /// FSEvents asks clients to rescan after any of these markers. Continuing to serve cached - /// dentries after a dropped event would make the host and guest disagree, so the coherence - /// coordinator must degrade immediately instead of treating this as an ordinary path edit. - public var requiresRescan: Bool { - let mask = UInt32( - kFSEventStreamEventFlagMustScanSubDirs | - kFSEventStreamEventFlagUserDropped | - kFSEventStreamEventFlagKernelDropped | - kFSEventStreamEventFlagEventIdsWrapped | - kFSEventStreamEventFlagRootChanged | - // HostFS pins root/directory descriptors. A detach/remount can replace the filesystem - // identity underneath those fds even when RootChanged is not co-reported. - kFSEventStreamEventFlagMount | - kFSEventStreamEventFlagUnmount - ) - return flags & mask != 0 - } - - /// A same-mode chmod generated solely to wake Linux watchers is expected to return as an inode - /// metadata event. Only this narrow shape is eligible for one-shot echo suppression; content, - /// namespace, xattr, ownership, overflow, and root events always make the round trip. - public var isMetadataOnly: Bool { - // macOS reports fchmod(2) as ItemChangeOwner even when uid/gid are unchanged. Treat both - // metadata classifications as echo-eligible; the path token still prevents an unrelated - // chmod from being discarded. - let metadata = UInt32( - kFSEventStreamEventFlagItemInodeMetaMod | - kFSEventStreamEventFlagItemChangeOwner - ) - let substantive = UInt32( - kFSEventStreamEventFlagItemCreated | - kFSEventStreamEventFlagItemRemoved | - kFSEventStreamEventFlagItemRenamed | - kFSEventStreamEventFlagItemModified | - kFSEventStreamEventFlagItemFinderInfoMod | - kFSEventStreamEventFlagItemXattrMod | - kFSEventStreamEventFlagMount | - kFSEventStreamEventFlagUnmount - ) - return !requiresRescan && flags & metadata != 0 && flags & substantive == 0 - } - - /// FSEvents may coalesce remove/create or both sides of an atomic replacement into one path. - /// DELETE is correct only while that name is actually absent; if a new object already occupies - /// it, INVAL_ENTRY preserves the replacement's watch/dentry semantics. - public var representsRemoval: Bool { - let namespaceMask = UInt32( - kFSEventStreamEventFlagItemRemoved | - kFSEventStreamEventFlagItemRenamed - ) - guard flags & namespaceMask != 0 else { - return false - } - var info = stat() - if lstat(hostPath, &info) == 0 { return false } - return errno == ENOENT || errno == ENOTDIR - } -} - -public struct HostFSEventShare: Sendable, Equatable { - public var hostRoot: String - /// FSEvents may return either the spelling used to create its stream or the canonical realpath - /// spelling (for example `/var` versus `/private/var`). Mapping accepts both. - public var hostRootAliases: [String] - public var guestRoot: String - - public init(hostRoot: String, guestRoot: String) { - let supplied = URL(fileURLWithPath: hostRoot).standardizedFileURL.path - let canonical = URL(fileURLWithPath: supplied) - .resolvingSymlinksInPath() - .standardizedFileURL.path - self.hostRoot = supplied - self.hostRootAliases = Array(Set([supplied, canonical])).sorted { $0.count > $1.count } - if guestRoot.count > 1, guestRoot.hasSuffix("/") { - self.guestRoot = String(guestRoot.dropLast()) - } else { - self.guestRoot = guestRoot - } - } - - public func mapHostPathToGuest(_ path: String) -> String? { - guard let relative = relativePath(forHostPath: path) else { return nil } - guard !relative.isEmpty else { return guestRoot } - return guestRoot == "/" ? "/" + relative : guestRoot + "/" + relative - } - - /// Returns the narrowest stable FSEvents root for an accessed path: the first real child of - /// this broad export. Watching `$HOME/Projects` preserves arbitrary bind reachability without - /// subscribing the host daemon to every change anywhere under `$HOME`. - public func topLevelObservationRoot(forHostPath path: String) -> String? { - guard let (normalized, root) = normalizedPathAndRootInsideShare(path), normalized != root else { - return nil - } - let prefix = root == "/" ? "/" : root + "/" - let relative = normalized.dropFirst(prefix.count) - guard let first = relative.split(separator: "/", omittingEmptySubsequences: true).first else { - return nil - } - return root == "/" ? "/" + first : root + "/" + first - } - - /// Predicts the exact path the guest agent will chmod. Missing/deleted entries fall back to the - /// nearest existing regular file or directory, but never above this share root. Symlinks are not - /// nudged because the guest opens its final component with O_NOFOLLOW. - public func nudgeTarget(forHostPath path: String) -> (host: String, guest: String)? { - guard let (initialCandidate, boundary) = normalizedPathAndRootInsideShare(path) else { - return nil - } - var candidate = initialCandidate - while true { - var info = stat() - if lstat(candidate, &info) == 0 { - let kind = info.st_mode & mode_t(S_IFMT) - if kind == mode_t(S_IFREG) || kind == mode_t(S_IFDIR) { - guard let guest = mapHostPathToGuest(candidate) else { return nil } - return (candidate, guest) - } - } else if errno != ENOENT && errno != ENOTDIR && errno != ELOOP { - return nil - } - guard candidate != boundary else { return nil } - let parent = URL(fileURLWithPath: candidate).deletingLastPathComponent().path - guard isPath(parent, inside: boundary), parent != candidate else { return nil } - candidate = parent - } - } - - private func relativePath(forHostPath path: String) -> String? { - guard let (normalized, root) = normalizedPathAndRootInsideShare(path) else { return nil } - if normalized == root { return "" } - let prefix = root == "/" ? "/" : root + "/" - return String(normalized.dropFirst(prefix.count)) - } - - private func normalizedPathAndRootInsideShare(_ path: String) -> (String, String)? { - let normalized = URL(fileURLWithPath: path).standardizedFileURL.path - guard let root = hostRootAliases.first(where: { isPath(normalized, inside: $0) }) else { - return nil - } - return (normalized, root) - } - - private func isPath(_ candidate: String, inside root: String) -> Bool { - if candidate == root { return true } - let prefix = root == "/" ? "/" : root + "/" - return candidate.hasPrefix(prefix) - } -} - -public enum HostFSEventRelayError: Error, Equatable, Sendable { - case streamCreationFailed - case streamStartFailed - case suppressionLedgerFull(limit: Int) - case repeatedSyntheticEcho(path: String) -} - -public enum HostShareCoherenceStartupError: Error, Equatable, Sendable { - case eventRelayUnavailable(productionShareCount: Int) -} - -/// Production host shares are safe only while their host-change observation path is live. This -/// applies to writable shares and read-only shares alike: read-only guest mappings can still retain -/// stale host page cache. Engine startup therefore fails instead of running any production share -/// without the relay; an empty production-share set has no such requirement. -public enum HostShareCoherenceStartupPolicy { - public static func requireEventRelay( - started: Bool, - productionShareCount: Int - ) throws { - guard productionShareCount > 0 else { return } - guard started else { - throw HostShareCoherenceStartupError.eventRelayUnavailable( - productionShareCount: productionShareCount - ) - } - } -} - -public final class FSEventBatcher: @unchecked Sendable { - public typealias SendBatch = @Sendable ([HostFSEventChange]) async throws -> Void - - /// An npm install can emit tens of thousands of distinct FileEvents before the asynchronous - /// coherence round trip finishes. Keep one bounded batch comfortably above that ordinary - /// developer workload: treating it as an observation loss needlessly restarts the VM even - /// though CoreServices delivered every path. A true FSEvents dropped-event marker still takes - /// the existing fail-closed recovery path, and this remains a hard cap against unbounded use. - public static let defaultPendingLimit = 65_536 - - private let shares: [HostFSEventShare] - private let send: SendBatch - private let pendingLimit: Int - private let eventsAreDirectoryAggregates: Bool - private let lock = NSLock() - private var pending: [String: HostFSEventChange] = [:] - private var pendingRequiresRescan = false - private var receivedEventCount: UInt64 = 0 - private var deliveredBatchCount: UInt64 = 0 - private var failedBatchCount: UInt64 = 0 - private var rescanCollapseCount: UInt64 = 0 - - public init( - shares: [HostFSEventShare], - pendingLimit: Int = FSEventBatcher.defaultPendingLimit, - eventsAreDirectoryAggregates: Bool = false, - send: @escaping SendBatch - ) { - // Longest root first makes nested shares deterministic. - self.shares = shares.sorted { $0.hostRoot.count > $1.hostRoot.count } - self.pendingLimit = max(1, pendingLimit) - self.eventsAreDirectoryAggregates = eventsAreDirectoryAggregates - self.send = send - } - - public func enqueue(hostPaths: [String], flags: [UInt32], eventIDs: [UInt64]) { - guard hostPaths.count == flags.count, flags.count == eventIDs.count else { return } - let changes = zip(hostPaths.indices, hostPaths).compactMap { index, path in - mapHostPath(path, flags: flags[index], eventID: eventIDs[index]) - } - guard !changes.isEmpty else { return } - let batchLatestEventID = changes.map(\.eventID).max() ?? 0 - lock.withLock { - receivedEventCount &+= UInt64(changes.count) - for change in changes { - if change.requiresRescan { - FileHandle.standardError.write(Data( - "dory-hv: FSEvents rescan marker path=\(change.hostPath) flags=0x\(String(change.flags, radix: 16)) event=\(change.eventID)\n".utf8 - )) - } - if pendingRequiresRescan { break } - if pending[change.hostPath] == nil, pending.count >= pendingLimit { - let latest = max(batchLatestEventID, pending.values.map(\.eventID).max() ?? 0) - collapseToRescanLocked(latestEventID: latest, reason: "pending-overflow") - break - } - if var existing = pending[change.hostPath] { - existing.flags |= change.flags - existing.eventID = max(existing.eventID, change.eventID) - pending[change.hostPath] = existing - } else { - pending[change.hostPath] = change - } - } - } - } - - public func enqueue(hostPaths: [String]) { - enqueue( - hostPaths: hostPaths, - flags: Array(repeating: 0, count: hostPaths.count), - eventIDs: Array(repeating: 0, count: hostPaths.count) - ) - } - - public func flushNow() async throws { - let changes = lock.withLock { () -> [HostFSEventChange] in - let changes = pending.values.sorted { - $0.guestPath == $1.guestPath ? $0.hostPath < $1.hostPath : $0.guestPath < $1.guestPath - } - pending.removeAll(keepingCapacity: true) - pendingRequiresRescan = false - return changes - } - guard !changes.isEmpty else { return } - do { - try await send(changes) - lock.withLock { deliveredBatchCount &+= 1 } - } catch { - lock.withLock { - failedBatchCount &+= 1 - let latest = max( - changes.map(\.eventID).max() ?? 0, - pending.values.map(\.eventID).max() ?? 0 - ) - if pendingRequiresRescan - || changes.contains(where: \.requiresRescan) - || pending.count + changes.count > pendingLimit { - collapseToRescanLocked(latestEventID: latest, reason: "retry-overflow-or-rescan") - } else { - for change in changes { - if var newer = pending[change.hostPath] { - newer.flags |= change.flags - newer.eventID = max(newer.eventID, change.eventID) - pending[change.hostPath] = newer - } else { - pending[change.hostPath] = change - } - } - } - } - throw error - } - } - - public var hasPending: Bool { - lock.withLock { !pending.isEmpty } - } - - public func discardPending() { - lock.withLock { - pending.removeAll(keepingCapacity: false) - pendingRequiresRescan = false - } - } - - var pendingCount: Int { - lock.withLock { pending.count } - } - - public var diagnostics: FSEventBatcherDiagnostics { - lock.withLock { - FSEventBatcherDiagnostics( - pendingCount: pending.count, - pendingLimit: pendingLimit, - pendingRequiresRescan: pendingRequiresRescan, - receivedEventCount: receivedEventCount, - deliveredBatchCount: deliveredBatchCount, - failedBatchCount: failedBatchCount, - rescanCollapseCount: rescanCollapseCount - ) - } - } - - public func mapHostPathToGuest(_ path: String) -> String? { - shares.lazy.compactMap { $0.mapHostPathToGuest(path) }.first - } - - public func mapHostPath(_ path: String, flags: UInt32, eventID: UInt64) -> HostFSEventChange? { - let normalized = URL(fileURLWithPath: path).standardizedFileURL.path - guard let guest = mapHostPathToGuest(normalized) else { return nil } - return HostFSEventChange( - hostPath: normalized, - guestPath: guest, - flags: flags, - eventID: eventID, - isDirectoryAggregate: eventsAreDirectoryAggregates - ) - } - - private func collapseToRescanLocked(latestEventID: UInt64, reason: String) { - FileHandle.standardError.write(Data( - "dory-hv: FSEvents batch collapsed reason=\(reason) pending=\(pending.count) event=\(latestEventID)\n".utf8 - )) - pending.removeAll(keepingCapacity: true) - pendingRequiresRescan = true - rescanCollapseCount &+= 1 - let flags = UInt32( - kFSEventStreamEventFlagMustScanSubDirs | - kFSEventStreamEventFlagUserDropped - ) - for share in shares { - pending[share.hostRoot] = HostFSEventChange( - hostPath: share.hostRoot, - guestPath: share.guestRoot, - flags: flags, - eventID: latestEventID - ) - } - } -} - -public struct FSEventBatcherDiagnostics: Codable, Equatable, Sendable { - public var pendingCount: Int - public var pendingLimit: Int - public var pendingRequiresRescan: Bool - public var receivedEventCount: UInt64 - public var deliveredBatchCount: UInt64 - public var failedBatchCount: UInt64 - public var rescanCollapseCount: UInt64 -} - -public struct HostFSEventRelayDiagnostics: Codable, Equatable, Sendable { - public var schema: String - public var version: Int - public var generatedAt: Date - public var configuredRoots: [String] - public var observationRoots: [String] - public var running: Bool - public var flushScheduled: Bool - public var consecutiveFailures: Int - public var batcher: FSEventBatcherDiagnostics - - public init( - generatedAt: Date = Date(), - configuredRoots: [String], - observationRoots: [String], - running: Bool, - flushScheduled: Bool, - consecutiveFailures: Int, - batcher: FSEventBatcherDiagnostics - ) { - self.schema = "dev.dory.host-share.resources" - self.version = 1 - self.generatedAt = generatedAt - self.configuredRoots = configuredRoots - self.observationRoots = observationRoots - self.running = running - self.flushScheduled = flushScheduled - self.consecutiveFailures = consecutiveFailures - self.batcher = batcher - } -} - -/// Watches configured host roots and emits loss-aware, retryable batches. This type deliberately -/// does not decide cache policy; the coordinator must invalidate the guest kernel and await its -/// completion barrier before sending a watcher nudge. -public final class HostFSEventRelay: @unchecked Sendable { - public typealias SendBatch = FSEventBatcher.SendBatch - public typealias FailureHandler = @Sendable (any Error) -> Void - @usableFromInline - static let defaultDebounceMilliseconds: UInt64 = 1 - - private let shares: [HostFSEventShare] - private let batcher: FSEventBatcher - private let debounceNanoseconds: UInt64 - private let onFailure: FailureHandler - private let observeRootsOnDemand: Bool - private let queue = DispatchQueue(label: "dev.dory.hostfs.fsevents") - /// CoreServices owns `queue` and can report UserDropped when its callback cannot drain quickly - /// enough. URL normalization, share mapping, and pending-dictionary merging are substantially - /// more expensive than copying one delivered batch, so perform them on a separate ordered queue - /// and return the FSEvents callback promptly. - private let processingQueue = DispatchQueue(label: "dev.dory.hostfs.fsevents.processing") - private let lock = NSLock() - private var streams: [String: FSEventStreamRef] = [:] - private var callbackBoxes: [String: CallbackBox] = [:] - private var flushScheduled = false - private var consecutiveFailures = 0 - private var running = false - private var lifecycleGeneration: UInt64 = 0 - - static let streamCreateFlags = FSEventStreamCreateFlags( - // Directory-level events keep a whole-home share from producing one record per package - // file. HostShareCoherenceCoordinator expands each changed directory only over immediate - // HostFS bindings the guest already knows, retaining precise cache and watcher behavior. - // RootChanged is emitted only with WatchRoot. Without it a renamed/deleted share root - // could silently leave positive cache state attached to an obsolete host directory. - kFSEventStreamCreateFlagWatchRoot | - // HostFS mutations and watcher nudges run in this same dory-hv process. Excluding - // self-originated events prevents guest writes from being reflected back into the - // guest while preserving edits made by editors and tools on macOS. - kFSEventStreamCreateFlagIgnoreSelf | - // Ask FSEvents to mark any self event that still reaches the stream. IgnoreSelf normally - // suppresses it, but relying on that suppression alone lets a package-manager create storm - // be mistaken for an external host edit when the system reports it anyway. - kFSEventStreamCreateFlagMarkSelf | - kFSEventStreamCreateFlagUseCFTypes - ) - - /// `dory-hv` performs the host syscalls for guest FUSE mutations. Those paths are already - /// coherent in its HostFS state and must not be fed back through the host-edit invalidation - /// pipeline. The explicit OwnEvent check is the fail-safe complement to IgnoreSelf. - static func ignoresOwnEvent(_ flags: UInt32) -> Bool { - flags & UInt32(kFSEventStreamEventFlagOwnEvent) != 0 - } - - public init( - shares: [HostFSEventShare], - debounceMilliseconds: UInt64 = HostFSEventRelay.defaultDebounceMilliseconds, - observeRootsOnDemand: Bool = false, - send: @escaping SendBatch, - onFailure: @escaping FailureHandler = { _ in } - ) { - self.shares = shares - self.batcher = FSEventBatcher( - shares: shares, - eventsAreDirectoryAggregates: true, - send: send - ) - self.debounceNanoseconds = debounceMilliseconds * 1_000_000 - self.observeRootsOnDemand = observeRootsOnDemand - self.onFailure = onFailure - } - - deinit { - stop() - } - - @discardableResult - public func start() -> Bool { - guard !shares.isEmpty else { return false } - let alreadyRunning = lock.withLock { running } - if alreadyRunning { return true } - lock.withLock { - lifecycleGeneration &+= 1 - running = true - flushScheduled = false - consecutiveFailures = 0 - } - if observeRootsOnDemand { return true } - for root in shares.map(\.hostRoot) { - guard startStream(root: root) else { - stop() - return false - } - } - return true - } - - /// Adds one narrow observation root without disturbing existing streams. This is synchronous: - /// the FUSE lookup that discovered the path does not return until the stream is live. - @discardableResult - public func observe(hostPath: String) -> Bool { - guard lock.withLock({ running }) else { return false } - let roots = shares.compactMap { $0.topLevelObservationRoot(forHostPath: hostPath) } - guard let root = roots.sorted(by: { $0.count > $1.count }).first else { return true } - if lock.withLock({ streams[root] != nil }) { return true } - return startStream(root: root) - } - - public var observationRoots: [String] { - lock.withLock { streams.keys.sorted() } - } - - public var diagnostics: HostFSEventRelayDiagnostics { - let state = lock.withLock { - ( - roots: streams.keys.sorted(), - running: running, - flushScheduled: flushScheduled, - failures: consecutiveFailures - ) - } - return HostFSEventRelayDiagnostics( - configuredRoots: shares.map(\.hostRoot).sorted(), - observationRoots: state.roots, - running: state.running, - flushScheduled: state.flushScheduled, - consecutiveFailures: state.failures, - batcher: batcher.diagnostics - ) - } - - private func startStream(root: String) -> Bool { - let box = CallbackBox(relay: self) - var context = FSEventStreamContext( - version: 0, - info: Unmanaged.passUnretained(box).toOpaque(), - retain: nil, - release: nil, - copyDescription: nil - ) - guard let created = FSEventStreamCreate( - nil, - { _, info, count, eventPaths, eventFlags, eventIDs in - guard let info else { return } - let box = Unmanaged.fromOpaque(info).takeUnretainedValue() - let pathsArray = unsafeBitCast(eventPaths, to: NSArray.self) - var paths = [String]() - var flags = [UInt32]() - var ids = [UInt64]() - paths.reserveCapacity(count) - flags.reserveCapacity(count) - ids.reserveCapacity(count) - for index in 0.. Bool in - guard running, streams[root] == nil else { return false } - streams[root] = created - callbackBoxes[root] = box - return true - } - if !accepted { - FSEventStreamStop(created) - FSEventStreamInvalidate(created) - FSEventStreamRelease(created) - } - return true - } - - public func stop() { - let existing = lock.withLock { () -> (streams: [FSEventStreamRef], boxes: [CallbackBox]) in - let existingStreams = Array(streams.values) - let existingBoxes = Array(callbackBoxes.values) - streams.removeAll() - callbackBoxes.removeAll() - lifecycleGeneration &+= 1 - running = false - flushScheduled = false - consecutiveFailures = 0 - return (existingStreams, existingBoxes) - } - batcher.discardPending() - for stream in existing.streams { - FSEventStreamStop(stream) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - } - // FSEventStreamContext retains an unretained pointer to each box. The local tuple keeps all - // boxes alive until every corresponding stream has stopped, invalidated, and released. - _ = existing.boxes - } - - public func record(hostPaths: [String], flags: [UInt32], eventIDs: [UInt64]) { - guard lock.withLock({ running }) else { return } - batcher.enqueue(hostPaths: hostPaths, flags: flags, eventIDs: eventIDs) - scheduleFlush() - } - - func recordFromStream(hostPaths: [String], flags: [UInt32], eventIDs: [UInt64]) { - guard let generation = lock.withLock({ running ? lifecycleGeneration : nil }) else { return } - processingQueue.async { [weak self] in - guard let self, - self.lock.withLock({ self.running && self.lifecycleGeneration == generation }) else { - return - } - self.batcher.enqueue(hostPaths: hostPaths, flags: flags, eventIDs: eventIDs) - self.scheduleFlush() - } - } - - public func record(hostPaths: [String]) { - guard lock.withLock({ running }) else { return } - batcher.enqueue(hostPaths: hostPaths) - scheduleFlush() - } - - private func scheduleFlush() { - let scheduled = lock.withLock { () -> (delay: UInt64, generation: UInt64)? in - guard running, !flushScheduled else { return nil } - flushScheduled = true - let backoff = min(UInt64(2_000_000_000), debounceNanoseconds << min(consecutiveFailures, 5)) - return (backoff, lifecycleGeneration) - } - guard let scheduled else { - if !lock.withLock({ running }) { batcher.discardPending() } - return - } - Task.detached { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: scheduled.delay) - guard self.lock.withLock({ - self.running && self.lifecycleGeneration == scheduled.generation - }) else { - self.batcher.discardPending() - return - } - do { - try await self.batcher.flushNow() - self.lock.withLock { - if self.running, self.lifecycleGeneration == scheduled.generation { - self.consecutiveFailures = 0 - } - } - } catch { - let shouldReport = self.lock.withLock { () -> Bool in - guard self.running, - self.lifecycleGeneration == scheduled.generation else { return false } - self.consecutiveFailures += 1 - return true - } - if shouldReport { self.onFailure(error) } - } - let shouldReschedule = self.lock.withLock { () -> Bool in - guard self.running, - self.lifecycleGeneration == scheduled.generation else { return false } - self.flushScheduled = false - return self.batcher.hasPending - } - if shouldReschedule { - self.scheduleFlush() - } else if !self.lock.withLock({ - self.running && self.lifecycleGeneration == scheduled.generation - }) { - self.batcher.discardPending() - } - } - } -} - -/// One-shot ledger for the metadata-only FSEvent produced by a guest watcher nudge. Cache -/// invalidation still runs for a consumed echo; only the second guest nudge is suppressed. -public final class FSEventEchoSuppressor: @unchecked Sendable { - private struct Token { - var sourceEventID: UInt64 - var expiresAt: TimeInterval - var remainingEchoes: Int - } - - private let lock = NSLock() - private let limit: Int - private let lifetimeSeconds: TimeInterval - private var tokens: [String: Token] = [:] - - public init(limit: Int = 8_192, lifetimeSeconds: TimeInterval = 2) { - self.limit = max(1, limit) - self.lifetimeSeconds = max(0.05, lifetimeSeconds) - } - - public func register(hostPath: String, sourceEventID: UInt64, now: TimeInterval = ProcessInfo.processInfo.systemUptime) throws { - let path = URL(fileURLWithPath: hostPath).standardizedFileURL.path - try lock.withLock { - expireLocked(now: now) - guard tokens[path] != nil || tokens.count < limit else { - throw HostFSEventRelayError.suppressionLedgerFull(limit: limit) - } - tokens[path] = Token( - sourceEventID: max(tokens[path]?.sourceEventID ?? 0, sourceEventID), - expiresAt: now + lifetimeSeconds, - // One token per nudge. A small burst on one path can legitimately have several - // in-flight chmod echoes, so count them rather than overwriting a one-shot token. - remainingEchoes: min(32, (tokens[path]?.remainingEchoes ?? 0) + 1) - ) - } - } - - public func consumeIfSyntheticEcho( - _ change: HostFSEventChange, - now: TimeInterval = ProcessInfo.processInfo.systemUptime - ) -> Bool { - guard change.isMetadataOnly else { return false } - return lock.withLock { - expireLocked(now: now) - guard var token = tokens[change.hostPath], - change.eventID == 0 || change.eventID > token.sourceEventID else { return false } - token.remainingEchoes -= 1 - if token.remainingEchoes == 0 { - tokens.removeValue(forKey: change.hostPath) - } else { - tokens[change.hostPath] = token - } - return true - } - } - - public func clear() { - lock.withLock { tokens.removeAll(keepingCapacity: false) } - } - - private func expireLocked(now: TimeInterval) { - tokens = tokens.filter { $0.value.expiresAt > now } - } -} - -private final class CallbackBox { - weak var relay: HostFSEventRelay? - - init(relay: HostFSEventRelay) { - self.relay = relay - } -} - -private extension NSLock { - func withLock(_ body: () throws -> R) rethrows -> R { - lock() - defer { unlock() } - return try body() - } -} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostShareCoherenceCoordinator.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostShareCoherenceCoordinator.swift deleted file mode 100644 index 26eaf86e..00000000 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostShareCoherenceCoordinator.swift +++ /dev/null @@ -1,868 +0,0 @@ -import CoreServices -import Foundation - -public struct HostShareCoherenceEndpoint: @unchecked Sendable { - public let share: HostFSEventShare - public let backend: VirtioFS - public let watcherNudgesEnabled: Bool - - public init( - share: HostFSEventShare, - backend: VirtioFS, - watcherNudgesEnabled: Bool = true - ) { - self.share = share - self.backend = backend - self.watcherNudgesEnabled = watcherNudgesEnabled - } -} - -public enum HostShareCoherenceError: Error, Equatable, Sendable { - case guestNudgeFailed(path: String) - case guestNudgeOperationExpired(operationID: UInt64) -} - -/// Orders host-originated edits so Linux never receives a watcher wakeup while its virtio-fs cache -/// still contains the old object. Batches are actor-serialized; transport failures are retryable, -/// while lossy FSEvents markers permanently return the affected VM to zero-cache safety. When the -/// export root and reverse-notification channel remain intact, loss is recovered in place by -/// invalidating every still-live FUSE identity rather than rebooting the Docker VM. -public actor HostShareCoherenceCoordinator { - /// Host edits fail closed quickly: dirty guest writeback was observed escaping a two-second - /// reverse-notification wait. VirtioFS keeps its generic timeout; coherence uses this tighter - /// deadline so the VM restart boundary wins before delayed dirty pages can overwrite host data. - static let reverseInvalidationFailCloseDeadline: Duration = .seconds(1) - /// A loss-recovery sweep invalidates every known node, so its deadline scales with the sweep - /// size instead of borrowing the per-edit fail-close bound. Caching is already deactivated for - /// the whole recovery window, so a longer publication budget cannot extend staleness. - static let maximumLossRecoveryInvalidationDeadline: Duration = .seconds(60) - /// Recovery handles bursts (one loss marker per relay flush), but a stream that keeps losing - /// events faster than sweeps complete is not converging; fall back to the VM restart boundary. - static let maximumConsecutiveLossRecoveries = 5 - - private let endpoints: [HostShareCoherenceEndpoint] - private let guestEvents: any GuestFSEventSending - private let onDegraded: @Sendable (String) -> Void - private let onRecovered: @Sendable (String) -> Void - private let onFatalRecoveryRequired: @Sendable (String) -> Void - private let relayHealth: RelayDeliveryHealth - private var batchInProgress = false - private var batchWaiters = [CheckedContinuation]() - private var cachingWasActivated = false - private var cacheValidityMayRemain = false - private var cacheExpiryDeadline: TimeInterval? - private var notificationDeliveryBroken = false - private var deliveredNudges = Set() - private var pendingNudgeOperation: PendingNudgeOperation? - private var fatalRecoveryRequested = false - private var consecutiveLossRecoveries = 0 - private(set) public var isDegraded = false - - public init( - endpoints: [HostShareCoherenceEndpoint], - guestEvents: any GuestFSEventSending, - onDegraded: @escaping @Sendable (String) -> Void = { _ in }, - onRecovered: @escaping @Sendable (String) -> Void = { _ in }, - onFatalRecoveryRequired: @escaping @Sendable (String) -> Void = { _ in } - ) { - let sortedEndpoints = endpoints.sorted { $0.share.hostRoot.count > $1.share.hostRoot.count } - self.endpoints = sortedEndpoints - self.guestEvents = guestEvents - self.onDegraded = onDegraded - self.onRecovered = onRecovered - self.onFatalRecoveryRequired = onFatalRecoveryRequired - self.relayHealth = RelayDeliveryHealth { - // This callback runs synchronously on the relay failure path. Revoking response TTLs - // here closes the actor-reentrancy window while the readiness health frame is in flight. - for endpoint in sortedEndpoints { - endpoint.backend.deactivateCoherentCaching() - } - } - } - - /// Turns on bounded positive caching only after both halves of the coherence path are live: - /// every virtio-fs device has negotiated/reposted its stable notification buffers and the guest - /// watcher service answers an empty health frame. A partial activation is rolled back. - /// - /// `false` means "not ready" (or permanently degraded), so callers may poll during guest boot - /// without treating an expected startup race as an engine failure. - public func activateCachingIfReady() async throws -> Bool { - guard !batchInProgress else { return false } - return try await performActivation(requireNoBatch: true) - } - - /// Loss recovery reactivates from inside its own batch, so the batch guard is parameterized: - /// the batch gate already serializes recovery against every other caller. - private func performActivation(requireNoBatch: Bool) async throws -> Bool { - let cacheableEndpoints = endpoints.filter(\.watcherNudgesEnabled) - guard !isDegraded, - pendingNudgeOperation == nil, - !cacheableEndpoints.isEmpty else { return false } - guard let relayGeneration = relayHealth.readyGeneration else { return false } - guard cacheableEndpoints.allSatisfy({ - $0.backend.cacheActivationEligibility.isEligible - }) else { - return false - } - - // Operation zero is reserved for this idempotent empty health probe. Normal batches always - // use a fresh nonzero ID and retain it across any uncertain transport result. - let health = try await guestEvents.send(operationID: 0, paths: []) - guard health.touched == 0, - health.failed == 0, - !isDegraded, - !requireNoBatch || !batchInProgress else { return false } - - let activated = relayHealth.whileReady(generation: relayGeneration) { - for endpoint in cacheableEndpoints { - guard endpoint.backend.activateCoherentCaching() == .activated else { - for rollback in cacheableEndpoints { - rollback.backend.deactivateCoherentCaching() - } - return false - } - } - return true - } - guard activated else { return false } - cachingWasActivated = true - cacheValidityMayRemain = true - cacheExpiryDeadline = nil - return true - } - - /// Relay and lifecycle failures use the same fail-closed transition as delivery failures. - public func markDegraded(_ reason: String) { - degrade(reason) - } - - /// Marks the relay behind synchronously, before actor bookkeeping can race a suspended cache - /// readiness probe. A successful retry marks it caught up again; after caching has ever been - /// enabled, the actor also makes the downgrade permanent for this VM session. - public nonisolated func relayDeliveryFailed(_ reason: String) { - relayHealth.markFailed() - Task { await recordRelayDeliveryFailure(reason) } - } - - /// Called only after the coordinator has completed invalidation and watcher delivery for the - /// relay's pending batch. This is the point at which a startup retry is genuinely caught up. - public nonisolated func relayDeliverySucceeded() { - relayHealth.markSucceeded() - } - - var relayDeliveryIsCaughtUp: Bool { - relayHealth.readyGeneration != nil - } - - private func recordRelayDeliveryFailure(_ reason: String) { - if cachingWasActivated || isDegraded { - degrade(reason) - } - } - - public func process(_ incoming: [HostFSEventChange]) async throws { - guard !incoming.isEmpty else { return } - await beginBatch() - defer { endBatch() } - let requiresRescan = incoming.contains(where: \.requiresRescan) - if requiresRescan { - try await recoverFromEventLoss(latestEventID: incoming.map(\.eventID).max() ?? 0) - return - } - consecutiveLossRecoveries = 0 - let changes = incoming - - let prepared = prepare(changes) - if !notificationDeliveryBroken { - var attemptedNotificationChannel = false - do { - for item in prepared { - // Negative dentries and directory handles are never cached, so an empty list is - // normal during guest boot. Any known positive inode/entry, however, may own - // open-file page cache even while metadata TTL is zero and must be invalidated. - guard !item.invalidations.isEmpty else { continue } - attemptedNotificationChannel = true - let batchLimit = max(1, min(128, item.endpoint.backend.notificationBacklogLimit)) - try await item.endpoint.backend.invalidateAtomically( - item.invalidations, - maximumBatchSize: batchLimit, - timeout: Self.reverseInvalidationFailCloseDeadline - ) - } - } catch { - if attemptedNotificationChannel || cachingWasActivated || isDegraded { - notificationDeliveryBroken = true - requireFatalRecovery( - "host-share reverse invalidation failed; restarting VM to discard uncertain page cache: \(error)" - ) - return - } - } - } else { - // The first failure permanently disabled caching for this VM. Do not add another - // fail-close wait to later host edits; the one-time expiry wait below is enough. - if cacheValidityMayRemain { - try await waitForPreviouslyIssuedCacheValidity() - } - } - - do { - // A prior response may have been lost after the guest performed its fchmod operations. - // Replay that exact ordered request and ID before considering this (possibly merged) - // FSEvents batch. The guest then returns its cached result without repeating the work. - try await deliverPendingNudgeOperation() - var nudgeTargets: [String: NudgeTarget] = [:] - for item in prepared { - guard item.endpoint.watcherNudgesEnabled else { continue } - for change in item.changes { - guard let target = item.endpoint.share.nudgeTarget(forHostPath: change.hostPath) else { - continue - } - // Key by guest path, not host path: one host directory may intentionally be - // exposed at multiple guest aliases and every mount needs its own watcher event. - if var existing = nudgeTargets[target.guest] { - existing.eventID = max(existing.eventID, change.eventID) - nudgeTargets[target.guest] = existing - } else { - nudgeTargets[target.guest] = NudgeTarget( - guestPath: target.guest, - eventID: change.eventID - ) - } - } - } - - let sortedTargets = nudgeTargets.values - .filter { !wasNudgeDelivered($0) } - .sorted { $0.guestPath < $1.guestPath } - for targets in sortedTargets.chunked(maximumCount: GuestFSEventBatchCodec.maximumPaths) { - pendingNudgeOperation = PendingNudgeOperation( - operationID: GuestFSEventOperationIDs.next(), - targets: targets, - createdAt: ProcessInfo.processInfo.systemUptime - ) - try await deliverPendingNudgeOperation() - } - retireDeliveredNudges(coveredBy: Array(nudgeTargets.values)) - } catch { - // Close the readiness gate before returning control to FSEventBatcher. Its external - // failure callback is necessarily a few instructions later, and cache polling must not - // exploit that gap while this batch remains retryable. - relayHealth.markFailed() - if cachingWasActivated || isDegraded { - degrade("host-share watcher delivery failed: \(error)") - } - throw error - } - } - - private struct PreparedEndpoint { - var endpoint: HostShareCoherenceEndpoint - var changes: [HostFSEventChange] - var invalidations: [VirtioFSInvalidation] - } - - private struct NudgeTarget: Sendable { - var guestPath: String - var eventID: UInt64 - } - - private struct NudgeKey: Hashable, Sendable { - var guestPath: String - var eventID: UInt64 - } - - private struct PendingNudgeOperation: Sendable { - var operationID: UInt64 - var targets: [NudgeTarget] - var createdAt: TimeInterval - } - - private func deliverPendingNudgeOperation() async throws { - guard let operation = pendingNudgeOperation else { return } - let age = ProcessInfo.processInfo.systemUptime - operation.createdAt - guard age < GuestFSEventBatchCodec.maximumOperationRetryAgeSeconds else { - // The guest's dedupe entry may expire after 120 seconds. Never cross that boundary and - // risk turning an uncertain prior success into a second watcher event. - requireFatalRecovery( - "guest watcher operation \(operation.operationID) exceeded its safe retry window; restarting VM" - ) - throw HostShareCoherenceError.guestNudgeOperationExpired( - operationID: operation.operationID - ) - } - - let result: GuestFSEventBatchResult - do { - result = try await guestEvents.send( - operationID: operation.operationID, - paths: operation.targets.map(\.guestPath) - ) - } catch let error as GuestFSEventBridgeError { - switch error { - case .operationIDConflict, .dedupeCapacityExhausted, - .tooManyPaths, .invalidOperationID, .invalidPath, .oversizedFrame: - // Conflict/capacity status proves this payload did not execute; local validation - // failures happen before connection I/O. Discard the ID so a later attempt starts - // a logically new, still-safe operation. - pendingNudgeOperation = nil - case .guestExecutionFailed: - // A caught guest panic may have happened after a subset of side effects. Retain the - // ID and fail closed; allocating a new operation could duplicate that unknown work. - requireFatalRecovery( - "guest watcher operation \(operation.operationID) has indeterminate execution; restarting VM" - ) - default: - // Timeout, disconnect, or a malformed response leaves delivery uncertain. Retain - // the exact ID and ordered paths so the guest dedupe store resolves the ambiguity. - break - } - throw error - } - - guard result.pathCount == UInt32(operation.targets.count) else { - throw GuestFSEventBridgeError.invalidResponse - } - // A valid response is durable in the guest dedupe window. It is now safe to retire this - // operation: successful indices are final, while failed indices receive a fresh ID later. - pendingNudgeOperation = nil - let failed = Set(result.failedIndices.map(Int.init)) - for (index, target) in operation.targets.enumerated() where !failed.contains(index) { - recordDeliveredNudge(target) - } - if let failedIndex = result.failedIndices.first.map(Int.init) { - let path = operation.targets[failedIndex].guestPath - throw HostShareCoherenceError.guestNudgeFailed(path: path) - } - } - - private func prepare( - _ changes: [HostFSEventChange], - exactRecoveryEndpoints: Bool = false - ) -> [PreparedEndpoint] { - var grouped: [Int: ( - changes: [HostFSEventChange], - invalidations: [String: VirtioFSInvalidation], - deletedNodeIDs: Set - )] = [:] - for endpointIndex in endpoints.indices { - let endpoint = endpoints[endpointIndex] - var endpointChanges = [String: HostFSEventChange]() - for change in changes { - let matchesEndpoint: Bool - if exactRecoveryEndpoints { - matchesEndpoint = endpoint.share.hostRoot == change.hostPath - && endpoint.share.guestRoot == change.guestPath - } else { - matchesEndpoint = endpoint.share.mapHostPathToGuest(change.hostPath) != nil - } - guard matchesEndpoint else { continue } - let expanded: [HostFSEventChange] - if change.isDirectoryAggregate { - expanded = Self.expandedDirectoryChange(change, endpoint: endpoint) - } else { - expanded = [change] - } - for exact in expanded { - if var existing = endpointChanges[exact.hostPath] { - existing.flags |= exact.flags - existing.eventID = max(existing.eventID, exact.eventID) - endpointChanges[exact.hostPath] = existing - } else { - endpointChanges[exact.hostPath] = exact - } - } - } - - // FSEvents can coalesce a rename to its destination path only and may classify that - // destination as either renamed or created. Reconcile cached bindings whose pinned - // identity no longer belongs at their old path so Linux sees a source-side - // DELETE/ENTRY notification as well as the destination update. Do this only for a - // namespace mutation and never during an exact loss-recovery root batch. - let namespaceMutationMask = UInt32( - kFSEventStreamEventFlagItemCreated | - kFSEventStreamEventFlagItemRemoved | - kFSEventStreamEventFlagItemRenamed - ) - if !exactRecoveryEndpoints, - endpointChanges.values.contains(where: { $0.flags & namespaceMutationMask != 0 }) { - let sourceEventID = endpointChanges.values.map(\.eventID).max() ?? 0 - let synthesizedFlags = UInt32( - kFSEventStreamEventFlagItemRemoved | - kFSEventStreamEventFlagItemRenamed - ) - for stalePath in endpoint.backend.hostFS - .knownStaleHostPathsForNamespaceReconciliation() - where endpointChanges[stalePath] == nil { - guard let guestPath = endpoint.share.mapHostPathToGuest(stalePath) else { continue } - endpointChanges[stalePath] = HostFSEventChange( - hostPath: stalePath, - guestPath: guestPath, - flags: synthesizedFlags, - eventID: sourceEventID - ) - } - } - - for change in endpointChanges.values.sorted(by: { - $0.hostPath == $1.hostPath ? $0.eventID < $1.eventID : $0.hostPath < $1.hostPath - }) { - let aliasPaths: [String] - if change.flags & UInt32(kFSEventStreamEventFlagItemRenamed) != 0 { - // A paired FSEvents rename has distinct source and destination records. Do not - // fan a destination's flags onto the source identity: the source must retain - // its absent-path classification so it can emit FUSE_NOTIFY_DELETE. - aliasPaths = [change.hostPath] - } else { - aliasPaths = endpoint.backend.hostFS - .knownIdentityAliasHostPaths(forHostPath: change.hostPath) - } - for aliasPath in aliasPaths { - guard let guestPath = endpoint.share.mapHostPathToGuest(aliasPath), - let snapshot = endpoint.backend.hostFS - .invalidationSnapshot(forHostPath: aliasPath) else { - continue - } - let effectiveChange = HostFSEventChange( - hostPath: aliasPath, - guestPath: guestPath, - flags: change.flags, - eventID: change.eventID, - permitsContentInvalidation: aliasPath == change.hostPath - ) - // A whole-home share receives unrelated macOS activity continuously. If neither - // the path nor its exact parent has ever been resolved by the guest, Linux cannot - // hold a dentry/inode cache or directory watch, so relaying it only creates noise. - guard !snapshot.nodeIDs.isEmpty || !snapshot.parentNodeIDs.isEmpty else { continue } - grouped[endpointIndex, default: ([], [:], [])].changes.append(effectiveChange) - - let planned = Self.plannedInvalidations( - for: effectiveChange, - snapshot: snapshot - ) - // The host mutation has already happened. Tombstone identities proven stale - // before Linux receives DELETE/INVAL_INODE so an open-file GETATTR that omits - // FUSE_GETATTR_FH observes the old inode's authoritative post-mutation nlink. - endpoint.backend.hostFS.reconcileHostInvalidation( - forHostPath: aliasPath, - staleNodeIDs: snapshot.staleNodeIDs - ) - let deletedNodeIDs = Set(planned.values.compactMap { invalidation -> UInt64? in - guard case .delete(_, let childNodeID, _) = invalidation else { return nil } - return childNodeID - }) - grouped[endpointIndex]?.deletedNodeIDs.formUnion(deletedNodeIDs) - for nodeID in deletedNodeIDs { - grouped[endpointIndex]?.invalidations.removeValue(forKey: "i:\(nodeID)") - } - - for (key, invalidation) in planned { - if case .inode(let nodeID, _, _) = invalidation, - grouped[endpointIndex]?.deletedNodeIDs.contains(nodeID) == true { - continue - } - let existing = grouped[endpointIndex]?.invalidations[key] - grouped[endpointIndex]?.invalidations[key] = Self.mergeInvalidation( - invalidation, - preservingStronger: existing - ) - } - } - } - } - - return grouped.keys.sorted().map { index in - let group = grouped[index]! - var invalidations = group.invalidations - // DELETE disconnects the stale dentry but does not reliably expire an open inode's - // cached attributes. Re-add attribute-only invalidation after cross-alias merging; a - // content invalidation must never win for a final unlinked/replaced identity. - for nodeID in group.deletedNodeIDs { - invalidations["i:\(nodeID)"] = .inode( - nodeID: nodeID, - offset: -1, - length: 0 - ) - } - return PreparedEndpoint( - endpoint: endpoints[index], - changes: group.changes, - invalidations: invalidations.keys.sorted().compactMap { invalidations[$0] } - ) - } - } - - static func expandedDirectoryChange( - _ change: HostFSEventChange, - endpoint: HostShareCoherenceEndpoint - ) -> [HostFSEventChange] { - guard change.isDirectoryAggregate else { return [change] } - let conservativeFlags = UInt32( - kFSEventStreamEventFlagItemModified | - kFSEventStreamEventFlagItemInodeMetaMod - ) - return endpoint.backend.hostFS - .knownHostPaths(inHostDirectory: change.hostPath) - .compactMap { path in - guard let guestPath = endpoint.share.mapHostPathToGuest(path) else { return nil } - return HostFSEventChange( - hostPath: path, - guestPath: guestPath, - flags: conservativeFlags, - eventID: change.eventID - ) - } - } - - /// Builds one path's reverse invalidations without touching coordinator state. Kept internal so - /// tests can lock down the distinction between pathname replacement and inode data lifetime. - static func plannedInvalidations( - for change: HostFSEventChange, - snapshot: HostFSInvalidationSnapshot - ) -> [String: VirtioFSInvalidation] { - var invalidations = [String: VirtioFSInvalidation]() - let representsRemoval = change.representsRemoval - let namespaceMask = UInt32( - kFSEventStreamEventFlagItemCreated | - kFSEventStreamEventFlagItemRemoved | - kFSEventStreamEventFlagItemRenamed - ) - let deletionCandidates: Set - if representsRemoval { - // Alias fanout carries the original removal flags to every hard-link name. Only the - // pathname whose identity is actually stale was removed; deleting every current alias - // would incorrectly disconnect surviving dentries that share the same node ID. - deletionCandidates = Set(snapshot.staleNodeIDs + snapshot.unverifiedNodeIDs) - } else if change.flags & namespaceMask != 0 { - deletionCandidates = Set(snapshot.staleNodeIDs + snapshot.unverifiedNodeIDs) - } else { - // An inode identity mismatch proves replacement even if FSEvents happened to classify - // the batch as content-only. Synthetic identities have no prior identity to compare. - deletionCandidates = Set(snapshot.staleNodeIDs) - } - let survivingLinks = Set(snapshot.survivingLinkNodeIDs) - // A host rename removes this *dentry* even when the moved inode still has nlink > 0 at - // its new name. Treat it as a DELETE for the source directory entry so Linux watchers see - // the removal. Ordinary nonfinal unlinks retain their entry-only hard-link semantics. - let representsRenameSource = representsRemoval - && change.flags & UInt32(kFSEventStreamEventFlagItemRenamed) != 0 - let nonFinalUnlinks = representsRenameSource - ? Set() - : deletionCandidates.intersection(survivingLinks) - let deleteNodeIDs = representsRenameSource - ? deletionCandidates.sorted() - : deletionCandidates.subtracting(survivingLinks).sorted() - - // DELETE detaches a final stale pathname identity and can produce IN_DELETE_SELF. A nonfinal - // hard-link unlink must instead invalidate only that entry plus inode attributes/nlink: the - // shared inode is still live through another name. Final stale objects also need an - // attribute-only inode invalidation because DELETE does not reliably expire an open inode's - // cached nlink. Never data-invalidate stale/final objects: an open fd or mmap must retain its - // old pages and dirty writes must keep their old target. - let staleNodeIDs = Set(snapshot.staleNodeIDs) - let deletedNodeIDs = Set(deleteNodeIDs) - // A namespace event is fanned out to every known hard-link alias. If this alias has no stale - // identity, its inode data did not change merely because another name was replaced/removed; - // touching its pages could spuriously conflict with a dirty mmap. The exact changed path has - // deletion candidates and may still need content invalidation for its current replacement. - // APFS may coalesce a prior create and a later same-inode host write into one event. The - // exact changed pathname must still invalidate its data pages even when that coalesced - // namespace event has no stale identity; otherwise a dirty guest MAP_SHARED folio can - // survive the host write. Alias fanout strips ItemModified above, so this remains local to - // the path that FSEvents actually reported. - let invalidatesContent = change.permitsContentInvalidation - && change.flags & UInt32(kFSEventStreamEventFlagItemModified) != 0 - for nodeID in snapshot.nodeIDs where !deletedNodeIDs.contains(nodeID) { - if nonFinalUnlinks.contains(nodeID) { - invalidations["i:\(nodeID)"] = .inode(nodeID: nodeID, offset: -1, length: 0) - } else if !staleNodeIDs.contains(nodeID) { - invalidations["i:\(nodeID)"] = invalidatesContent - ? .inode(nodeID: nodeID, offset: 0, length: -1) - // offset < 0 makes fuse_reverse_inval_inode invalidate attributes/ACLs only. - : .inode(nodeID: nodeID, offset: -1, length: 0) - } - } - for nodeID in deleteNodeIDs { - invalidations["i:\(nodeID)"] = .inode(nodeID: nodeID, offset: -1, length: 0) - } - - guard let name = snapshot.entryName else { return invalidations } - if !snapshot.parentNodeIDs.isEmpty, !deleteNodeIDs.isEmpty { - // DELETE must precede INVAL_ENTRY for this name. ENTRY first would remove the cached - // dentry before Linux can match DELETE's child ID and notify the old inode's watch. - for parentNodeID in snapshot.parentNodeIDs { - for childNodeID in deleteNodeIDs { - invalidations["d:\(parentNodeID):\(childNodeID):\(name)"] = .delete( - parentNodeID: parentNodeID, - childNodeID: childNodeID, - name: name - ) - } - } - } - // ENTRY invalidation is a namespace operation: it is only needed when the name→inode - // mapping may have changed (a stale pinned identity proves replacement; a synthetic - // identity cannot be compared). For a verified-unchanged path the positive dentry still - // maps to the same inode, and Linux's fuse_reverse_inval_entry runs d_invalidate, which - // detaches every mount below the dentry — a host write to a *sibling* file must never - // unmount a container's bind of this directory out from under it. - let identityMayHaveChanged = !snapshot.staleNodeIDs.isEmpty - || !snapshot.unverifiedNodeIDs.isEmpty - let shouldInvalidateEntry = representsRemoval - ? !nonFinalUnlinks.isEmpty - : identityMayHaveChanged - if shouldInvalidateEntry, !snapshot.nodeIDs.isEmpty { - // Negative dentries are never cached. A known old/current identity means the pathname may - // have a positive dentry, so invalidate it after DELETE to reveal the current replacement. - // For a nonfinal hard-link removal ENTRY is the namespace operation: DELETE would falsely - // signal that the shared inode itself died while another link remains. - for parentNodeID in snapshot.parentNodeIDs { - invalidations["e:\(parentNodeID):\(name)"] = .entry( - parentNodeID: parentNodeID, - name: name - ) - } - } - return invalidations - } - - /// Multiple hard-link aliases can plan the same inode. A content invalidation must dominate an - /// attribute-only invalidation regardless of FSEvents batch iteration order. - private static func mergeInvalidation( - _ incoming: VirtioFSInvalidation, - preservingStronger existing: VirtioFSInvalidation? - ) -> VirtioFSInvalidation { - guard let existing else { return incoming } - if case .inode(_, 0, -1) = existing, - case .inode(_, -1, 0) = incoming { - return existing - } - return incoming - } - - private func degrade(_ reason: String) { - // This is synchronous and happens before diagnostics/callbacks: every later response will - // advertise zero validity even if the notification transport is still technically alive. - if cachingWasActivated, cacheValidityMayRemain, cacheExpiryDeadline == nil { - cacheExpiryDeadline = ProcessInfo.processInfo.systemUptime - + Double(VirtioFS.maximumCoherentCacheValiditySeconds) - + 0.1 - } - for endpoint in endpoints { - endpoint.backend.deactivateCoherentCaching() - } - // A loss burst may be reported in several batches. Caching is already disabled after - // the first one, so repeat diagnostics add noise without communicating a new state. - guard !isDegraded else { return } - isDegraded = true - onDegraded(reason) - } - - /// Recovers from FSEvents loss in place instead of restarting the VM. The window is - /// fail-closed for freshness but not for availability: caching is deactivated first (new - /// grants are zero-TTL), every known path whose pinned identity no longer matches the disk - /// gets its identity-verified DELETE/ENTRY invalidation through the ordinary pipeline, and a - /// content+attribute sweep over every known node retires stale pages and attributes that an - /// in-place host write could have left behind. Entry invalidation stays identity-gated: a - /// verified-unchanged dentry must survive because fuse_reverse_inval_entry's d_invalidate - /// detaches container bind mounts beneath it. Negative dentries expire on their own 1 s bound. - /// Once the notification barrier acknowledges the sweep, caching is reactivated. - private func recoverFromEventLoss(latestEventID: UInt64) async throws { - consecutiveLossRecoveries += 1 - guard consecutiveLossRecoveries <= Self.maximumConsecutiveLossRecoveries else { - requireFatalRecovery( - "FSEvents loss recurred \(consecutiveLossRecoveries) times without converging; restarting VM to discard unknown descendant cache state" - ) - return - } - degrade("FSEvents lost host-share changes; recovering in place with caching disabled") - - do { - let synthesizedFlags = UInt32( - kFSEventStreamEventFlagItemRemoved | - kFSEventStreamEventFlagItemRenamed - ) - var staleChanges = [HostFSEventChange]() - for endpoint in endpoints { - for stalePath in endpoint.backend.hostFS.knownStaleHostPathsForNamespaceReconciliation() { - guard let guestPath = endpoint.share.mapHostPathToGuest(stalePath) else { continue } - staleChanges.append(HostFSEventChange( - hostPath: stalePath, - guestPath: guestPath, - flags: synthesizedFlags, - eventID: latestEventID - )) - } - } - let prepared = prepare(staleChanges) - for item in prepared where !item.invalidations.isEmpty { - try await item.endpoint.backend.invalidateAtomically( - item.invalidations, - maximumBatchSize: max(1, min(128, item.endpoint.backend.notificationBacklogLimit)), - timeout: Self.lossRecoveryDeadline(invalidationCount: item.invalidations.count) - ) - } - - for endpoint in endpoints { - let sweep = endpoint.backend.hostFS.knownNodeIDsForLossRecovery().map { - VirtioFSInvalidation.inode(nodeID: $0, offset: 0, length: -1) - } - guard !sweep.isEmpty else { continue } - try await endpoint.backend.invalidateAtomically( - sweep, - maximumBatchSize: max(1, min(128, endpoint.backend.notificationBacklogLimit)), - timeout: Self.lossRecoveryDeadline(invalidationCount: sweep.count) - ) - } - } catch { - requireFatalRecovery( - "host-share loss recovery could not publish its invalidation sweep; restarting VM: \(error)" - ) - return - } - - // The sweep and barrier make the guest coherent again; the degrade latch can lift. If - // reactivation is not currently eligible (for example the notification buffers are being - // reposted), the share simply keeps running with zero-TTL grants, which is correct. - isDegraded = false - cacheExpiryDeadline = nil - let reactivated = (try? await performActivation(requireNoBatch: false)) ?? false - onRecovered( - reactivated - ? "host-share coherence recovered from FSEvents loss; caching reactivated" - : "host-share coherence recovered from FSEvents loss; running uncached until the channel is eligible again" - ) - } - - private static func lossRecoveryDeadline(invalidationCount: Int) -> Duration { - min( - Self.maximumLossRecoveryInvalidationDeadline, - .seconds(5) + .milliseconds(2 * invalidationCount) - ) - } - - private func requireFatalRecovery(_ reason: String) { - // Unknown host edits make every endpoint unsafe, including read-only mounts with stale - // open-file page cache. Establish the publication boundary synchronously and for all - // aliases before diagnostics or the VM-stop callback can run. A late notification ack or - // guest-controlled device reset cannot reopen this one-way backend latch. - for endpoint in endpoints { - endpoint.backend.failStopRequestPublication() - } - degrade(reason) - if !fatalRecoveryRequested { - fatalRecoveryRequested = true - onFatalRecoveryRequired(reason) - } - } - - private func beginBatch() async { - if !batchInProgress { - batchInProgress = true - return - } - await withCheckedContinuation { continuation in - batchWaiters.append(continuation) - } - } - - private func endBatch() { - if batchWaiters.isEmpty { - batchInProgress = false - } else { - // Ownership passes directly to the oldest waiter; keep the gate closed so a new caller - // cannot overtake it between continuation resumption and actor re-entry. - batchWaiters.removeFirst().resume() - } - } - - private func waitForPreviouslyIssuedCacheValidity() async throws { - guard cacheValidityMayRemain else { return } - if cacheExpiryDeadline == nil { - cacheExpiryDeadline = ProcessInfo.processInfo.systemUptime - + Double(VirtioFS.maximumCoherentCacheValiditySeconds) - + 0.1 - } - if let deadline = cacheExpiryDeadline { - let remaining = max(0, deadline - ProcessInfo.processInfo.systemUptime) - if remaining > 0 { - try await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) - } - } - cacheValidityMayRemain = false - cacheExpiryDeadline = nil - } - - private func wasNudgeDelivered(_ target: NudgeTarget) -> Bool { - deliveredNudges.contains(NudgeKey(guestPath: target.guestPath, eventID: target.eventID)) - } - - private func recordDeliveredNudge(_ target: NudgeTarget) { - deliveredNudges.insert(NudgeKey(guestPath: target.guestPath, eventID: target.eventID)) - } - - private func retireDeliveredNudges(coveredBy targets: [NudgeTarget]) { - guard !deliveredNudges.isEmpty, !targets.isEmpty else { return } - let covered = Dictionary(targets.map { ($0.guestPath, $0.eventID) }) { _, newer in newer } - deliveredNudges = deliveredNudges.filter { key in - guard let through = covered[key.guestPath] else { return true } - return key.eventID > through - } - } -} - -/// A small synchronous gate shared by the FSEvents callback and the actor. Holding its lock across -/// activation makes failure-vs-activation ordering total: either activation wins and the failure -/// immediately revokes it, or failure wins and activation is refused for that generation. -private final class RelayDeliveryHealth: @unchecked Sendable { - private let lock = NSLock() - private let onFailure: @Sendable () -> Void - private var generation: UInt64 = 1 - private var caughtUp = true - - init(onFailure: @escaping @Sendable () -> Void) { - self.onFailure = onFailure - } - - var readyGeneration: UInt64? { - lock.withLock { caughtUp ? generation : nil } - } - - func markFailed() { - lock.withLock { - generation &+= 1 - caughtUp = false - onFailure() - } - } - - func markSucceeded() { - lock.withLock { - generation &+= 1 - caughtUp = true - } - } - - func whileReady(generation expected: UInt64, _ body: () -> Bool) -> Bool { - lock.withLock { - guard caughtUp, generation == expected else { return false } - return body() - } - } -} - -private extension Array { - func chunked(maximumCount: Int) -> [[Element]] { - guard !isEmpty else { return [] } - let size = Swift.max(1, maximumCount) - var result = [[Element]]() - result.reserveCapacity((count + size - 1) / size) - var start = 0 - while start < count { - let end = Swift.min(count, start + size) - result.append(Array(self[start.. -/// in-process vsock -> guest agent. A failed canary alone is never enough to restart the VM. It -/// counts only when the independent witness answers at the same time, isolating the sidecar without -/// treating host network loss, guest startup, or guest overload as a gvproxy fault. +/// The inert canary reaches the guest agent's HTTP witness through a private gvproxy unix forward; +/// the independent witness reaches dockerd's Unix socket through engine.sock -> in-process vsock -> +/// guest agent. A failed canary alone is never enough to restart the VM. It counts only when the +/// Docker witness answers at the same time, isolating the sidecar without treating host network +/// loss, guest startup, or guest overload as a gvproxy fault. public struct GVProxyDatapathGuard: Sendable { public enum Decision: Equatable, Sendable { case healthy case recovered(previousFailures: Int) + case awaitingReadiness case inconclusive case suspected(consecutiveFailures: Int) case restartRequired(consecutiveFailures: Int) @@ -18,6 +19,7 @@ public struct GVProxyDatapathGuard: Sendable { public let failureThreshold: Int private var consecutiveFailures = 0 private var restartRequested = false + private var hasObservedHealthyCanary = false public init(failureThreshold: Int = 3) { self.failureThreshold = max(2, failureThreshold) @@ -34,6 +36,7 @@ public struct GVProxyDatapathGuard: Sendable { if gvproxyCanaryReachable { let previous = consecutiveFailures consecutiveFailures = 0 + hasObservedHealthyCanary = true return previous > 0 ? .recovered(previousFailures: previous) : .healthy } @@ -44,6 +47,13 @@ public struct GVProxyDatapathGuard: Sendable { return .inconclusive } + // Recovery is valid only for a path whose working baseline was observed by this engine + // invocation. Otherwise a missing/misconfigured startup canary causes an endless sequence + // of healthy-VM restarts while proving nothing about a gvproxy regression. + guard hasObservedHealthyCanary else { + return .awaitingReadiness + } + consecutiveFailures += 1 guard consecutiveFailures >= failureThreshold else { return .suspected(consecutiveFailures: consecutiveFailures) diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift new file mode 100644 index 00000000..cdad2866 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/GVProxyDesktopLaunchPlan.swift @@ -0,0 +1,43 @@ +/// Builds the isolated gvproxy command line used by persistent desktop VMs. +/// +/// Desktop control and shell access travel over dedicated per-machine vsock bridges. Keeping +/// gvproxy on private Unix sockets—and explicitly disabling its legacy SSH forward—prevents one +/// desktop from claiming a host-global port needed by another desktop or by its own restart. +package enum GVProxyDesktopLaunchPlan { + package static let hostOnlyConfigurationYAML = """ + stack: + connectivity: host-only + + """ + + /// Pins gvproxy's deterministic guest lease to the MAC persisted in the resolved plan. + /// Without this binding an adapter could expose one MAC while DHCP authority retained the + /// historical global default. + package static func configurationYAML(hostOnly: Bool, guestMAC: String) -> String { + let connectivity = hostOnly ? " connectivity: host-only\n" : "" + return """ + stack: + \(connectivity) dhcpStaticLeases: + 192.168.127.2: \(guestMAC) + + """ + } + + package static func arguments( + mtu: Int, + datapathSocket: String, + apiSocket: String, + configurationPath: String? = nil + ) -> [String] { + var arguments = [ + "-mtu", String(mtu), + "-listen-vfkit", "unixgram://\(datapathSocket)", + "-listen", "unix://\(apiSocket)", + "-ssh-port", "-1", + ] + if let configurationPath { + arguments.append(contentsOf: ["-config", configurationPath]) + } + return arguments + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GuestDatapathCanary.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GuestDatapathCanary.swift index ec9456b6..427f6170 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/GuestDatapathCanary.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/GuestDatapathCanary.swift @@ -2,9 +2,10 @@ /// without exposing dockerd or another privileged control plane on the guest network. public enum GuestDatapathCanary: Sendable { public static let port: UInt16 = 2_380 + public static let environmentKey = "DORY_DATAPATH_CANARY_PORT" + public static let requiredBootConfigurationKernelArgument = "dory.config=required" - public static func listener() -> String { - let response = "HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\nConnection: close\\r\\n\\r\\nOK" - return "( while true; do printf '\(response)' | nc -l -p \(port) >/dev/null 2>&1 || true; done ) &" + public static func agentEnvironmentAssignment() -> String { + "\(environmentKey)=\(port)" } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GuestFSEventBridge.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GuestFSEventBridge.swift index 4ba5fad7..4601bc46 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/GuestFSEventBridge.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/GuestFSEventBridge.swift @@ -1,3 +1,4 @@ +import DoryFSWorkerContracts import Foundation public struct GuestFSEventBatchResult: Equatable, Sendable { @@ -27,8 +28,12 @@ public enum GuestFSEventBridgeError: Error, Equatable { case operationIDConflict case dedupeCapacityExhausted case guestExecutionFailed + case watcherNotReady case timedOut case connectionClosed + case serviceAdmissionFailed(VirtioVsockServiceAdmissionError) + case connectionAdmissionFailed(VirtioVsockConnectionAdmissionError) + case outboundBackpressure } public enum GuestFSEventBatchCodec { @@ -152,26 +157,67 @@ public extension GuestFSEventSending { /// be reached through Dory's remote-machine control surface. public final class GuestFSEventBridge: GuestFSEventSending, @unchecked Sendable { private let vsock: VirtioVsock - private let timeoutNanoseconds: UInt64 + private let readinessGate: GuestFSEventReadinessGate - public init(vsock: VirtioVsock, timeoutNanoseconds: UInt64 = 2_000_000_000) { + public init( + vsock: VirtioVsock, + timeoutNanoseconds: UInt64 = DoryFSWorkerCoherenceTiming + .guestWatcherAttemptNanoseconds, + startupGraceNanoseconds: UInt64 = DoryFSWorkerCoherenceTiming + .guestWatcherStartupGraceNanoseconds, + startupRetryDelayNanoseconds: UInt64 = DoryFSWorkerCoherenceTiming + .guestWatcherRetryDelayNanoseconds, + startupMaximumRetryDelayNanoseconds: UInt64 = DoryFSWorkerCoherenceTiming + .guestWatcherMaximumRetryDelayNanoseconds + ) { self.vsock = vsock - self.timeoutNanoseconds = timeoutNanoseconds + readinessGate = GuestFSEventReadinessGate( + attemptTimeoutNanoseconds: timeoutNanoseconds, + startupGraceNanoseconds: startupGraceNanoseconds, + retryDelayNanoseconds: startupRetryDelayNanoseconds, + maximumRetryDelayNanoseconds: startupMaximumRetryDelayNanoseconds + ) } public func send(operationID: UInt64, paths: [String]) async throws -> GuestFSEventBatchResult { let frame = try GuestFSEventBatchCodec.encodeRequest(operationID: operationID, paths: paths) + return try await transact( + frame: frame, + operationID: operationID, + pathCount: paths.count, + readinessProbe: false + ) + } + + private func transact( + frame: [UInt8], + operationID: UInt64, + pathCount: Int, + readinessProbe: Bool + ) async throws -> GuestFSEventBatchResult { let cancellation = GuestFSEventCancellation() return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { [self] in do { - let result = try send( - frame: frame, - operationID: operationID, - pathCount: paths.count, - cancellation: cancellation - ) + let result = if readinessProbe { + try readinessGate.establish { maximumAttemptNanoseconds in + try send( + frame: frame, + operationID: operationID, + pathCount: pathCount, + cancellation: cancellation, + timeoutNanoseconds: maximumAttemptNanoseconds + ) + } + } else { + try deliver( + frame: frame, + operationID: operationID, + pathCount: pathCount, + cancellation: cancellation + ) + } continuation.resume(returning: result) } catch { continuation.resume(throwing: error) @@ -183,15 +229,71 @@ public final class GuestFSEventBridge: GuestFSEventSending, @unchecked Sendable } } - private func send( + /// A zero-path transaction proves that the guest listener has bound port 1028 and can decode, + /// execute, and acknowledge the complete protocol. It changes no guest filesystem state. + public func establishReadiness() async throws { + let frame = try GuestFSEventBatchCodec.encodeRequest(operationID: 0, paths: []) + _ = try await transact( + frame: frame, + operationID: 0, + pathCount: 0, + readinessProbe: true + ) + } + + /// Engine mode is a synchronous composition root. It starts the VM on its dedicated owner + /// thread, then uses this barrier before accepting a long-running engine generation. + public func establishReadinessBlocking() throws { + let frame = try GuestFSEventBatchCodec.encodeRequest(operationID: 0, paths: []) + let cancellation = GuestFSEventCancellation() + _ = try readinessGate.establish { maximumAttemptNanoseconds in + try send( + frame: frame, + operationID: 0, + pathCount: 0, + cancellation: cancellation, + timeoutNanoseconds: maximumAttemptNanoseconds + ) + } + } + + private func deliver( frame: [UInt8], operationID: UInt64, pathCount: Int, cancellation: GuestFSEventCancellation + ) throws -> GuestFSEventBatchResult { + try readinessGate.perform { maximumAttemptNanoseconds in + try send( + frame: frame, + operationID: operationID, + pathCount: pathCount, + cancellation: cancellation, + timeoutNanoseconds: maximumAttemptNanoseconds + ) + } + } + + private func send( + frame: [UInt8], + operationID: UInt64, + pathCount: Int, + cancellation: GuestFSEventCancellation, + timeoutNanoseconds: UInt64 ) throws -> GuestFSEventBatchResult { let deadline = ProcessInfo.processInfo.systemUptime + Double(timeoutNanoseconds) / 1_000_000_000 - let connection = vsock.connect(port: VsockPorts.fsevents) + let connection: VsockConnection + do { + connection = try vsock.connectForServiceIfCapacity( + port: VsockPorts.fsevents, + service: .fileEvents + ) + } catch let error as VirtioVsockServiceAdmissionError { + throw GuestFSEventBridgeError.serviceAdmissionFailed(error) + } catch let error as VirtioVsockConnectionAdmissionError { + throw GuestFSEventBridgeError.connectionAdmissionFailed(error) + } try cancellation.install(connection) defer { connection.close() } do { @@ -200,6 +302,8 @@ public final class GuestFSEventBridge: GuestFSEventSending, @unchecked Sendable throw GuestFSEventBridgeError.timedOut } catch VsockConnectionWriteError.connectionClosed { throw GuestFSEventBridgeError.connectionClosed + } catch VsockConnectionWriteError.outboundQueueFull { + throw GuestFSEventBridgeError.outboundBackpressure } connection.shutdownSend() let prefix = try readExactly(4, from: connection, deadline: deadline) @@ -247,6 +351,144 @@ public final class GuestFSEventBridge: GuestFSEventSending, @unchecked Sendable } } +/// Serializes watcher transactions. Only the explicit zero-path readiness probe owns the bounded +/// startup retry window; real batches are rejected until that probe succeeds, preventing a long +/// retry transaction from nesting inside worker XPC activation. Once ready, any transport loss +/// immediately reaches the existing fail-stop coherence boundary. +final class GuestFSEventReadinessGate: @unchecked Sendable { + typealias Uptime = @Sendable () -> TimeInterval + typealias Sleeper = @Sendable (UInt64) -> Void + + private let deliveryLock = NSLock() + private let stateLock = NSLock() + private let attemptTimeoutNanoseconds: UInt64 + private let startupGraceNanoseconds: UInt64 + private let retryDelayNanoseconds: UInt64 + private let maximumRetryDelayNanoseconds: UInt64 + private let uptime: Uptime + private let sleep: Sleeper + private var ready = false + + init( + attemptTimeoutNanoseconds: UInt64, + startupGraceNanoseconds: UInt64, + retryDelayNanoseconds: UInt64, + maximumRetryDelayNanoseconds: UInt64 = DoryFSWorkerCoherenceTiming + .guestWatcherMaximumRetryDelayNanoseconds, + uptime: @escaping Uptime = { ProcessInfo.processInfo.systemUptime }, + sleep: @escaping Sleeper = { nanoseconds in + Thread.sleep(forTimeInterval: Double(nanoseconds) / 1_000_000_000) + } + ) { + precondition(attemptTimeoutNanoseconds > 0) + precondition(maximumRetryDelayNanoseconds >= retryDelayNanoseconds) + self.attemptTimeoutNanoseconds = attemptTimeoutNanoseconds + self.startupGraceNanoseconds = startupGraceNanoseconds + self.retryDelayNanoseconds = retryDelayNanoseconds + self.maximumRetryDelayNanoseconds = maximumRetryDelayNanoseconds + self.uptime = uptime + self.sleep = sleep + } + + var isReady: Bool { stateLock.withLock { ready } } + + func establish( + _ attempt: (_ maximumAttemptNanoseconds: UInt64) throws -> Result + ) throws -> Result { + deliveryLock.lock() + defer { deliveryLock.unlock() } + + if stateLock.withLock({ ready }) { + return try attempt(attemptTimeoutNanoseconds) + } + let startedAt = uptime() + let startupDeadline = startedAt + + Double(startupGraceNanoseconds) / 1_000_000_000 + var nextRetryDelayNanoseconds = retryDelayNanoseconds + + while true { + let maximumAttemptNanoseconds = min( + attemptTimeoutNanoseconds, + Self.remainingNanoseconds(until: startupDeadline, now: uptime()) + ) + + do { + let result = try attempt(max(1, maximumAttemptNanoseconds)) + stateLock.withLock { ready = true } + return result + } catch { + let now = uptime() + guard !stateLock.withLock({ ready }), + GuestFSEventBridgeError.isRetryableBeforeReadiness(error), + now < startupDeadline else { + throw error + } + let remaining = Self.remainingNanoseconds( + until: startupDeadline, + now: now + ) + sleep(min(nextRetryDelayNanoseconds, remaining)) + nextRetryDelayNanoseconds = min( + maximumRetryDelayNanoseconds, + Self.saturatingDouble(nextRetryDelayNanoseconds) + ) + } + } + } + + func perform( + _ attempt: (_ maximumAttemptNanoseconds: UInt64) throws -> Result + ) throws -> Result { + deliveryLock.lock() + defer { deliveryLock.unlock() } + guard stateLock.withLock({ ready }) else { + throw GuestFSEventBridgeError.watcherNotReady + } + return try attempt(attemptTimeoutNanoseconds) + } + + private static func remainingNanoseconds( + until deadline: TimeInterval, + now: TimeInterval + ) -> UInt64 { + UInt64(max(0, deadline - now) * 1_000_000_000) + } + + private static func saturatingDouble(_ value: UInt64) -> UInt64 { + let (doubled, overflow) = value.multipliedReportingOverflow(by: 2) + return overflow ? UInt64.max : doubled + } +} + +private extension GuestFSEventBridgeError { + static func isRetryableBeforeReadiness(_ error: any Error) -> Bool { + guard let error = error as? GuestFSEventBridgeError else { return false } + switch error { + case .timedOut, .connectionClosed, .outboundBackpressure: + return true + case .serviceAdmissionFailed(let failure): + switch failure { + case .serviceCapacityReached, .aggregateCapacityReached, .deviceResetting: + return true + case .deviceQuiesced, .lifecycleRevoked: + return false + } + case .connectionAdmissionFailed(let failure): + switch failure { + case .connectionCapacityReached, .hostPortRangeExhausted, + .outboundQueueCapacityReached: + return true + case .deviceQuiesced: + return false + } + case .tooManyPaths, .invalidOperationID, .invalidPath, .oversizedFrame, + .invalidResponse, .operationIDConflict, .dedupeCapacityExhausted, + .guestExecutionFailed, .watcherNotReady: + return false + } + } +} + private final class GuestFSEventOperationIDSource: @unchecked Sendable { private let lock = NSLock() private var value: UInt64 = { diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemory.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemory.swift index c94f694e..d874e82b 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemory.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemory.swift @@ -1,4 +1,5 @@ import Darwin +import DoryGuestMemoryShim import Foundation import Hypervisor import Synchronization @@ -24,36 +25,159 @@ public final class ByteCounter: @unchecked Sendable { } } -/// The VM's RAM: one anonymous mmap region in OUR address space, mapped into the guest at a fixed -/// physical base. Owning the pages is the entire point of dory-hv: reclaim is madvise on this -/// region, something Virtualization.framework structurally cannot offer (its guest RAM lives in -/// Apple's XPC process). +/// Raw host pointers never escape these synchronous callbacks. GuestMemory invokes them only while +/// holding its page-state mutex; unchecked Sendable documents that external serialization seam. +struct GuestMemoryReclaimOperations: @unchecked Sendable { + static let production = GuestMemoryReclaimOperations( + unmap: { guestAddress, length in + hv_vm_unmap(guestAddress, length) == HV_SUCCESS + }, + map: { hostAddress, guestAddress, length in + hv_vm_map( + hostAddress, + guestAddress, + length, + hv_memory_flags_t(HV_MEMORY_READ | HV_MEMORY_WRITE | HV_MEMORY_EXEC) + ) == HV_SUCCESS + }, + markReusable: { hostAddress, length in + madvise(hostAddress, length, MADV_FREE_REUSABLE) == 0 + }, + markInUse: { hostAddress, length in + madvise(hostAddress, length, MADV_FREE_REUSE) == 0 + } + ) + + let unmap: (UInt64, Int) -> Bool + let map: (UnsafeMutableRawPointer, UInt64, Int) -> Bool + let markReusable: (UnsafeMutableRawPointer, Int) -> Bool + let markInUse: (UnsafeMutableRawPointer, Int) -> Bool +} + +/// Exact outcome of a free-page-reporting reclaim attempt. In particular, `unmappedNotReclaimed` +/// is not a rejection: stage-2 ownership was removed and is tracked for fault-time restoration, +/// but macOS did not accept the reusable-memory advice so the bytes must not be counted reclaimed. +public enum GuestMemoryReleaseResult: Equatable, Sendable { + case reclaimed + case unmappedNotReclaimed + case rejected + case unmapFailed + + public var guestMappingWasReleased: Bool { + switch self { + case .reclaimed, .unmappedNotReclaimed: true + case .rejected, .unmapFailed: false + } + } + + public var hostMemoryWasReclaimed: Bool { self == .reclaimed } +} + +/// One owned, bounded view of the VMM's unlinked guest-RAM backing object. The descriptor is an +/// independently closeable authority suitable for transfer to a signed worker. +struct GuestMemorySharedRegion: @unchecked Sendable { + let descriptor: FileHandle + let offset: UInt64 + let length: UInt64 + let declaredFileSize: UInt64 +} + +/// The VM's RAM: one unlinked shared mapping in OUR address space, mapped into the guest at a fixed +/// physical base. The shareable backing lets isolated device workers map only explicitly granted +/// slices while dory-hv retains reclaim and stage-2 ownership. public final class GuestMemory: @unchecked Sendable { + private enum PageMappingState: Equatable { + case mapped + case released(reclaimed: Bool, requiresMarkInUse: Bool) + } + public let guestBase: UInt64 public let size: UInt64 public let hostBase: UnsafeMutableRawPointer + /// Unlinked, process-private shared-memory authority. Renderer workers receive only bounded + /// CLOEXEC duplicates of this descriptor, never a host pointer or a filesystem path. + private let backingDescriptor: Int32 + private let backingDeclaredFileSize: UInt64 + private let backingIdentity: DoryGuestMemoryBackingIdentity public let releasedBytes = ByteCounter() public let restoredBytes = ByteCounter() + public let reclaimUnmapFailures = ByteCounter() + public let reclaimAdviceFailures = ByteCounter() + public let restoreAdviceFailures = ByteCounter() + public let restoreMapFailures = ByteCounter() static let pageSize: UInt64 = HostPage.size - private let releasedPages: Mutex<[Bool]> + private let pageStates: Mutex<[PageMappingState]> + private let reclaimOperations: GuestMemoryReclaimOperations + + public convenience init(guestBase: UInt64, size: UInt64) throws { + try self.init( + guestBase: guestBase, + size: size, + reclaimOperations: .production + ) + } - public init(guestBase: UInt64, size: UInt64) throws { + init( + guestBase: UInt64, + size: UInt64, + reclaimOperations: GuestMemoryReclaimOperations + ) throws { guard size > 0, size % Self.pageSize == 0 else { throw VMError.invalidConfiguration("RAM size must be a positive multiple of the host page size") } - guard let region = mmap(nil, Int(size), PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0), - region != MAP_FAILED else { + guard DoryGuestMemoryBackingDataOffset() == Self.pageSize else { + throw VMError.invalidConfiguration("guest RAM authority page size does not match the host") + } + var identity = DoryGuestMemoryBackingIdentity() + var declaredFileSize: UInt64 = 0 + let descriptor = DoryCreateGuestMemoryBacking( + size, + &identity, + &declaredFileSize + ) + guard descriptor >= 0 else { + throw VMError.outOfMemory( + "cannot create guest RAM shared-memory authority: errno \(errno)" + ) + } + var descriptorIsOwned = true + defer { + if descriptorIsOwned { close(descriptor) } + } + guard DoryGuestMemoryBackingMatches( + descriptor, + declaredFileSize, + &identity + ) == 1 else { + throw VMError.outOfMemory("guest RAM authority failed identity validation") + } + guard let region = mmap( + nil, + Int(size), + PROT_READ | PROT_WRITE, + MAP_SHARED, + descriptor, + off_t(DoryGuestMemoryBackingDataOffset()) + ), region != MAP_FAILED else { throw VMError.outOfMemory("mmap of \(size) bytes failed: errno \(errno)") } self.guestBase = guestBase self.size = size self.hostBase = region - self.releasedPages = Mutex([Bool](repeating: false, count: Int(size / Self.pageSize))) + self.backingDescriptor = descriptor + self.backingDeclaredFileSize = declaredFileSize + self.backingIdentity = identity + self.pageStates = Mutex( + [PageMappingState](repeating: .mapped, count: Int(size / Self.pageSize)) + ) + self.reclaimOperations = reclaimOperations + descriptorIsOwned = false } deinit { munmap(hostBase, Int(size)) + close(backingDescriptor) } public func mapIntoGuest() throws { @@ -67,49 +191,75 @@ public final class GuestMemory: @unchecked Sendable { /// range is unmapped from the guest first, then marked reusable; the physical pages leave the /// process footprint immediately. The guest gets the range back lazily via handleRAMFault. @discardableResult - public func releaseRange(guestAddress: UInt64, length: UInt64) -> Bool { + public func releaseRange(guestAddress: UInt64, length: UInt64) -> GuestMemoryReleaseResult { guard contains(guestAddress, count: length), length > 0, - guestAddress % Self.pageSize == 0, length % Self.pageSize == 0 else { return false } + guestAddress % Self.pageSize == 0, length % Self.pageSize == 0 else { return .rejected } let first = Int((guestAddress - guestBase) / Self.pageSize) let count = Int(length / Self.pageSize) let host = hostBase.advanced(by: Int(guestAddress - guestBase)) - // The bitmap flip and the stage-2 unmap happen atomically under one lock, so a concurrent - // restorePage on another vCPU can never observe an unmapped-but-unmarked page (which it - // would misread as a genuine fault and crash). - return releasedPages.withLock { pages -> Bool in - guard hv_vm_unmap(guestAddress, Int(length)) == HV_SUCCESS else { return false } - _ = madvise(host, Int(length), MADV_FREE_REUSABLE) - for page in first.. GuestMemoryReleaseResult in + let end = min(first + count, states.count) + guard first < end, + states[first.. Bool { guard contains(guestAddress, count: 1) else { return false } let pageStart = guestAddress & ~(Self.pageSize - 1) let index = Int((pageStart - guestBase) / Self.pageSize) let host = hostBase.advanced(by: Int(pageStart - guestBase)) - return releasedPages.withLock { pages -> Bool in - guard index < pages.count else { return false } - // Bit clear means a concurrent fault on the same page already remapped it (both - // observed the fault; whoever won the lock first did the mapping). A stage-2 RAM fault - // never occurs on a page we did not unmap, so treating this as already-mapped is safe - // and the guest's retry resolves it. - guard pages[index] else { return true } - _ = madvise(host, Int(Self.pageSize), MADV_FREE_REUSE) - guard hv_vm_map(host, pageStart, Int(Self.pageSize), hv_memory_flags_t(HV_MEMORY_READ | HV_MEMORY_WRITE | HV_MEMORY_EXEC)) == HV_SUCCESS else { + return pageStates.withLock { states -> Bool in + guard index < states.count else { return false } + // Mapped means a concurrent fault on this page already won the lock and restored it. + // The guest retry can proceed without another stage-2 map or accounting change. + guard case .released(let reclaimed, let requiresMarkInUse) = states[index] else { + return true + } + if requiresMarkInUse { + guard reclaimOperations.markInUse(host, Int(Self.pageSize)) else { + restoreAdviceFailures.add(1) + return false + } + // MADV_FREE_REUSE succeeded even if the following stage-2 map does not. Persist + // that sub-state so a retry never repeats a one-way host advice transition. + states[index] = .released(reclaimed: reclaimed, requiresMarkInUse: false) + } + guard reclaimOperations.map(host, pageStart, Int(Self.pageSize)) else { + restoreMapFailures.add(1) return false } - pages[index] = false - restoredBytes.add(Self.pageSize) + states[index] = .mapped + if reclaimed { restoredBytes.add(Self.pageSize) } return true } } @@ -127,6 +277,60 @@ public final class GuestMemory: @unchecked Sendable { return hostBase.advanced(by: Int(guestAddress - guestBase)) } + /// Produces one bounded descriptor slice over guest RAM for an isolated device worker. The + /// file was unlinked before this object became visible, so the returned authority is path-free + /// and disappears when the last duplicate closes. + func duplicateSharedRegion( + at guestAddress: UInt64, + count: UInt64 + ) throws -> GuestMemorySharedRegion { + let bounds = try sharedRegionBounds(at: guestAddress, count: count) + let descriptor = try duplicateSharedBackingDescriptor() + return GuestMemorySharedRegion( + descriptor: descriptor, + offset: bounds.offset, + length: bounds.length, + declaredFileSize: bounds.declaredFileSize + ) + } + + func sharedRegionBounds( + at guestAddress: UInt64, + count: UInt64 + ) throws -> (offset: UInt64, length: UInt64, declaredFileSize: UInt64) { + guard count > 0, contains(guestAddress, count: count) else { + throw VMError.guestMemoryFault(address: guestAddress, count: count) + } + return ( + DoryGuestMemoryBackingDataOffset() + guestAddress - guestBase, + count, + backingDeclaredFileSize + ) + } + + func duplicateSharedBackingDescriptor() throws -> FileHandle { + let duplicate = fcntl(backingDescriptor, F_DUPFD_CLOEXEC, 0) + guard duplicate >= 0 else { + throw VMError.outOfMemory("cannot duplicate guest RAM authority: errno \(errno)") + } + guard sharedBackingDescriptorMatches(duplicate) else { + close(duplicate) + throw VMError.invalidConfiguration("guest RAM authority identity changed") + } + return FileHandle(fileDescriptor: duplicate, closeOnDealloc: true) + } + + /// Tests and cold-path authority handoff use the same exact descriptor identity check. It + /// rejects regular files, stale descriptors, resized objects, and another VM's same-sized RAM. + func sharedBackingDescriptorMatches(_ descriptor: Int32) -> Bool { + var identity = backingIdentity + return DoryGuestMemoryBackingMatches( + descriptor, + backingDeclaredFileSize, + &identity + ) == 1 + } + public func read(_ type: T.Type, at guestAddress: UInt64) throws -> T { let pointer = try hostPointer(at: guestAddress, count: UInt64(MemoryLayout.size)) var value = T.zero diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemoryReclaimBootCommand.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemoryReclaimBootCommand.swift index 9c58f44c..35696c5f 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemoryReclaimBootCommand.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemoryReclaimBootCommand.swift @@ -6,8 +6,8 @@ public enum GuestMemoryReclaimBootCommand { private static let quietGate = "set -- $(awk '/^cpu /{t=0; for(i=2;i<=NF;i++) t+=$i; print t,$5; exit}' /proc/stat); total=${1:-0}; idle=${2:-0}; quiet=0; if [ ${prev_total:-0} -gt 0 ]; then dt=$((total-prev_total)); di=$((idle-prev_idle)); [ $dt -gt 0 ] && [ $((100 - (di * 100 / dt))) -le 8 ] && quiet=1; fi; prev_total=$total; prev_idle=$idle; running=$(docker -H unix:///var/run/docker.sock ps -q 2>/dev/null | wc -l | tr -d ' '); if [ ${running:-0} -gt 0 ] && [ $quiet -eq 1 ]; then quiet_running_ticks=$((quiet_running_ticks+1)); else quiet_running_ticks=0; fi" - /// Emits the idle-reclaim daemon. `experimentalSenpai` must only be true for the explicit - /// `DORY_ENGINE_RECLAIM_MODE=senpai` opt-in; false preserves the established drop-caches path. + /// Emits the idle-reclaim daemon. `experimentalSenpai` must only be true for the typed + /// `senpai` launch policy; false preserves the established drop-caches path. public static func idleLoop( experimentalSenpai: Bool, pressureMemoryPath: String = "/proc/pressure/memory" diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GuestVsockSocketBridge.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GuestVsockSocketBridge.swift new file mode 100644 index 00000000..19101c98 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/GuestVsockSocketBridge.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Publishes one private host Unix socket and relays each accepted connection to a fixed guest +/// vsock port. Desktop machines use it for the agent control and shell endpoints expected by doryd. +public final class GuestVsockSocketBridge: @unchecked Sendable { + private let socketPath: String + private let guestPort: UInt32 + private let service: VirtioVsockService + private let log: @Sendable (String) -> Void + private let listener: BoundedVsockSocketListener + + public init( + socketPath: String, + guestPort: UInt32, + service: VirtioVsockService, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { + self.socketPath = socketPath + self.guestPort = guestPort + self.service = service + self.log = log + self.listener = BoundedVsockSocketListener( + socketPath: socketPath, + mode: 0o600, + endpointLabel: "vsock bridge", + log: log + ) + } + + public static func validateSocketPath(_ socketPath: String) throws { + try VsockUnixRelay.validateSocketPath(socketPath) + } + + public func attach(to vsock: VirtioVsock) throws { + let port = guestPort + let logger = log + try listener.attach(to: vsock, service: service) { _ in + do { + return try vsock.connectIfCapacity(port: port) + } catch { + logger("vsock bridge rejected guest port \(port): \(error)") + return nil + } + } + log("vsock bridge serving \(socketPath) over guest port \(guestPort)") + } + + public func stop(timeout: TimeInterval = 1) { + listener.stop(timeout: timeout) + } + + var activeSessionCount: Int { listener.activeSessionCount } + var serviceAdmissionSnapshot: VirtioVsockServiceAdmissionSnapshot? { + listener.serviceAdmissionSnapshot + } + + deinit { + stop() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/HostAIBridge.swift b/Packages/ContainerizationEngine/Sources/DoryHV/HostAIBridge.swift index 112ad5e3..55675852 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/HostAIBridge.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/HostAIBridge.swift @@ -17,6 +17,7 @@ public final class HostAIBridge: @unchecked Sendable { private let ports: [UInt16] private let host: String private let log: @Sendable (String) -> Void + private let lifecycle: BoundedGuestVsockServiceLifecycle public init( ports: [UInt16] = HostAIBridge.defaultPorts, @@ -26,114 +27,128 @@ public final class HostAIBridge: @unchecked Sendable { self.ports = Array(Set(ports)).sorted() self.host = host self.log = log + self.lifecycle = BoundedGuestVsockServiceLifecycle( + endpointLabel: "host AI bridge", + log: log + ) } - public func attach(to vsock: VirtioVsock) { - for port in ports { - vsock.listen(port: UInt32(port)) { [self] connection in - let box = ConnectionBox(connection) - Thread.detachNewThread { - self.serve(connection: box.connection, port: port) + public func attach(to vsock: VirtioVsock) throws { + try lifecycle.beginAttachment(to: vsock) + var unregister = [@Sendable () -> Void]() + do { + let host = self.host + let log = self.log + for port in ports { + let registration = try vsock.registerServiceListener( + port: UInt32(port), + service: .hostAI + ) { [weak lifecycle] connection in + guard let lifecycle else { + connection.close() + return + } + lifecycle.admit(connection) { connection, completion in + GuestVsockHostSocketRelaySession( + connection: connection, + connector: { session in + let descriptor = Self.connectTCP( + host: host, + port: port, + context: session + ) + if descriptor == nil { + log("host AI bridge could not connect to \(host):\(port)") + } + return descriptor + }, + completion: completion + ) + } } + unregister.append { registration.close() } } + guard lifecycle.commitAttachment(unregister: unregister) else { + throw VMError.invalidConfiguration( + "host AI bridge stopped while attaching" + ) + } + } catch { + lifecycle.cancelAttachment(unregister: unregister) + throw error } if !ports.isEmpty { log("host AI bridge ready on ports \(ports.map(String.init).joined(separator: ","))") } } - private final class ConnectionBox: @unchecked Sendable { - let connection: VsockConnection - - init(_ connection: VsockConnection) { - self.connection = connection - } + public func stop(timeout: TimeInterval = 1) { + lifecycle.stop(timeout: timeout) } - private func serve(connection: VsockConnection, port: UInt16) { - guard let upstream = Self.connectTCP(host: host, port: port) else { - log("host AI bridge could not connect to \(host):\(port)") - connection.close() - return - } - defer { - connection.close() - shutdown(upstream, SHUT_RDWR) - close(upstream) - } + public var activeSessionCount: Int { + lifecycle.activeSessionCount + } - let group = DispatchGroup() - group.enter() - let box = ConnectionBox(connection) - Thread.detachNewThread { - Self.pumpTCPToVsock(from: upstream, to: box.connection) - group.leave() - } - Self.pumpVsockToTCP(from: connection, to: upstream) - // The guest has stopped sending the request: half-close only the write side to the upstream - // so it sees request-EOF while the reply keeps streaming back on the other pump. The defer - // does the full teardown once pumpTCPToVsock has drained the response. - shutdown(upstream, SHUT_WR) - group.wait() + public var serviceAdmissionSnapshot: VirtioVsockServiceAdmissionSnapshot? { + lifecycle.serviceAdmissionSnapshot } - private static func pumpTCPToVsock(from fd: Int32, to connection: VsockConnection) { - var buffer = [UInt8](repeating: 0, count: 32 * 1024) - while true { - let capacity = buffer.count - let count = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress, capacity) } - if count <= 0 { break } - do { - try connection.write(Array(buffer.prefix(count))) - } catch { - break - } - } + deinit { + lifecycle.stop() } - private static func pumpVsockToTCP(from connection: VsockConnection, to fd: Int32) { - var buffer = [UInt8](repeating: 0, count: 32 * 1024) - var pollInterval: useconds_t = 1_000 - let maxPollInterval: useconds_t = 16_000 - while true { - let capacity = buffer.count - let count = (try? buffer.withUnsafeMutableBytes { - try connection.read(into: UnsafeMutableRawBufferPointer(rebasing: $0[0.. Int32? { + connectTCP( + host: host, + port: port, + timeoutMilliseconds: timeoutMilliseconds, + context: UncancelledHostSocketConnectContext() + ) } - private static func connectTCP(host: String, port: UInt16) -> Int32? { - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { return nil } + private static func connectTCP( + host: String, + port: UInt16, + timeoutMilliseconds: Int32 = 2_000, + context: any BoundedHostSocketConnectContext + ) -> Int32? { var address = sockaddr_in() address.sin_family = sa_family_t(AF_INET) address.sin_port = in_port_t(port.bigEndian) - address.sin_addr.s_addr = inet_addr(host) - let result = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { - Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) + guard inet_pton(AF_INET, host, &address.sin_addr) == 1 else { return nil } + return BoundedHostSocketConnector.connect( + domain: AF_INET, + timeout: TimeInterval(max(0, timeoutMilliseconds)) / 1_000, + context: context, + initiate: { descriptor in + withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + }, + verify: { descriptor in + var peer = sockaddr_in() + var peerLength = socklen_t(MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &peer) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getpeername(descriptor, $0, &peerLength) + } + } + return result == 0 + && peer.sin_family == sa_family_t(AF_INET) + && peer.sin_port == address.sin_port + && peer.sin_addr.s_addr == address.sin_addr.s_addr } - } - guard result == 0 else { - close(fd) - return nil - } - return fd + ) } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/HostFileDescriptorLimit.swift b/Packages/ContainerizationEngine/Sources/DoryHV/HostFileDescriptorLimit.swift index be7a51bf..064183a2 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/HostFileDescriptorLimit.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/HostFileDescriptorLimit.swift @@ -5,9 +5,9 @@ public enum HostFileDescriptorLimitError: Error, Equatable { case update(Int32) } -/// Raises launchd's commonly low descriptor soft limit before HostFS starts pinning inode -/// identities. Existing higher limits are preserved; the requested increase is bounded so a -/// malformed hard limit cannot grant the VM process an unreasonable descriptor budget. +/// Raises the helper process's commonly low descriptor soft limit for its VMM devices, sockets, +/// and relays. The filesystem XPC service establishes its own independent limit in ServiceCore; +/// resource limits are process-local and cannot be inherited after launch. public enum HostFileDescriptorLimit { public static let ceiling: rlim_t = 262_144 diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/HostSSHAgentBridge.swift b/Packages/ContainerizationEngine/Sources/DoryHV/HostSSHAgentBridge.swift index 34e17285..cadc200b 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/HostSSHAgentBridge.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/HostSSHAgentBridge.swift @@ -26,16 +26,21 @@ public final class HostSSHAgentBridge: @unchecked Sendable { private let socketPath: String private let expectedUID: uid_t private let log: @Sendable (String) -> Void + private let lifecycle: BoundedGuestVsockServiceLifecycle public init( socketPath: String, - expectedUID: uid_t = getuid(), + expectedUID: uid_t = geteuid(), log: @escaping @Sendable (String) -> Void = { _ in } ) throws { try Self.validate(socketPath: socketPath) self.socketPath = socketPath self.expectedUID = expectedUID self.log = log + self.lifecycle = BoundedGuestVsockServiceLifecycle( + endpointLabel: "SSH agent bridge", + log: log + ) } public static func validate(socketPath: String) throws { @@ -55,27 +60,69 @@ public final class HostSSHAgentBridge: @unchecked Sendable { } } - public func attach(to vsock: VirtioVsock) { - vsock.listen(port: VsockPorts.sshAgent) { [self] connection in - let box = ConnectionBox(connection) - Thread.detachNewThread { - guard let fd = Self.connectSameUserSocket( - path: self.socketPath, - expectedUID: self.expectedUID - ) else { - self.log("SSH agent bridge rejected an unavailable or non-owned host socket") - box.connection.close() + public func attach(to vsock: VirtioVsock) throws { + try lifecycle.beginAttachment(to: vsock) + var unregister = [@Sendable () -> Void]() + do { + let socketPath = self.socketPath + let expectedUID = self.expectedUID + let log = self.log + let registration = try vsock.registerServiceListener( + port: VsockPorts.sshAgent, + service: .sshAgent + ) { [weak lifecycle] connection in + guard let lifecycle else { + connection.close() return } - VsockUnixRelay.serve(client: fd, connection: box.connection) + lifecycle.admit(connection) { connection, completion in + GuestVsockHostSocketRelaySession( + connection: connection, + connector: { session in + let descriptor = Self.connectSameUserSocket( + path: socketPath, + expectedUID: expectedUID, + context: session + ) + if descriptor == nil { + log( + "SSH agent bridge rejected an unavailable or " + + "non-owned host socket" + ) + } + return descriptor + }, + completion: completion + ) + } + } + unregister.append { registration.close() } + guard lifecycle.commitAttachment(unregister: unregister) else { + throw VMError.invalidConfiguration( + "SSH agent bridge stopped while attaching" + ) } + } catch { + lifecycle.cancelAttachment(unregister: unregister) + throw error } log("SSH agent bridge ready on guest vsock:\(VsockPorts.sshAgent)") } - private final class ConnectionBox: @unchecked Sendable { - let connection: VsockConnection - init(_ connection: VsockConnection) { self.connection = connection } + public func stop(timeout: TimeInterval = 1) { + lifecycle.stop(timeout: timeout) + } + + public var activeSessionCount: Int { + lifecycle.activeSessionCount + } + + public var serviceAdmissionSnapshot: VirtioVsockServiceAdmissionSnapshot? { + lifecycle.serviceAdmissionSnapshot + } + + deinit { + lifecycle.stop() } static func connectSameUserSocket( @@ -83,25 +130,34 @@ public final class HostSSHAgentBridge: @unchecked Sendable { expectedUID: uid_t, timeoutMilliseconds: Int32 = 2_000 ) -> Int32? { + connectSameUserSocket( + path: path, + expectedUID: expectedUID, + timeoutMilliseconds: timeoutMilliseconds, + context: UncancelledHostSocketConnectContext() + ) + } + + private static func connectSameUserSocket( + path: String, + expectedUID: uid_t, + timeoutMilliseconds: Int32 = 2_000, + context: any BoundedHostSocketConnectContext + ) -> Int32? { + let pathBytes = Array(path.utf8) + guard path.hasPrefix("/"), + !pathBytes.contains(0), + pathBytes.count <= VsockUnixRelay.maximumSocketPathByteCount else { + return nil + } var status = stat() guard lstat(path, &status) == 0, status.st_mode & mode_t(S_IFMT) == mode_t(S_IFSOCK), status.st_uid == expectedUID else { return nil } - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { return nil } - let originalFlags = fcntl(fd, F_GETFL, 0) - guard originalFlags >= 0, - fcntl(fd, F_SETFL, originalFlags | O_NONBLOCK) == 0 else { - close(fd) - return nil - } - var noSigpipe: Int32 = 1 - _ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigpipe, socklen_t(MemoryLayout.size)) var address = sockaddr_un() address.sun_family = sa_family_t(AF_UNIX) - let pathBytes = Array(path.utf8) withUnsafeMutableBytes(of: &address.sun_path) { destination in pathBytes.withUnsafeBytes { source in destination.baseAddress!.copyMemory( @@ -110,38 +166,31 @@ public final class HostSSHAgentBridge: @unchecked Sendable { ) } } - let connected = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { - Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) - } - } - if connected != 0 { - guard errno == EINPROGRESS else { - close(fd) - return nil - } - var descriptor = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0) - guard poll(&descriptor, 1, max(0, timeoutMilliseconds)) > 0 else { - close(fd) - return nil - } - var socketError: Int32 = 0 - var socketErrorLength = socklen_t(MemoryLayout.size) - guard getsockopt( - fd, - SOL_SOCKET, - SO_ERROR, - &socketError, - &socketErrorLength - ) == 0, socketError == 0 else { - close(fd) - return nil + return BoundedHostSocketConnector.connect( + domain: AF_UNIX, + timeout: TimeInterval(max(0, timeoutMilliseconds)) / 1_000, + context: context, + initiate: { descriptor in + withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + }, + verify: { descriptor in + peerUIDMatches(descriptor: descriptor, expectedUID: expectedUID) } - } - guard fcntl(fd, F_SETFL, originalFlags) == 0 else { - close(fd) - return nil - } - return fd + ) + } + + static func peerUIDMatches(descriptor: Int32, expectedUID: uid_t) -> Bool { + var peerUID: uid_t = 0 + var peerGID: gid_t = 0 + return getpeereid(descriptor, &peerUID, &peerGID) == 0 + && peerUID == expectedUID } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/KernelImage.swift b/Packages/ContainerizationEngine/Sources/DoryHV/KernelImage.swift index d825c6b9..4bfd5057 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/KernelImage.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/KernelImage.swift @@ -10,7 +10,10 @@ public struct KernelImage { private static let magic: UInt32 = 0x644D_5241 // "ARM\x64" public init(contentsOf path: String) throws { - let data = try Data(contentsOf: URL(fileURLWithPath: path)) + try self.init(data: Data(contentsOf: URL(fileURLWithPath: path))) + } + + public init(data: Data) throws { guard data.count > 64 else { throw VMError.bootFailure("kernel image too small: \(data.count) bytes") } @@ -26,7 +29,10 @@ public struct KernelImage { /// Copies the image into guest RAM and returns the entry point. public func load(into memory: GuestMemory) throws -> UInt64 { - let loadAddress = memory.guestBase + textOffset + let (loadAddress, addressOverflowed) = memory.guestBase.addingReportingOverflow(textOffset) + guard !addressOverflowed else { + throw VMError.bootFailure("kernel load address overflows") + } guard memory.contains(loadAddress, count: imageSize) else { throw VMError.bootFailure("kernel does not fit in guest RAM") } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/LZFSE.swift b/Packages/ContainerizationEngine/Sources/DoryHV/LZFSE.swift deleted file mode 100644 index afe11a54..00000000 --- a/Packages/ContainerizationEngine/Sources/DoryHV/LZFSE.swift +++ /dev/null @@ -1,90 +0,0 @@ -import Compression -import Foundation - -public enum LZFSEError: Error, CustomStringConvertible { - case openInput(String) - case openOutput(String) - case streamInit - case read - case write - case process - - public var description: String { - switch self { - case .openInput(let path): "cannot open input \(path)" - case .openOutput(let path): "cannot open output \(path)" - case .streamInit: "compression_stream_init failed" - case .read: "read failed" - case .write: "write failed" - case .process: "compression_stream_process failed" - } - } -} - -/// Streaming LZFSE codec over Apple's Compression framework, which ships in every macOS. The engine -/// uses it to compress its kernel/initfs at build time and decompress them at first launch, so there -/// is no external `zstd` binary or dylib to bundle, link, or go missing. -public enum LZFSE { - private static let chunk = 1 << 20 - - public static func compress(source: String, destination: String) throws { - try transform(source: source, destination: destination, operation: COMPRESSION_STREAM_ENCODE) - } - - public static func decompress(source: String, destination: String) throws { - try transform(source: source, destination: destination, operation: COMPRESSION_STREAM_DECODE) - } - - private static func transform(source: String, destination: String, operation: compression_stream_operation) throws { - guard let input = InputStream(fileAtPath: source) else { throw LZFSEError.openInput(source) } - guard let output = OutputStream(toFileAtPath: destination, append: false) else { throw LZFSEError.openOutput(destination) } - input.open() - output.open() - defer { input.close(); output.close() } - - let source = UnsafeMutablePointer.allocate(capacity: chunk) - let sink = UnsafeMutablePointer.allocate(capacity: chunk) - defer { source.deallocate(); sink.deallocate() } - - var stream = compression_stream(dst_ptr: sink, dst_size: chunk, src_ptr: UnsafePointer(source), src_size: 0, state: nil) - guard compression_stream_init(&stream, operation, COMPRESSION_LZFSE) == COMPRESSION_STATUS_OK else { - throw LZFSEError.streamInit - } - defer { compression_stream_destroy(&stream) } - - stream.src_size = 0 - stream.dst_ptr = sink - stream.dst_size = chunk - var inputExhausted = false - - while true { - if stream.src_size == 0, !inputExhausted { - let read = input.read(source, maxLength: chunk) - if read < 0 { throw LZFSEError.read } - if read == 0 { inputExhausted = true } - stream.src_ptr = UnsafePointer(source) - stream.src_size = read - } - - let flags = inputExhausted ? Int32(COMPRESSION_STREAM_FINALIZE.rawValue) : 0 - let status = compression_stream_process(&stream, flags) - guard status == COMPRESSION_STATUS_OK || status == COMPRESSION_STATUS_END else { - throw LZFSEError.process - } - - let produced = chunk - stream.dst_size - if produced > 0 { - var offset = 0 - while offset < produced { - let written = output.write(sink + offset, maxLength: produced - offset) - if written <= 0 { throw LZFSEError.write } - offset += written - } - stream.dst_ptr = sink - stream.dst_size = chunk - } - - if status == COMPRESSION_STATUS_END { return } - } - } -} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/MMIO.swift b/Packages/ContainerizationEngine/Sources/DoryHV/MMIO.swift index 0e13aa3a..f45e4c6d 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/MMIO.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/MMIO.swift @@ -8,19 +8,119 @@ public protocol MMIODevice: AnyObject { /// Routes guest data aborts to the owning device by physical address. public final class MMIOBus { - private var devices: [MMIODevice] = [] + private struct Region { + let baseAddress: UInt64 + let lastAddress: UInt64 + let device: MMIODevice + + @inline(__always) + func contains(_ address: UInt64) -> Bool { + address >= baseAddress && address <= lastAddress + } + } + + /// Device attachment is a boot-time operation. `seal()` makes that topology immutable before + /// vCPU threads begin reading it concurrently. + private var regions: [Region] = [] + private var isSealed = false public init() {} public func attach(_ device: MMIODevice) { - devices.append(device) + precondition(!isSealed, "MMIO devices must be attached before the bus is sealed") + precondition(device.size > 0, "MMIO device windows must not be empty") + let (lastAddress, overflow) = device.baseAddress.addingReportingOverflow(device.size - 1) + precondition(!overflow, "MMIO device window must fit in the physical address space") + + let insertionIndex = firstRegionIndex(startingAtOrAfter: device.baseAddress) + if insertionIndex > 0 { + precondition( + regions[insertionIndex - 1].lastAddress < device.baseAddress, + "MMIO device windows must not overlap" + ) + } + if insertionIndex < regions.count { + precondition( + lastAddress < regions[insertionIndex].baseAddress, + "MMIO device windows must not overlap" + ) + } + regions.insert( + Region(baseAddress: device.baseAddress, lastAddress: lastAddress, device: device), + at: insertionIndex + ) + } + + /// Freezes the cold-path topology before concurrent vCPU execution starts. + public func seal() { + isSealed = true } + @inline(__always) public func device(for address: UInt64) -> (MMIODevice, UInt64)? { - for device in devices where address >= device.baseAddress && address < device.baseAddress + device.size { - return (device, address - device.baseAddress) + guard let region = region(containing: address) else { return nil } + return (region.device, address - region.baseAddress) + } + + /// The vCPU-local cache makes repeated accesses to one device O(1), while cache misses use a + /// binary search over the immutable region table instead of scanning every attached device. + @inline(__always) + func device( + for address: UInt64, + cache: inout MMIORouteCache + ) -> (MMIODevice, UInt64)? { + precondition(isSealed, "MMIO cached lookup requires a sealed bus") + if let device = cache.device, + address >= cache.baseAddress, + address <= cache.lastAddress { + return (device, address - cache.baseAddress) } - return nil + guard let region = region(containing: address) else { + cache.clear() + return nil + } + cache.baseAddress = region.baseAddress + cache.lastAddress = region.lastAddress + cache.device = region.device + return (region.device, address - region.baseAddress) + } + + @inline(__always) + private func region(containing address: UInt64) -> Region? { + let insertionIndex = firstRegionIndex(startingAtOrAfter: address) + if insertionIndex < regions.count, regions[insertionIndex].baseAddress == address { + return regions[insertionIndex] + } + guard insertionIndex > 0 else { return nil } + let candidate = regions[insertionIndex - 1] + return candidate.contains(address) ? candidate : nil + } + + @inline(__always) + private func firstRegionIndex(startingAtOrAfter address: UInt64) -> Int { + var lowerBound = 0 + var upperBound = regions.count + while lowerBound < upperBound { + let midpoint = lowerBound + (upperBound - lowerBound) / 2 + if regions[midpoint].baseAddress < address { + lowerBound = midpoint + 1 + } else { + upperBound = midpoint + } + } + return lowerBound + } +} + +/// One instance lives on each vCPU stack, so the common repeated-device path needs no lock or +/// shared mutable cache state. +struct MMIORouteCache { + fileprivate var baseAddress: UInt64 = 0 + fileprivate var lastAddress: UInt64 = 0 + fileprivate var device: MMIODevice? + + fileprivate mutating func clear() { + device = nil } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/MPTable.swift b/Packages/ContainerizationEngine/Sources/DoryHV/MPTable.swift index bbf293fa..e6b904ac 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/MPTable.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/MPTable.swift @@ -1,3 +1,4 @@ +import DoryFSWorkerContracts import Foundation public struct MPTableImage: Equatable, Sendable { diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift index 5f0f3ccc..72221061 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift @@ -1,6 +1,213 @@ import Darwin import Foundation import Hypervisor +import Synchronization + +/// Candidate-bound RawHV host scheduling profile. +/// +/// AppKit owns the user-interactive class. Sustained guest execution and device work are +/// user-initiated: they remain latency-sensitive while yielding the system's highest scheduling +/// class to input and presentation. Revision changes require a new runtime-envelope identity and +/// matched physical responsiveness/workload calibration before release qualification. +public enum RawHVSchedulingPolicy { + public static let revision: UInt16 = 1 + public static let vCPUThreadQualityOfService: QualityOfService = .userInitiated + public static let machineOwnerThreadQualityOfService: QualityOfService = .userInitiated + public static let machineOwnerThreadStackSize = 1 << 21 + public static let blockIOWorkerDispatchQoS: DispatchQoS = .userInitiated + public static let networkIOWorkerDispatchQoS: DispatchQoS = .userInitiated + public static let fileSystemWorkerDispatchQoS: DispatchQoS = .userInitiated + + static func applyToCurrentVCPUThread() { + applyUserInitiated(to: vCPUThreadQualityOfService) + } + + static func applyToCurrentMachineOwnerThread() { + applyUserInitiated(to: machineOwnerThreadQualityOfService) + } + + private static func applyUserInitiated(to qualityOfService: QualityOfService) { + Thread.current.qualityOfService = qualityOfService + _ = pthread_set_qos_class_self_np(QOS_CLASS_USER_INITIATED, 0) + } +} + +/// Guest-visible identity of one occupied virtio-mmio slot. +/// +/// This intentionally carries only the low-level bus identity. Product device roles belong to the +/// resolved virtual-hardware topology contract and must not be inferred from attachment order. +public struct VirtioMMIOSlotIdentity: Equatable, Sendable { + public let slot: Int + public let baseAddress: UInt64 + public let size: UInt64 + public let interrupt: UInt32 + + init(slot: Int, baseAddress: UInt64, size: UInt64, interrupt: UInt32) { + self.slot = slot + self.baseAddress = baseAddress + self.size = size + self.interrupt = interrupt + } +} + +/// Canonical low-level input for the eventual virtual-hardware ABI fingerprint. The resolved +/// topology layer will prefix device roles and capabilities; this layer contributes stable +/// slot/MMIO/IRQ identities in a fixed byte order independent of attachment order. +enum VirtioMMIOLayoutCanonicalizer { + static func fingerprintInput(for identities: [VirtioMMIOSlotIdentity]) -> [UInt8] { + let sorted = identities.sorted { lhs, rhs in + if lhs.slot != rhs.slot { return lhs.slot < rhs.slot } + if lhs.baseAddress != rhs.baseAddress { return lhs.baseAddress < rhs.baseAddress } + if lhs.interrupt != rhs.interrupt { return lhs.interrupt < rhs.interrupt } + return lhs.size < rhs.size + } + var bytes = Array("dory.virtio-mmio.layout".utf8) + bytes.append(0) + appendBigEndian(UInt32(1), to: &bytes) + appendBigEndian(UInt32(sorted.count), to: &bytes) + for identity in sorted { + appendBigEndian(UInt32(identity.slot), to: &bytes) + appendBigEndian(identity.baseAddress, to: &bytes) + appendBigEndian(identity.size, to: &bytes) + appendBigEndian(identity.interrupt, to: &bytes) + } + return bytes + } + + private static func appendBigEndian(_ value: T, to bytes: inout [UInt8]) { + withUnsafeBytes(of: value.bigEndian) { bytes.append(contentsOf: $0) } + } +} + +/// Owns the one-to-one relationship between stable virtio slots and attached MMIO devices. +/// Configuration is serialized even though normal callers attach before vCPU startup, so duplicate +/// concurrent requests cannot leak a second device into `MMIOBus`. +final class VirtioMMIOSlotOwnership { + private struct Attachment { + let device: MMIODevice + let identity: VirtioMMIOSlotIdentity + } + + private struct State { + var attachmentsBySlot: [Int: Attachment] = [:] + var attachedDeviceIdentities: Set = [] + } + + private let lock = NSLock() + private var state = State() + private let maximumSlots: Int + private let baseAddress: UInt64 + private let slotSize: UInt64 + private let firstInterrupt: UInt32 + + init(maximumSlots: Int, baseAddress: UInt64, slotSize: UInt64, firstInterrupt: UInt32) { + precondition(maximumSlots > 0) + precondition(slotSize > 0) + self.maximumSlots = maximumSlots + self.baseAddress = baseAddress + self.slotSize = slotSize + self.firstInterrupt = firstInterrupt + } + + var identities: [VirtioMMIOSlotIdentity] { + lock.lock() + defer { lock.unlock() } + return state.attachmentsBySlot.values.map(\.identity).sorted { $0.slot < $1.slot } + } + + var fingerprintInput: [UInt8] { + VirtioMMIOLayoutCanonicalizer.fingerprintInput(for: identities) + } + + @discardableResult + func attach( + _ device: MMIODevice, + at slot: Int, + attachToBus: (MMIODevice) -> Void + ) throws -> VirtioMMIOSlotIdentity { + let slotIdentity = try identity(for: slot) + lock.lock() + defer { lock.unlock() } + return try attachLocked( + device, + identity: slotIdentity, + attachToBus: attachToBus + ) + } + + private func attachLocked( + _ device: MMIODevice, + identity slotIdentity: VirtioMMIOSlotIdentity, + attachToBus: (MMIODevice) -> Void + ) throws -> VirtioMMIOSlotIdentity { + let slot = slotIdentity.slot + guard device.baseAddress == slotIdentity.baseAddress else { + throw VMError.invalidConfiguration( + "virtio slot \(slot) requires MMIO base 0x\(String(slotIdentity.baseAddress, radix: 16)), got 0x\(String(device.baseAddress, radix: 16))" + ) + } + guard device.size == slotIdentity.size else { + throw VMError.invalidConfiguration( + "virtio slot \(slot) requires MMIO size 0x\(String(slotIdentity.size, radix: 16)), got 0x\(String(device.size, radix: 16))" + ) + } + guard state.attachmentsBySlot[slot] == nil else { + throw VMError.invalidConfiguration("virtio slot \(slot) is already occupied") + } + let deviceIdentity = ObjectIdentifier(device) + guard !state.attachedDeviceIdentities.contains(deviceIdentity) else { + throw VMError.invalidConfiguration("virtio MMIO device is already attached") + } + attachToBus(device) + state.attachmentsBySlot[slot] = Attachment(device: device, identity: slotIdentity) + state.attachedDeviceIdentities.insert(deviceIdentity) + return slotIdentity + } + + private func identity(for slot: Int) throws -> VirtioMMIOSlotIdentity { + guard slot >= 0, slot < maximumSlots else { + throw VMError.invalidConfiguration( + "virtio slot \(slot) is outside 0..<\(maximumSlots)" + ) + } + guard let unsignedSlot = UInt64(exactly: slot) else { + throw VMError.invalidConfiguration("virtio slot \(slot) cannot be represented") + } + let (offset, offsetOverflow) = unsignedSlot.multipliedReportingOverflow(by: slotSize) + let (address, addressOverflow) = baseAddress.addingReportingOverflow(offset) + guard !offsetOverflow, !addressOverflow else { + throw VMError.invalidConfiguration("virtio slot \(slot) MMIO address overflows") + } + guard let interruptOffset = UInt32(exactly: slot) else { + throw VMError.invalidConfiguration("virtio slot \(slot) interrupt cannot be represented") + } + let (interrupt, interruptOverflow) = firstInterrupt.addingReportingOverflow(interruptOffset) + guard !interruptOverflow else { + throw VMError.invalidConfiguration("virtio slot \(slot) interrupt overflows") + } + return VirtioMMIOSlotIdentity( + slot: slot, + baseAddress: address, + size: slotSize, + interrupt: interrupt + ) + } +} + +enum VirtioMMIODeviceTree { + static func appendNodes( + for identities: [VirtioMMIOSlotIdentity], + to fdt: FDTBuilder + ) { + for identity in identities.sorted(by: { $0.slot < $1.slot }) { + fdt.beginNode("virtio_mmio@\(String(identity.baseAddress, radix: 16))") + fdt.property("compatible", string: "virtio,mmio") + fdt.property("reg", cells64: [identity.baseAddress, identity.size]) + fdt.property("interrupts", cells: [0, identity.interrupt, 1]) + fdt.endNode() + } + } +} #if arch(arm64) /// Guest physical layout, modeled on QEMU's virt machine so every address is one Linux has been @@ -15,20 +222,45 @@ public enum GuestLayout { public static let rtcBase: UInt64 = 0x0C09_0000 public static let virtioBase: UInt64 = 0x0C10_0000 public static let virtioSlotSize: UInt64 = 0x200 + /// QEMU's arm64 `virt` platform reserves 32 virtio-mmio transports. Dory preserves that + /// bounded window while allowing holes within it. + public static let virtioSlotCount = 32 public static let virtioFirstIRQ: UInt32 = 16 // SPI numbers 16... (intid 48...) public static let ramBase: UInt64 = 0x8000_0000 public static let dtbOffset: UInt64 = 256 << 20 + /// Direct-boot initrds live beyond the kernel/DTB reservation while remaining well inside + /// the minimum supported 1-GiB guest. Keeping this deterministic also makes the DTB contract + /// straightforward to test and diagnose. + public static let initrdOffset: UInt64 = 320 << 20 public static let daxWindowBase: UInt64 = 0xC_0000_0000 } public struct MachineConfiguration { - public var kernelPath: String + public let bootPayload: MachineBootPayload public var commandLine: String public var memoryBytes: UInt64 public var cpuCount: Int - public init(kernelPath: String, commandLine: String, memoryBytes: UInt64, cpuCount: Int) { - self.kernelPath = kernelPath + public init( + kernelPath: String, + initrdPath: String? = nil, + commandLine: String, + memoryBytes: UInt64, + cpuCount: Int + ) { + self.bootPayload = .legacyPaths(kernel: kernelPath, initrd: initrdPath) + self.commandLine = commandLine + self.memoryBytes = memoryBytes + self.cpuCount = cpuCount + } + + public init( + bootPayload: MachineBootPayload, + commandLine: String, + memoryBytes: UInt64, + cpuCount: Int + ) { + self.bootPayload = bootPayload self.commandLine = commandLine self.memoryBytes = memoryBytes self.cpuCount = cpuCount @@ -41,6 +273,24 @@ public enum GuestStopReason: Sendable { case crash(String) } +/// One-way, lock-free publication from stop ownership into each vCPU's exit loop. +/// +/// `Machine` remains single-run: the condition-protected stop reason, vCPU handles, wakeups, and +/// joins own the lifecycle. This signal only removes that global condition from the common path +/// after a vCPU exit. A releasing request paired with an acquiring read also makes state published +/// before the stop request visible before a vCPU leaves its loop. +final class VCPUStopSignal: Sendable { + private let requested = Atomic(false) + + var isRequested: Bool { + requested.load(ordering: .acquiring) + } + + func request() { + requested.store(true, ordering: .releasing) + } +} + /// The virtual machine: RAM, GIC, devices, and the vCPU threads. SMP: secondaries are created /// eagerly, parked, and released by PSCI CPU_ON. Thread-shared state is guarded by /// `teamCondition`; devices serialize their own guest-facing surfaces. @@ -52,6 +302,12 @@ public final class Machine: @unchecked Sendable { private var dtbAddress: UInt64 = 0 private var sysregLogCount = 0 private let redistributorMMIO: GICRedistributorMMIO + private let virtioSlotOwnership = VirtioMMIOSlotOwnership( + maximumSlots: GuestLayout.virtioSlotCount, + baseAddress: GuestLayout.virtioBase, + slotSize: GuestLayout.virtioSlotSize, + firstInterrupt: GuestLayout.virtioFirstIRQ + ) public init(configuration: MachineConfiguration) throws { try hvCreateVM() @@ -105,8 +361,14 @@ public final class Machine: @unchecked Sendable { /// Pulses a guest system interrupt. On arm64 these are GIC SPIs declared edge-triggered in the DTB. public func raiseGSI(_ gsi: UInt32) { + setGSI(gsi, asserted: true) + } + + /// Drives a level-sensitive guest system interrupt. UART input uses this to keep the PL011 + /// receive line asserted until the guest has drained the pending bytes. + public func setGSI(_ gsi: UInt32, asserted: Bool) { let intid = 32 + gsi - _ = hv_gic_set_spi(intid, true) + _ = hv_gic_set_spi(intid, asserted) } /// Compatibility spelling for arm64 callers; new shared engine code should use `raiseGSI`. @@ -119,17 +381,39 @@ public final class Machine: @unchecked Sendable { } public func loadBootPayload() throws { - let kernel = try KernelImage(contentsOf: configuration.kernelPath) - entryPoint = try kernel.load(into: memory) - dtbAddress = GuestLayout.ramBase + GuestLayout.dtbOffset - guard kernel.textOffset + kernel.imageSize < GuestLayout.dtbOffset else { - throw VMError.bootFailure("kernel image overlaps DTB placement") + try configuration.bootPayload.consumeForGuestLoad { kernelData, loadInitrd in + let kernel = try KernelImage(data: kernelData) + entryPoint = try kernel.load(into: memory) + dtbAddress = GuestLayout.ramBase + GuestLayout.dtbOffset + let (kernelEndOffset, kernelEndOverflowed) = + kernel.textOffset.addingReportingOverflow(kernel.imageSize) + guard !kernelEndOverflowed, + kernelEndOffset < GuestLayout.dtbOffset else { + throw VMError.bootFailure("kernel image overlaps DTB placement") + } + let initrdRange = try loadInitrdIfPresent(try loadInitrd()) + let dtb = try buildDeviceTree(initrdRange: initrdRange) + try memory.write(dtb, at: dtbAddress) + } + } + + private func loadInitrdIfPresent(_ data: Data?) throws -> Range? { + guard let data else { return nil } + guard !data.isEmpty else { + throw VMError.bootFailure("initrd is empty") + } + let start = GuestLayout.ramBase + GuestLayout.initrdOffset + let (end, overflowed) = start.addingReportingOverflow(UInt64(data.count)) + guard !overflowed, + end > start, + end <= GuestLayout.ramBase + configuration.memoryBytes else { + throw VMError.bootFailure("initrd does not fit in guest memory") } - let dtb = try buildDeviceTree() - try memory.write(dtb, at: dtbAddress) + try copyBootData(data, at: start) + return start.. [UInt8] { + private func buildDeviceTree(initrdRange: Range?) throws -> [UInt8] { let gicPhandle: UInt32 = 1 let clockPhandle: UInt32 = 2 let virtualTimer = try Self.reservedIntid(HV_GIC_INT_EL1_VIRTUAL_TIMER) @@ -148,6 +432,10 @@ public final class Machine: @unchecked Sendable { fdt.beginNode("chosen") fdt.property("bootargs", string: configuration.commandLine) fdt.property("stdout-path", string: "/pl011@\(String(GuestLayout.uartBase, radix: 16))") + if let initrdRange { + fdt.property("linux,initrd-start", cells64: [initrdRange.lowerBound]) + fdt.property("linux,initrd-end", cells64: [initrdRange.upperBound]) + } fdt.endNode() fdt.beginNode("memory@\(String(GuestLayout.ramBase, radix: 16))") @@ -224,27 +512,12 @@ public final class Machine: @unchecked Sendable { fdt.property("clock-names", strings: ["apb_pclk"]) fdt.endNode() - for (slot, device) in virtioSlots.enumerated() { - let base = GuestLayout.virtioBase + UInt64(slot) * GuestLayout.virtioSlotSize - fdt.beginNode("virtio_mmio@\(String(base, radix: 16))") - fdt.property("compatible", string: "virtio,mmio") - fdt.property("reg", cells64: [base, GuestLayout.virtioSlotSize]) - fdt.property("interrupts", cells: [0, GuestLayout.virtioFirstIRQ + UInt32(slot), 1]) - fdt.endNode() - _ = device - } + VirtioMMIODeviceTree.appendNodes(for: attachedVirtioSlots, to: fdt) fdt.endNode() return fdt.finish() } - private var virtioSlots: [MMIODevice] = [] - - public func attachVirtioSlot(_ device: MMIODevice) { - virtioSlots.append(device) - bus.attach(device) - } - public func attachConsole(_ uart: PL011) { bus.attach(uart) } @@ -256,6 +529,7 @@ public final class Machine: @unchecked Sendable { private var secondaryStarts: [(entry: UInt64, context: UInt64)?] = [] private var cpuStarted: [Bool] = [] private var stopReason: GuestStopReason? + private let stopSignal = VCPUStopSignal() private var registeredCPUs = 0 private var finishedSecondaries = 0 private var vcpusExited = false @@ -265,6 +539,9 @@ public final class Machine: @unchecked Sendable { /// up front so the kernel's redistributor walk sees all GIC frames, then parked until PSCI /// CPU_ON. The calling thread becomes the boot CPU. Returns when the guest stops. public func run() throws -> GuestStopReason { + // Attachment is a cold boot operation. Freeze the sorted routing table before any vCPU + // can read it concurrently, and give each vCPU its own hot lookup cache in `runLoop`. + bus.seal() let count = max(1, configuration.cpuCount) teamHandles = Array(repeating: nil, count: count) secondaryStarts = Array(repeating: nil, count: count) @@ -274,7 +551,7 @@ public final class Machine: @unchecked Sendable { for index in 1.. hv_vm_destroy) never races a live vCPU thread. - stopAll(stopReason ?? .powerOff) + let terminalReason = stopReason + ?? .crash("boot CPU exited without a published stop reason") + stopAll(terminalReason) teamCondition.lock() while finishedSecondaries < count - 1 { teamCondition.wait() } defer { teamCondition.unlock() } - return stopReason ?? .crash("boot CPU exited without a stop reason") + return stopReason ?? terminalReason } private func cpuMain(index: Int) { - Self.applyVCPUQoS() + RawHVSchedulingPolicy.applyToCurrentVCPUThread() defer { if index != 0 { teamCondition.lock() @@ -331,7 +610,7 @@ public final class Machine: @unchecked Sendable { try vcpu.write(HV_REG_X0, start.context) } - runLoop(vcpu: vcpu) + runLoop(vcpu: vcpu, index: index) } catch { stopAll(.crash("cpu\(index) failed: \(error)")) } @@ -365,7 +644,9 @@ public final class Machine: @unchecked Sendable { private func stopAll(_ reason: GuestStopReason) { teamCondition.lock() - if stopReason == nil { stopReason = reason } + let publishesReason = stopReason == nil + if publishesReason { stopReason = reason } + stopSignal.request() // Cancel running vCPUs exactly once: a second pass could touch a handle a finished thread // has already destroyed. var handles: [hv_vcpu_t] = [] @@ -375,6 +656,11 @@ public final class Machine: @unchecked Sendable { } teamCondition.broadcast() teamCondition.unlock() + if publishesReason { + FileHandle.standardError.write( + Data("dory-hv: guest stop reason: \(reason)\n".utf8) + ) + } if !handles.isEmpty { hv_vcpus_exit(&handles, UInt32(handles.count)) } @@ -392,17 +678,20 @@ public final class Machine: @unchecked Sendable { return 0 } - private func runLoop(vcpu: VCPU) { + private func runLoop(vcpu: VCPU, index: Int) { + var mmioRouteCache = MMIORouteCache() while true { - teamCondition.lock() - let stopped = stopReason != nil - teamCondition.unlock() - if stopped { return } + if stopSignal.isRequested { return } do { let event = try vcpu.run() switch event { case .canceled: + if !stopSignal.isRequested { + stopAll(.crash( + "cpu\(index) Hypervisor run was canceled without a stop request" + )) + } return case .vtimerActivated: // With the in-kernel GIC the timer PPI is delivered by the GIC itself; unmask @@ -410,7 +699,10 @@ public final class Machine: @unchecked Sendable { try vcpu.setVTimerMask(false) case .exception(let syndrome, _, let physicalAddress): if let stop = try handleException( - vcpu: vcpu, syndrome: syndrome, physicalAddress: physicalAddress + vcpu: vcpu, + syndrome: syndrome, + physicalAddress: physicalAddress, + mmioRouteCache: &mmioRouteCache ) { stopAll(stop) return @@ -426,19 +718,24 @@ public final class Machine: @unchecked Sendable { } } - private static func applyVCPUQoS() { - Thread.current.qualityOfService = .userInteractive - _ = pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0) - } - - private func handleException(vcpu: VCPU, syndrome: UInt64, physicalAddress: UInt64) throws -> GuestStopReason? { + private func handleException( + vcpu: VCPU, + syndrome: UInt64, + physicalAddress: UInt64, + mmioRouteCache: inout MMIORouteCache + ) throws -> GuestStopReason? { guard let exceptionClass = ExceptionClass(syndrome: syndrome) else { let pc = try vcpu.read(HV_REG_PC) return .crash("unhandled exception class \(syndrome >> 26), syndrome 0x\(String(syndrome, radix: 16)), pc 0x\(String(pc, radix: 16))") } switch exceptionClass { case .dataAbortLowerEL: - try handleMMIO(vcpu: vcpu, syndrome: syndrome, physicalAddress: physicalAddress) + try handleMMIO( + vcpu: vcpu, + syndrome: syndrome, + physicalAddress: physicalAddress, + routeCache: &mmioRouteCache + ) return nil case .instructionAbortLowerEL: guard restoreIfReleasedRAM(physicalAddress) else { @@ -461,14 +758,19 @@ public final class Machine: @unchecked Sendable { } } - private func handleMMIO(vcpu: VCPU, syndrome: UInt64, physicalAddress: UInt64) throws { + private func handleMMIO( + vcpu: VCPU, + syndrome: UInt64, + physicalAddress: UInt64, + routeCache: inout MMIORouteCache + ) throws { if restoreIfReleasedRAM(physicalAddress) { return } let abort = DataAbortInfo(syndrome: syndrome) guard abort.isValid else { let pc = try vcpu.read(HV_REG_PC) throw VMError.unexpectedExit("data abort without syndrome info at pa 0x\(String(physicalAddress, radix: 16)), pc 0x\(String(pc, radix: 16))") } - guard let (device, offset) = bus.device(for: physicalAddress) else { + guard let (device, offset) = bus.device(for: physicalAddress, cache: &routeCache) else { let pc = try vcpu.read(HV_REG_PC) throw VMError.unexpectedExit("guest touched unmapped pa 0x\(String(physicalAddress, radix: 16)), pc 0x\(String(pc, radix: 16))") } @@ -589,19 +891,38 @@ public enum GuestLayout { public static let rtcBase = X86GuestLayout.rtcBase public static let virtioBase = X86GuestLayout.virtioBase public static let virtioSlotSize = X86GuestLayout.virtioSlotSize + public static let virtioSlotCount = X86GuestLayout.virtioSlotCount public static let virtioFirstIRQ = UInt32(X86GuestLayout.virtioFirstIRQ) public static let ramBase = X86GuestLayout.ramBase public static let daxWindowBase = X86GuestLayout.daxWindowBase } public struct MachineConfiguration { - public var kernelPath: String + public let bootPayload: MachineBootPayload public var commandLine: String public var memoryBytes: UInt64 public var cpuCount: Int - public init(kernelPath: String, commandLine: String, memoryBytes: UInt64, cpuCount: Int) { - self.kernelPath = kernelPath + public init( + kernelPath: String, + initrdPath: String? = nil, + commandLine: String, + memoryBytes: UInt64, + cpuCount: Int + ) { + self.bootPayload = .legacyPaths(kernel: kernelPath, initrd: initrdPath) + self.commandLine = commandLine + self.memoryBytes = memoryBytes + self.cpuCount = cpuCount + } + + public init( + bootPayload: MachineBootPayload, + commandLine: String, + memoryBytes: UInt64, + cpuCount: Int + ) { + self.bootPayload = bootPayload self.commandLine = commandLine self.memoryBytes = memoryBytes self.cpuCount = cpuCount @@ -623,6 +944,12 @@ public final class Machine: @unchecked Sendable { public private(set) var startInfoAddress: UInt64 = 0 private let stopLock = NSLock() private var stopReason: GuestStopReason? + private let virtioSlotOwnership = VirtioMMIOSlotOwnership( + maximumSlots: GuestLayout.virtioSlotCount, + baseAddress: GuestLayout.virtioBase, + slotSize: GuestLayout.virtioSlotSize, + firstInterrupt: GuestLayout.virtioFirstIRQ + ) public init(configuration: MachineConfiguration) throws { try hvCreateVM() @@ -644,39 +971,67 @@ public final class Machine: @unchecked Sendable { } public func loadBootPayload() throws { - let kernel = try PVHKernelImage(contentsOf: configuration.kernelPath) - entryPoint = try kernel.load(into: memory) - startInfoAddress = X86GuestLayout.pvhStartInfo - - let plan = X86BootPlanBuilder.build( - baseCommandLine: configuration.commandLine, - memoryBytes: configuration.memoryBytes, - virtioDeviceCount: busDeviceCount - ) - let pvh = PVHBootBuilder.build( - commandLine: plan.commandLine, - commandLinePhysicalAddress: X86GuestLayout.pvhCommandLine, - modulesPhysicalAddress: X86GuestLayout.pvhModules, - memoryMapPhysicalAddress: X86GuestLayout.pvhMemoryMap, - modules: [], - memoryMap: plan.memoryMap - ) - try memory.write(Array(pvh.startInfo), at: X86GuestLayout.pvhStartInfo) - try memory.write(Array(pvh.commandLine), at: X86GuestLayout.pvhCommandLine) - try memory.write(Array(pvh.memoryMap), at: X86GuestLayout.pvhMemoryMap) - - let mpTable = MPTableBuilder.build( - tablePhysicalAddress: UInt32(X86GuestLayout.mpConfigurationTable), - cpuCount: configuration.cpuCount, - virtioInterruptPins: plan.virtioDevices.map(\.irq) - ) - try memory.write(Array(mpTable.floatingPointer), at: X86GuestLayout.mpFloatingPointer) - try memory.write(Array(mpTable.configurationTable), at: X86GuestLayout.mpConfigurationTable) - } + try configuration.bootPayload.consumeForGuestLoad { kernelData, loadInitrd in + let kernel = try PVHKernelImage(data: kernelData) + entryPoint = try kernel.load(into: memory) + startInfoAddress = X86GuestLayout.pvhStartInfo + + let initrdData = try loadInitrd() + if let initrdData, initrdData.isEmpty { + throw VMError.bootFailure("initrd is empty") + } + let initrdAddress = X86GuestLayout.initrd + if let initrdData { + let (end, overflowed) = initrdAddress.addingReportingOverflow(UInt64(initrdData.count)) + guard !overflowed, end <= configuration.memoryBytes else { + throw VMError.bootFailure("initrd does not fit in guest memory") + } + try copyBootData(initrdData, at: initrdAddress) + } + + let virtioDevices = try attachedVirtioSlots.map { identity -> X86VirtioMMIODevice in + guard let interrupt = UInt8(exactly: identity.interrupt) else { + throw VMError.invalidConfiguration( + "virtio slot \(identity.slot) interrupt \(identity.interrupt) exceeds x86 IOAPIC encoding" + ) + } + return X86VirtioMMIODevice( + slot: identity.slot, + baseAddress: identity.baseAddress, + size: identity.size, + irq: interrupt + ) + } + let plan = X86BootPlanBuilder.build( + baseCommandLine: configuration.commandLine, + memoryBytes: configuration.memoryBytes, + virtioDevices: virtioDevices + ) + let pvh = PVHBootBuilder.build( + commandLine: plan.commandLine, + commandLinePhysicalAddress: X86GuestLayout.pvhCommandLine, + modulesPhysicalAddress: X86GuestLayout.pvhModules, + memoryMapPhysicalAddress: X86GuestLayout.pvhMemoryMap, + modules: initrdData.map { + [PVHModule(physicalAddress: initrdAddress, size: UInt64($0.count))] + } ?? [], + memoryMap: plan.memoryMap + ) + try memory.write(Array(pvh.startInfo), at: X86GuestLayout.pvhStartInfo) + try memory.write(Array(pvh.commandLine), at: X86GuestLayout.pvhCommandLine) + if !pvh.modules.isEmpty { + try memory.write(Array(pvh.modules), at: X86GuestLayout.pvhModules) + } + try memory.write(Array(pvh.memoryMap), at: X86GuestLayout.pvhMemoryMap) - public func attachVirtioSlot(_ device: MMIODevice) { - busDeviceCount += 1 - bus.attach(device) + let mpTable = MPTableBuilder.build( + tablePhysicalAddress: UInt32(X86GuestLayout.mpConfigurationTable), + cpuCount: configuration.cpuCount, + virtioInterruptPins: plan.virtioDevices.map(\.irq) + ) + try memory.write(Array(mpTable.floatingPointer), at: X86GuestLayout.mpFloatingPointer) + try memory.write(Array(mpTable.configurationTable), at: X86GuestLayout.mpConfigurationTable) + } } public func attachConsole(_ uart: UART16550) { @@ -711,6 +1066,7 @@ public final class Machine: @unchecked Sendable { if entryPoint == 0 || startInfoAddress == 0 { try loadBootPayload() } + bus.seal() let vcpu = try VCPU() try vcpu.configurePVHEntry(entryPoint: entryPoint, startInfoAddress: startInfoAddress) var executor = X86VMExitExecutor() @@ -844,7 +1200,51 @@ public final class Machine: @unchecked Sendable { ) } } - - private var busDeviceCount = 0 } #endif + +extension GuestStopReason: CustomStringConvertible { + public var description: String { + switch self { + case .powerOff: + "guest requested power off" + case .reset: + "guest requested reset" + case .crash(let detail): + "guest crash: \(detail)" + } + } +} + +private extension Machine { + /// Copies immutable boot bytes directly into guest RAM without materializing a second + /// full-sized `[UInt8]` buffer. + func copyBootData(_ data: Data, at guestAddress: UInt64) throws { + guard !data.isEmpty else { return } + let destination = try memory.hostPointer( + at: guestAddress, + count: UInt64(data.count) + ) + data.withUnsafeBytes { source in + destination.copyMemory(from: source.baseAddress!, byteCount: source.count) + } + } +} + +public extension Machine { + var attachedVirtioSlots: [VirtioMMIOSlotIdentity] { + virtioSlotOwnership.identities + } + + var virtioMMIOLayoutFingerprintInput: [UInt8] { + virtioSlotOwnership.fingerprintInput + } + + @discardableResult + func attachVirtioSlot( + _ device: MMIODevice, + at slot: Int + ) throws -> VirtioMMIOSlotIdentity { + try virtioSlotOwnership.attach(device, at: slot) { bus.attach($0) } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/MachineBootPayload.swift b/Packages/ContainerizationEngine/Sources/DoryHV/MachineBootPayload.swift new file mode 100644 index 00000000..64dd4860 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/MachineBootPayload.swift @@ -0,0 +1,256 @@ +import CryptoKit +import Darwin +import Foundation + +/// Exact authority for one anonymous, read-only boot blob inherited from the daemon. +/// +/// `maximumByteCount` is a local allocation ceiling, not caller-controlled evidence. The child +/// validates the descriptor before allocating and uses `pread` so supervised restarts never share +/// or depend on an open-file-description offset. +public struct MachineInheritedBootBlob: Sendable, Equatable { + public let descriptor: Int32 + public let byteCount: UInt64 + public let sha256: String + public let maximumByteCount: UInt64 + + public init( + descriptor: Int32, + byteCount: UInt64, + sha256: String, + maximumByteCount: UInt64 + ) { + self.descriptor = descriptor + self.byteCount = byteCount + self.sha256 = sha256 + self.maximumByteCount = maximumByteCount + } +} + +/// Single-use ownership of resolved boot bytes. +/// +/// Every `MachineBootPayload` copy shares this authority. Consumption removes the authority's +/// references before invoking the guest-memory loader and permanently retires the authority when +/// that loader returns or throws. Consequently a retained `MachineConfiguration` cannot keep a +/// second kernel/initrd copy alive or replay partially loaded resolved authority. +public final class MachineImmutableBootAuthority: @unchecked Sendable, Equatable { + private struct Bytes { + let kernel: Data + let initrd: Data? + } + + private enum State { + case available(Bytes) + case consuming + case consumed + } + + private let lock = NSLock() + private var state: State + + fileprivate init(kernel: Data, initrd: Data?) { + self.state = .available(Bytes(kernel: kernel, initrd: initrd)) + } + + public static func == ( + lhs: MachineImmutableBootAuthority, + rhs: MachineImmutableBootAuthority + ) -> Bool { + lhs === rhs + } + + fileprivate func consumeForGuestLoad( + _ load: (Data, () throws -> Data?) throws -> Void + ) throws { + let bytes: Bytes + lock.lock() + switch state { + case .available(let available): + bytes = available + state = .consuming + lock.unlock() + case .consuming: + lock.unlock() + throw VMError.invalidConfiguration( + "resolved immutable boot payload is already being consumed" + ) + case .consumed: + lock.unlock() + throw VMError.invalidConfiguration( + "resolved immutable boot payload has already been consumed" + ) + } + + defer { + lock.lock() + state = .consumed + lock.unlock() + } + try load(bytes.kernel) { bytes.initrd } + } + + /// Test-visible accounting of bytes retained by the authority itself. The count becomes zero + /// before guest loading begins and remains zero after either success or failure. + var retainedByteCount: UInt64 { + lock.lock() + defer { lock.unlock() } + guard case .available(let bytes) = state else { return 0 } + return UInt64(bytes.kernel.count) + UInt64(bytes.initrd?.count ?? 0) + } + + var isConsumed: Bool { + lock.lock() + defer { lock.unlock() } + guard case .consumed = state else { return false } + return true + } +} + +/// A typed split between repeatable legacy pathname boot and one-shot resolved immutable-byte boot. +public enum MachineBootPayload: Sendable, Equatable { + case legacyPaths(kernel: String, initrd: String?) + case immutableBytes(authority: MachineImmutableBootAuthority) + + /// Source-compatible construction spelling for callers with already-verified bytes. Copies of + /// the returned enum share one consumable authority rather than retaining independent `Data`. + public static func immutableBytes(kernel: Data, initrd: Data?) -> Self { + .immutableBytes( + authority: MachineImmutableBootAuthority(kernel: kernel, initrd: initrd) + ) + } + + public static func inheritedReadOnlyDescriptors( + kernel: MachineInheritedBootBlob, + initrd: MachineInheritedBootBlob? + ) throws -> Self { + var descriptors = [kernel.descriptor] + if let initrd { descriptors.append(initrd.descriptor) } + let ownedDescriptors = Set(descriptors.filter { $0 >= 3 }) + defer { ownedDescriptors.forEach { Darwin.close($0) } } + guard descriptors.allSatisfy({ $0 >= 3 }), + Set(descriptors).count == descriptors.count else { + throw VMError.invalidConfiguration( + "resolved boot descriptors must be unique inherited descriptors" + ) + } + let kernelData = try readExactAnonymousBlob(kernel, kind: "linuxKernel") + let initrdData = try initrd.map { + try readExactAnonymousBlob($0, kind: "linuxInitrd") + } + return .immutableBytes(kernel: kernelData, initrd: initrdData) + } + + func consumeForGuestLoad( + _ load: (Data, () throws -> Data?) throws -> Void + ) throws { + switch self { + case .legacyPaths(let kernelPath, let initrdPath): + let kernel = try Data(contentsOf: URL(fileURLWithPath: kernelPath)) + try load(kernel) { + try Self.readLegacyInitrd(at: initrdPath) + } + case .immutableBytes(let authority): + try authority.consumeForGuestLoad(load) + } + } + + var retainedImmutableByteCount: UInt64 { + guard case .immutableBytes(let authority) = self else { return 0 } + return authority.retainedByteCount + } + + var immutableBytesWereConsumed: Bool { + guard case .immutableBytes(let authority) = self else { return false } + return authority.isConsumed + } + + private static func readLegacyInitrd(at path: String?) throws -> Data? { + guard let path else { return nil } + let data = try Data( + contentsOf: URL(fileURLWithPath: path), + options: .mappedIfSafe + ) + guard !data.isEmpty else { + throw VMError.bootFailure("initrd is empty: \(path)") + } + return data + } + + private static func readExactAnonymousBlob( + _ authority: MachineInheritedBootBlob, + kind: String + ) throws -> Data { + guard authority.byteCount > 0, + authority.byteCount <= authority.maximumByteCount, + let allocationCount = Int(exactly: authority.byteCount), + isLowercaseSHA256(authority.sha256) else { + throw VMError.invalidConfiguration( + "resolved \(kind) metadata is outside its allocation or digest bounds" + ) + } + let accessFlags = fcntl(authority.descriptor, F_GETFL) + var before = stat() + guard accessFlags >= 0, + accessFlags & O_ACCMODE == O_RDONLY, + fstat(authority.descriptor, &before) == 0, + before.st_mode & S_IFMT == S_IFREG, + before.st_uid == geteuid(), + before.st_nlink == 0, + before.st_size > 0, + UInt64(before.st_size) == authority.byteCount, + before.st_mode & 0o077 == 0 else { + throw VMError.invalidConfiguration( + "resolved \(kind) is not the exact private anonymous read-only blob" + ) + } + + var data = Data(count: allocationCount) + try data.withUnsafeMutableBytes { raw in + guard let base = raw.baseAddress else { + throw VMError.bootFailure("resolved \(kind) allocation is empty") + } + var offset = 0 + while offset < raw.count { + let result = pread( + authority.descriptor, + base.advanced(by: offset), + raw.count - offset, + off_t(offset) + ) + if result > 0 { + offset += result + } else if result < 0, errno == EINTR { + continue + } else { + throw VMError.bootFailure( + "resolved \(kind) changed or ended during exact descriptor read" + ) + } + } + } + + var after = stat() + guard fstat(authority.descriptor, &after) == 0, + before.st_dev == after.st_dev, + before.st_ino == after.st_ino, + before.st_size == after.st_size, + before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, + before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec, + before.st_ctimespec.tv_sec == after.st_ctimespec.tv_sec, + before.st_ctimespec.tv_nsec == after.st_ctimespec.tv_nsec else { + throw VMError.bootFailure( + "resolved \(kind) identity changed during descriptor read" + ) + } + let actual = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + guard actual == authority.sha256 else { + throw VMError.bootFailure("resolved \(kind) failed exact SHA-256 validation") + } + return data + } + + private static func isLowercaseSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { + (48...57).contains($0) || (97...102).contains($0) + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/PL011.swift b/Packages/ContainerizationEngine/Sources/DoryHV/PL011.swift index 45939834..a6cc4d17 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/PL011.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/PL011.swift @@ -1,7 +1,11 @@ import Foundation -/// ARM PrimeCell PL011 UART, transmit-only. Console output lands on the supplied sink; the guest -/// sees an always-empty receive FIFO and an always-ready transmit FIFO. +/// ARM PrimeCell PL011 UART with a bounded host-to-guest receive queue. +/// +/// Console output lands on the supplied sink. Host input is delivered through ``receive(_:)`` and +/// raises the level-high UART interrupt while both data and a guest receive-interrupt mask are +/// present. Raw-HV uses this path for the same private recovery-console contract as the VZ backend, +/// so a guest remains diagnosable even when Dory Tools are missing or broken. public final class PL011: MMIODevice { public let baseAddress: UInt64 public let size: UInt64 = 0x1000 @@ -12,27 +16,58 @@ public final class PL011: MMIODevice { private var fractionalBaud: UInt64 = 0 private var interruptMask: UInt64 = 0 private var fifoLevel: UInt64 = 0x12 + private var receiveBytes = [UInt8]() + private var receiveOffset = 0 + private var interruptAsserted = false + private let lock = NSLock() private let sink: (UInt8) -> Void + private let setInterrupt: (Bool) -> Void + + private static let receiveInterrupt: UInt64 = 1 << 4 + private static let receiveTimeoutInterrupt: UInt64 = 1 << 6 + private static let maximumBufferedReceiveBytes = 64 * 1_024 private static let peripheralID: [UInt64] = [0x11, 0x10, 0x14, 0x00] private static let cellID: [UInt64] = [0x0D, 0xF0, 0x05, 0xB1] - public init(baseAddress: UInt64, sink: @escaping (UInt8) -> Void) { + public init( + baseAddress: UInt64, + sink: @escaping (UInt8) -> Void, + setInterrupt: @escaping (Bool) -> Void = { _ in } + ) { self.baseAddress = baseAddress self.sink = sink + self.setInterrupt = setInterrupt } public func read(offset: UInt64, width: Int) -> UInt64 { + lock.lock() + defer { lock.unlock() } switch offset { - case 0x00: return 0 - case 0x18: return 0x90 // FR: TXFE | RXFE + case 0x00: + guard receiveOffset < receiveBytes.count else { return 0 } + let byte = receiveBytes[receiveOffset] + receiveOffset += 1 + if receiveOffset == receiveBytes.count { + receiveBytes.removeAll(keepingCapacity: true) + receiveOffset = 0 + } else if receiveOffset >= 4_096 { + receiveBytes.removeFirst(receiveOffset) + receiveOffset = 0 + } + refreshInterruptLocked() + return UInt64(byte) + case 0x18: + // FR: TXFE is always set; RXFE is set only when host input has drained. + return receiveOffset < receiveBytes.count ? 0x80 : 0x90 case 0x24: return integerBaud case 0x28: return fractionalBaud case 0x2C: return lineControl case 0x30: return control case 0x34: return fifoLevel case 0x38: return interruptMask - case 0x3C, 0x40: return 0 // RIS, MIS + case 0x3C: return rawInterruptStatusLocked + case 0x40: return rawInterruptStatusLocked & interruptMask case 0xFE0...0xFEC: return Self.peripheralID[Int((offset - 0xFE0) / 4)] case 0xFF0...0xFFC: return Self.cellID[Int((offset - 0xFF0) / 4)] default: return 0 @@ -40,15 +75,56 @@ public final class PL011: MMIODevice { } public func write(offset: UInt64, value: UInt64, width: Int) { + if offset == 0x00 { + sink(UInt8(truncatingIfNeeded: value)) + return + } + lock.lock() + defer { lock.unlock() } switch offset { - case 0x00: sink(UInt8(truncatingIfNeeded: value)) case 0x24: integerBaud = value case 0x28: fractionalBaud = value case 0x2C: lineControl = value case 0x30: control = value case 0x34: fifoLevel = value - case 0x38: interruptMask = value - default: break // ICR and friends: write-ignored + case 0x38: + interruptMask = value + refreshInterruptLocked() + case 0x44: + // RX is level-derived from queued bytes, so clearing ICR cannot hide unread input. + refreshInterruptLocked() + default: break + } + } + + /// Queues one bounded input frame for the guest UART. Returns false without changing state + /// when the frame would exceed the private recovery console's memory bound. + @discardableResult + public func receive(_ bytes: [UInt8]) -> Bool { + guard !bytes.isEmpty else { return true } + lock.lock() + defer { lock.unlock() } + let unread = receiveBytes.count - receiveOffset + guard bytes.count <= Self.maximumBufferedReceiveBytes - unread else { return false } + if receiveOffset > 0 { + receiveBytes.removeFirst(receiveOffset) + receiveOffset = 0 } + receiveBytes.append(contentsOf: bytes) + refreshInterruptLocked() + return true + } + + private var rawInterruptStatusLocked: UInt64 { + receiveOffset < receiveBytes.count + ? Self.receiveInterrupt | Self.receiveTimeoutInterrupt + : 0 + } + + private func refreshInterruptLocked() { + let shouldAssert = rawInterruptStatusLocked & interruptMask != 0 + guard shouldAssert != interruptAsserted else { return } + interruptAsserted = shouldAssert + setInterrupt(shouldAssert) } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/PVHBoot.swift b/Packages/ContainerizationEngine/Sources/DoryHV/PVHBoot.swift index d35cf4ce..0eec938a 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/PVHBoot.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/PVHBoot.swift @@ -1,3 +1,4 @@ +import DoryFSWorkerContracts import Foundation public struct PVHMemoryMapEntry: Equatable, Sendable { diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/RawHVMachineRunner.swift b/Packages/ContainerizationEngine/Sources/DoryHV/RawHVMachineRunner.swift new file mode 100644 index 00000000..81b3629a --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/RawHVMachineRunner.swift @@ -0,0 +1,271 @@ +import Darwin +import Foundation + +/// Lifecycle failures for the dedicated RawHV owner thread. +/// +/// A runner is deliberately single-use. Hypervisor.framework vCPUs cannot migrate between host +/// threads, so retrying the same closure on another thread would turn a failed launch into an +/// ambiguous ownership transfer. +public enum RawHVMachineRunnerError: Error, Equatable, Sendable, CustomStringConvertible { + case alreadyStarted + case notStarted + case waitFromOwnerThread + case threadCreationFailed(Int32) + case threadJoinFailed(Int32) + + public var description: String { + switch self { + case .alreadyStarted: + return "RawHV machine runner is single-use and has already started" + case .notStarted: + return "RawHV machine runner has not started" + case .waitFromOwnerThread: + return "RawHV machine owner thread cannot join itself" + case .threadCreationFailed(let code): + return "cannot create RawHV machine owner thread: pthread error \(code)" + case .threadJoinFailed(let code): + return "cannot join RawHV machine owner thread: pthread error \(code)" + } + } +} + +private final class RawHVOwnerThreadBootstrap: @unchecked Sendable { + private let body: @Sendable () -> Void + + init(body: @escaping @Sendable () -> Void) { + self.body = body + } + + func run() { + body() + } +} + +/// A single-use pthread lifecycle with exactly one operation owner and exactly one native join. +/// +/// This is internal so focused tests can prove the threading contract without creating a live +/// Hypervisor.framework VM. Production callers use `RawHVMachineRunner` below. +final class RawHVOwnerThread: @unchecked Sendable { + typealias Completion = @Sendable (Result) -> Void + + private enum Phase { + case ready + case running + case finished + case joining + case joined + case startFailed(Int32) + case joinFailed(Int32) + } + + private let condition = NSCondition() + private let name: String + private let stackSize: Int + private let operation: @Sendable () throws -> Output + + private var phase = Phase.ready + private var nativeThread: pthread_t? + private var ownerThread: pthread_t? + private var result: Result? + private var completion: Completion? + + init( + name: String, + stackSize: Int = RawHVSchedulingPolicy.machineOwnerThreadStackSize, + operation: @escaping @Sendable () throws -> Output + ) { + precondition(!name.isEmpty) + precondition(stackSize >= PTHREAD_STACK_MIN) + self.name = String(name.prefix(63)) + self.stackSize = stackSize + self.operation = operation + } + + func start(completion: Completion? = nil) throws { + var attributes = pthread_attr_t() + let attributeResult = pthread_attr_init(&attributes) + guard attributeResult == 0 else { + throw RawHVMachineRunnerError.threadCreationFailed(attributeResult) + } + defer { pthread_attr_destroy(&attributes) } + + let stackResult = pthread_attr_setstacksize(&attributes, stackSize) + guard stackResult == 0 else { + throw RawHVMachineRunnerError.threadCreationFailed(stackResult) + } + + condition.lock() + guard case .ready = phase else { + condition.unlock() + throw RawHVMachineRunnerError.alreadyStarted + } + self.completion = completion + + // Keep this owner alive independently of its caller until the native entry point returns. + // Holding `condition` across pthread_create closes the race where a very short operation + // could publish `.finished` before the creator stores the pthread handle. + let bootstrap = RawHVOwnerThreadBootstrap { [self] in threadMain() } + let context = Unmanaged.passRetained(bootstrap).toOpaque() + var createdThread: pthread_t? + let createResult = pthread_create( + &createdThread, + &attributes, + { rawContext -> UnsafeMutableRawPointer? in + let bootstrap = Unmanaged + .fromOpaque(rawContext) + .takeRetainedValue() + bootstrap.run() + return nil + }, + context + ) + guard createResult == 0, let createdThread else { + Unmanaged.fromOpaque(context).release() + phase = .startFailed(createResult == 0 ? EINVAL : createResult) + self.completion = nil + condition.broadcast() + condition.unlock() + throw RawHVMachineRunnerError.threadCreationFailed( + createResult == 0 ? EINVAL : createResult + ) + } + nativeThread = createdThread + phase = .running + condition.unlock() + } + + /// Waits for the operation and performs the one native `pthread_join`. + /// + /// Concurrent waiters are allowed: one performs the join and all others observe the identical + /// result after that join completes. The operation error itself is also replayable, so a second + /// lifecycle observer cannot accidentally consume it. + func wait() throws -> Output { + condition.lock() + if let ownerThread, pthread_equal(pthread_self(), ownerThread) != 0 { + condition.unlock() + throw RawHVMachineRunnerError.waitFromOwnerThread + } + + while true { + switch phase { + case .ready: + condition.unlock() + throw RawHVMachineRunnerError.notStarted + case .startFailed(let code): + condition.unlock() + throw RawHVMachineRunnerError.threadCreationFailed(code) + case .running: + condition.wait() + case .joining: + condition.wait() + case .joinFailed(let code): + condition.unlock() + throw RawHVMachineRunnerError.threadJoinFailed(code) + case .joined: + let result = self.result + condition.unlock() + return try requiredResult(result).get() + case .finished: + guard let nativeThread else { + phase = .joinFailed(EINVAL) + condition.broadcast() + condition.unlock() + throw RawHVMachineRunnerError.threadJoinFailed(EINVAL) + } + phase = .joining + condition.unlock() + + let joinResult = pthread_join(nativeThread, nil) + + condition.lock() + if joinResult == 0 { + phase = .joined + self.nativeThread = nil + } else { + phase = .joinFailed(joinResult) + } + condition.broadcast() + if joinResult != 0 { + condition.unlock() + throw RawHVMachineRunnerError.threadJoinFailed(joinResult) + } + let result = self.result + condition.unlock() + return try requiredResult(result).get() + } + } + } + + private func threadMain() { + RawHVSchedulingPolicy.applyToCurrentMachineOwnerThread() + pthread_setname_np(name) + + condition.lock() + ownerThread = pthread_self() + condition.unlock() + + let completed = Result { try operation() } + + condition.lock() + result = completed + phase = .finished + let completion = self.completion + self.completion = nil + condition.broadcast() + condition.unlock() + + // Completion may only publish onto another executor; lifecycle destruction still waits for + // `pthread_join`, which does not complete until this callback and the entry point return. + completion?(completed) + } + + private func requiredResult( + _ result: Result? + ) -> Result { + guard let result else { + preconditionFailure("joined RawHV owner thread did not publish a result") + } + return result + } +} + +/// Runs `Machine.run()` on a dedicated, non-libdispatch pthread. +/// +/// `Machine.run()` makes the owner thread the boot vCPU. Secondary vCPUs already have one +/// Foundation thread each and are joined by `Machine.run()` before it returns. Joining this runner +/// therefore establishes the final boundary before device and VM teardown. +public final class RawHVMachineRunner: @unchecked Sendable { + public typealias Completion = @Sendable (Result) -> Void + + private let machine: Machine + private let owner: RawHVOwnerThread + + public init(machine: Machine, threadName: String) { + self.machine = machine + owner = RawHVOwnerThread(name: threadName) { + try machine.run() + } + } + + public func start(completion: Completion? = nil) throws { + try owner.start(completion: completion) + } + + @discardableResult + public func wait() throws -> GuestStopReason { + try owner.wait() + } + + @discardableResult + public func runToCompletion() throws -> GuestStopReason { + try start() + return try wait() + } + + /// Publishes the terminal reason, exits every live vCPU, and joins the owner thread. + @discardableResult + public func stopAndWait(_ reason: GuestStopReason) throws -> GuestStopReason { + machine.requestStop(reason) + return try wait() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/HostUsbDevice.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/HostUsbDevice.swift index 837e7a6c..3d135ac7 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/HostUsbDevice.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/HostUsbDevice.swift @@ -31,19 +31,12 @@ public enum HostUsbDiscoveryError: Error, Equatable, Sendable { case matchingFailed(kern_return_t) } -public enum HostUsbOpenMode: Equatable, Sendable { +public enum HostUsbOpenMode: Hashable, Sendable { case userAuthorized case seize case capture } -public struct HostUsbOpenPlan: Equatable, Sendable { - public var mode: HostUsbOpenMode - public var authorize: Bool - public var requiresPrivilegedHelperForClaimedDevice: Bool - public var optionNames: [String] -} - public enum HostUsbOpenError: Error, Equatable, Sendable { case notFound(String) case authorizationFailed(kern_return_t) @@ -51,29 +44,15 @@ public enum HostUsbOpenError: Error, Equatable, Sendable { } public enum HostUsbDeviceFactory: Sendable { - public static func plan(mode: HostUsbOpenMode) -> HostUsbOpenPlan { - switch mode { - case .userAuthorized: - HostUsbOpenPlan(mode: mode, authorize: true, requiresPrivilegedHelperForClaimedDevice: false, optionNames: []) - case .seize: - HostUsbOpenPlan(mode: mode, authorize: true, requiresPrivilegedHelperForClaimedDevice: false, optionNames: ["deviceSeize"]) - case .capture: - HostUsbOpenPlan(mode: mode, authorize: true, requiresPrivilegedHelperForClaimedDevice: true, optionNames: ["deviceCapture"]) - } - } - public static func open(busID: String, mode: HostUsbOpenMode = .userAuthorized) throws -> HostUsbDevice { let (candidate, service) = try findService(busID: busID) defer { IOObjectRelease(service) } - let plan = plan(mode: mode) - if plan.authorize { - let kr = IOServiceAuthorize(service, UInt32(kIOServiceInteractionAllowed)) - guard kr == KERN_SUCCESS else { throw HostUsbOpenError.authorizationFailed(kr) } - } + let kr = IOServiceAuthorize(service, UInt32(kIOServiceInteractionAllowed)) + guard kr == KERN_SUCCESS else { throw HostUsbOpenError.authorizationFailed(kr) } guard let device = DoryIOUSBHostCreateDevice(service, options(for: mode), nil) else { throw HostUsbOpenError.openDeviceFailed } - let opened = collectPipes(deviceService: service, mode: mode) + let opened = collectPipes(deviceService: service) let retained: [IOUSBHostObject] = [device] + opened.interfaces let backend = IOUSBHostDeviceBackend(controlObject: device, pipes: opened.pipes, retainedObjects: retained) return HostUsbDevice(descriptor: candidate.descriptor, backend: backend) @@ -100,7 +79,7 @@ public enum HostUsbDeviceFactory: Sendable { throw HostUsbOpenError.notFound(busID) } - private static func collectPipes(deviceService: io_service_t, mode: HostUsbOpenMode) -> (interfaces: [IOUSBHostInterface], pipes: [UInt8: IOUSBHostPipe]) { + private static func collectPipes(deviceService: io_service_t) -> (interfaces: [IOUSBHostInterface], pipes: [UInt8: IOUSBHostPipe]) { var iterator: io_iterator_t = 0 guard IORegistryEntryCreateIterator(deviceService, kIOServicePlane, IOOptionBits(kIORegistryIterateRecursively), &iterator) == KERN_SUCCESS else { return ([], [:]) @@ -301,27 +280,88 @@ public enum HostUsbTransferError: Error, Equatable, Sendable { case failed(errno: Int32) } +public enum HostUsbTransferKind: Equatable, Sendable { + case bulk + case interrupt +} + public protocol HostUsbBackend: Sendable { func control(_ setup: HostUsbControlSetup, payload: [UInt8], direction: UsbipDirection, timeout: TimeInterval) throws -> HostUsbTransferResult - func transfer(endpointAddress: UInt8, payload: [UInt8], expectedLength: UInt32, direction: UsbipDirection, timeout: TimeInterval) throws -> HostUsbTransferResult + func transfer(endpointAddress: UInt8, payload: [UInt8], expectedLength: UInt32, direction: UsbipDirection, kind: HostUsbTransferKind, timeout: TimeInterval) throws -> HostUsbTransferResult func abort(endpointAddress: UInt8?) throws } public final class HostUsbDevice: UsbipExportedDevice, @unchecked Sendable { + /// Linux `EREMOTEIO`, used on the USB/IP wire for `URB_SHORT_NOT_OK`. + private static let linuxRemoteIO: Int32 = 121 + + private struct RequestKey: Hashable { + var contextID: UUID + var sequenceNumber: UInt32 + } + + private struct ActiveRequest { + var endpointAddress: UInt8 + var reservedBytes: UInt64 + } + public let descriptor: UsbipDeviceDescriptor private let backend: any HostUsbBackend private let timeout: TimeInterval + private let shutdownTimeout: TimeInterval + private let maxConcurrentRequests: Int + private let maxInFlightBytes: UInt64 + private let state = NSCondition() + private var activeRequests: [RequestKey: ActiveRequest] = [:] + private var inFlightBytes: UInt64 = 0 + private var abortingEndpoints = Set() + private var shuttingDown = false - public init(descriptor: UsbipDeviceDescriptor, backend: any HostUsbBackend, timeout: TimeInterval = 5) { + public init( + descriptor: UsbipDeviceDescriptor, + backend: any HostUsbBackend, + timeout: TimeInterval = 5, + maxConcurrentRequests: Int = 8, + maxInFlightBytes: UInt64 = 16 * 1024 * 1024, + shutdownTimeout: TimeInterval = 2 + ) { self.descriptor = descriptor self.backend = backend - self.timeout = timeout + self.timeout = Self.finiteDeadline(timeout, fallback: 5) + self.maxConcurrentRequests = max(1, maxConcurrentRequests) + self.maxInFlightBytes = max(1, maxInFlightBytes) + self.shutdownTimeout = Self.finiteDeadline(shutdownTimeout, fallback: 2) } - public func submit(_ command: UsbipSubmitCommand) throws -> UsbipSubmitReply { - guard command.numberOfPackets == 0 || command.numberOfPackets == 0xffff_ffff else { + public func submit(_ command: UsbipSubmitCommand, context: UsbipRequestContext) throws -> UsbipSubmitReply { + do { + try UsbipSubmitCommand.validateTransferFlags( + command.transferFlags, + direction: command.header.direction + ) + } catch { + return reply(for: command, status: -EINVAL, actualLength: 0, data: []) + } + // Accept both non-iso values produced/required by Linux USB/IP; positive counts are + // isochronous and remain unsupported by this backend. + guard command.numberOfPackets == 0 || command.numberOfPackets == UInt32.max else { return reply(for: command, status: -EPIPE, actualLength: 0, data: []) } + guard command.header.endpoint <= 15, + command.transferBufferLength <= UsbipSubmitCommand.maxTransferBytes, + (command.header.direction == .in || command.transferBuffer.count == Int(command.transferBufferLength)) else { + return reply(for: command, status: -EINVAL, actualLength: 0, data: []) + } + + let endpointAddress = command.header.endpoint == 0 + ? UInt8(0) + : Self.endpointAddress(number: command.header.endpoint, direction: command.header.direction) + let key = RequestKey(contextID: context.id, sequenceNumber: command.header.sequenceNumber) + let reservation = UInt64(command.transferBufferLength) + if let admissionError = admit(key: key, endpointAddress: endpointAddress, bytes: reservation) { + return reply(for: command, status: -admissionError, actualLength: 0, data: []) + } + defer { finish(key: key) } do { let result: HostUsbTransferResult @@ -335,29 +375,93 @@ public final class HostUsbDevice: UsbipExportedDevice, @unchecked Sendable { payload: command.header.direction == .out ? command.transferBuffer : [], expectedLength: command.transferBufferLength, direction: command.header.direction, - timeout: command.header.endpoint == 0 ? timeout : (command.interval == 0 ? 0 : timeout) + kind: command.interval == 0 ? .bulk : .interrupt, + timeout: timeout ) } - return reply(for: command, status: result.status, actualLength: result.actualLength, data: result.data) + let validated = try validate(result: result, for: command) + return reply(for: command, status: validated.status, actualLength: validated.actualLength, data: validated.data) } catch let error as HostUsbTransferError { return reply(for: command, status: Self.usbipStatus(for: error), actualLength: 0, data: []) + } catch { + return reply(for: command, status: -EIO, actualLength: 0, data: []) } } - public func unlink(_ command: UsbipUnlinkCommand) throws -> UsbipUnlinkReply { + public func unlink(_ command: UsbipUnlinkCommand, context: UsbipRequestContext) throws -> UsbipUnlinkReply { let status: Int32 - do { - try backend.abort(endpointAddress: nil) - status = 0 - } catch let error as HostUsbTransferError { - status = Self.usbipStatus(for: error) + let targetKey = RequestKey(contextID: context.id, sequenceNumber: command.unlinkSequenceNumber) + state.lock() + if let target = activeRequests[targetKey] { + let endpointIsShared = activeRequests.contains { key, request in + key != targetKey && request.endpointAddress == target.endpointAddress + } + if endpointIsShared || abortingEndpoints.contains(target.endpointAddress) { + state.unlock() + status = -EBUSY + } else { + abortingEndpoints.insert(target.endpointAddress) + state.unlock() + do { + // Endpoint zero means only default-control requests. nil is reserved for + // terminal device shutdown and is never used by an untrusted UNLINK. + try backend.abort(endpointAddress: target.endpointAddress) + status = 0 + } catch let error as HostUsbTransferError { + status = Self.usbipStatus(for: error) + } catch { + status = -EIO + } + state.lock() + abortingEndpoints.remove(target.endpointAddress) + state.broadcast() + state.unlock() + } + } else { + state.unlock() + status = -ENOENT } let header = UsbipHeaderBasic(command: .retUnlink, sequenceNumber: command.header.sequenceNumber, deviceID: 0, direction: .out, endpoint: 0) return UsbipUnlinkReply(header: header, status: status) } + public func closeSession(_ context: UsbipRequestContext) { + // The synchronous stream pump cannot race-proof a disconnect cancellation against a new + // request entering the same pipe. Fail closed: leave it to the finite host deadline instead + // of risking an abort of a later or unrelated URB. Explicit UNLINK uses the locked identity + // check above; terminal detach uses shutdown(). + } + + public func shutdown() { + state.lock() + if shuttingDown { + state.unlock() + return + } + shuttingDown = true + state.broadcast() + state.unlock() + + let deadline = Date().addingTimeInterval(shutdownTimeout) + Self.abort(backend, endpointAddress: nil, timeout: shutdownTimeout) + state.lock() + while !activeRequests.isEmpty, state.wait(until: deadline) {} + state.unlock() + } + + deinit { + state.lock() + let alreadyShuttingDown = shuttingDown + shuttingDown = true + state.unlock() + if !alreadyShuttingDown { + Self.abort(backend, endpointAddress: nil, timeout: shutdownTimeout) + } + } + nonisolated public static func endpointAddress(number: UInt32, direction: UsbipDirection) -> UInt8 { - UInt8(number & 0x0f) | (direction == .in ? 0x80 : 0x00) + precondition(number <= 15) + return UInt8(number) | (direction == .in ? 0x80 : 0x00) } private func reply(for command: UsbipSubmitCommand, status: Int32, actualLength: UInt32, data: [UInt8]) -> UsbipSubmitReply { @@ -372,14 +476,76 @@ public final class HostUsbDevice: UsbipExportedDevice, @unchecked Sendable { case .failed(let errno): -abs(errno) } } + + private func admit(key: RequestKey, endpointAddress: UInt8, bytes: UInt64) -> Int32? { + state.lock() + defer { state.unlock() } + guard !shuttingDown else { return ENODEV } + guard activeRequests[key] == nil else { return EALREADY } + guard activeRequests.count < maxConcurrentRequests else { return EBUSY } + guard !abortingEndpoints.contains(endpointAddress) else { return EBUSY } + let (newTotal, overflow) = inFlightBytes.addingReportingOverflow(bytes) + guard !overflow, newTotal <= maxInFlightBytes else { return EBUSY } + inFlightBytes = newTotal + activeRequests[key] = ActiveRequest(endpointAddress: endpointAddress, reservedBytes: bytes) + return nil + } + + private func finish(key: RequestKey) { + state.lock() + if let removed = activeRequests.removeValue(forKey: key) { + inFlightBytes -= removed.reservedBytes + } + state.broadcast() + state.unlock() + } + + private func validate(result: HostUsbTransferResult, for command: UsbipSubmitCommand) throws -> HostUsbTransferResult { + guard result.actualLength <= command.transferBufferLength else { + throw HostUsbTransferError.failed(errno: EPROTO) + } + if command.header.direction == .in { + guard result.data.count >= Int(result.actualLength), + result.data.count <= Int(command.transferBufferLength) else { + throw HostUsbTransferError.failed(errno: EPROTO) + } + let shortTransferRejected = result.status == 0 + && command.transferFlags & UsbipTransferFlag.shortNotOK != 0 + && result.actualLength < command.transferBufferLength + return HostUsbTransferResult( + status: shortTransferRejected ? -Self.linuxRemoteIO : result.status, + actualLength: result.actualLength, + data: Array(result.data.prefix(Int(result.actualLength))) + ) + } + return HostUsbTransferResult(status: result.status, actualLength: result.actualLength) + } + + private nonisolated static func finiteDeadline(_ value: TimeInterval, fallback: TimeInterval) -> TimeInterval { + guard value.isFinite, value > 0 else { return fallback } + return min(max(value, 0.01), 60) + } + + private nonisolated static func abort( + _ backend: any HostUsbBackend, + endpointAddress: UInt8?, + timeout: TimeInterval + ) { + let completion = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + try? backend.abort(endpointAddress: endpointAddress) + completion.signal() + } + _ = completion.wait(timeout: .now() + timeout) + } } public final class IOUSBHostDeviceBackend: HostUsbBackend, @unchecked Sendable { private let controlObject: IOUSBHostObject? private let pipes: [UInt8: IOUSBHostPipe] - private let retainedObjects: [IOUSBHostObject] + private let lifetime: IOUSBObjectLifetime private let controlHandler: (@Sendable (HostUsbControlSetup, [UInt8], UsbipDirection, TimeInterval) throws -> HostUsbTransferResult)? - private let lock = NSLock() + private let abortLock = NSLock() public init( controlObject: IOUSBHostObject? = nil, @@ -389,16 +555,10 @@ public final class IOUSBHostDeviceBackend: HostUsbBackend, @unchecked Sendable { ) { self.controlObject = controlObject self.pipes = pipes - self.retainedObjects = retainedObjects + self.lifetime = IOUSBObjectLifetime(objects: retainedObjects) self.controlHandler = controlHandler } - deinit { - for object in retainedObjects { - DoryIOUSBHostDestroyObject(object, []) - } - } - public func control(_ setup: HostUsbControlSetup, payload: [UInt8], direction: UsbipDirection, timeout: TimeInterval) throws -> HostUsbTransferResult { if let controlHandler { return try controlHandler(setup, payload, direction, timeout) @@ -409,73 +569,118 @@ public final class IOUSBHostDeviceBackend: HostUsbBackend, @unchecked Sendable { if direction == .out { data.replaceBytes(in: NSRange(location: 0, length: min(payload.count, data.length)), withBytes: payload) } - var transferred = 0 let request = setup.ioUSBDeviceRequest() - let ok = locked { - DoryIOUSBHostSendDeviceRequest(controlObject, request, data, &transferred, timeout, nil) + let lease = IOUSBRequestLease(data: data, resource: controlObject, lifetime: lifetime) + let result = try HostUsbDeadlineWaiter.perform( + timeout: timeout, + enqueue: { completion in + DoryIOUSBHostEnqueueDeviceRequest(controlObject, request, data, timeout, nil) { status, count in + _ = lease + completion(status, Int(count)) + } + }, + abort: { [self] in abortControlRequests() } + ) + guard result.status == kIOReturnSuccess else { + throw HostUsbTransferError.failed(errno: Self.errno(for: result.status)) + } + let transferred = result.count + guard transferred >= 0, transferred <= data.length, UInt32(exactly: transferred) != nil else { + throw HostUsbTransferError.failed(errno: EPROTO) } - guard ok else { throw HostUsbTransferError.failed(errno: EIO) } let bytes = direction == .in ? Array(UnsafeBufferPointer(start: data.bytes.assumingMemoryBound(to: UInt8.self), count: min(transferred, data.length))) : [] return HostUsbTransferResult(status: 0, actualLength: UInt32(transferred), data: bytes) } - public func transfer(endpointAddress: UInt8, payload: [UInt8], expectedLength: UInt32, direction: UsbipDirection, timeout: TimeInterval) throws -> HostUsbTransferResult { + public func transfer( + endpointAddress: UInt8, + payload: [UInt8], + expectedLength: UInt32, + direction: UsbipDirection, + kind: HostUsbTransferKind, + timeout: TimeInterval + ) throws -> HostUsbTransferResult { guard let pipe = pipes[endpointAddress] else { throw HostUsbTransferError.endpointNotFound(endpointAddress) } + let actualKind = try Self.transferKind(endpointAttributes: pipe.descriptors.pointee.descriptor.bmAttributes) + guard actualKind == kind else { throw HostUsbTransferError.failed(errno: EPROTO) } let length = direction == .in ? Int(expectedLength) : payload.count guard let data = NSMutableData(length: length) else { throw HostUsbTransferError.failed(errno: ENOMEM) } if direction == .out { data.replaceBytes(in: NSRange(location: 0, length: min(payload.count, data.length)), withBytes: payload) } - let completion = IOUSBCompletionBox() - let status = locked { - let semaphore = DispatchSemaphore(value: 0) - do { - try pipe.enqueueIORequest(with: data, completionTimeout: timeout) { status, count in - completion.set(status: status, count: count) - semaphore.signal() + let lease = IOUSBRequestLease(data: data, resource: pipe, lifetime: lifetime) + // IOUSBHost requires a zero framework timeout for interrupt pipes. Dory still applies the + // finite outer deadline below and synchronously asks the exact pipe to abort on expiry. + let frameworkTimeout = kind == .interrupt ? 0 : timeout + let result = try HostUsbDeadlineWaiter.perform( + timeout: timeout, + enqueue: { completion in + do { + try pipe.enqueueIORequest(with: data, completionTimeout: frameworkTimeout) { status, count in + _ = lease + completion(status, count) + } + return true + } catch { + return false } - } catch { - return kIOReturnError + }, + abort: { [self, lease] in + guard let retainedPipe = lease.resource as? IOUSBHostPipe else { return } + abortPipe(retainedPipe) } - semaphore.wait() - return completion.result.status + ) + guard result.status == kIOReturnSuccess else { + throw HostUsbTransferError.failed(errno: Self.errno(for: result.status)) + } + let transferred = result.count + guard transferred >= 0, transferred <= data.length, UInt32(exactly: transferred) != nil else { + throw HostUsbTransferError.failed(errno: EPROTO) } - guard status == kIOReturnSuccess else { throw HostUsbTransferError.failed(errno: Self.errno(for: status)) } - let transferred = completion.result.count let bytes = direction == .in ? Array(UnsafeBufferPointer(start: data.bytes.assumingMemoryBound(to: UInt8.self), count: min(transferred, data.length))) : [] return HostUsbTransferResult(status: 0, actualLength: UInt32(transferred), data: bytes) } public func abort(endpointAddress: UInt8?) throws { if let endpointAddress { + if endpointAddress == 0 { + guard controlObject != nil else { + throw HostUsbTransferError.endpointNotFound(endpointAddress) + } + guard abortControlRequests() else { throw HostUsbTransferError.failed(errno: EIO) } + return + } guard let pipe = pipes[endpointAddress] else { throw HostUsbTransferError.endpointNotFound(endpointAddress) } - let ok = locked { - DoryIOUSBHostAbortPipe(pipe, IOUSBHostAbortOption.synchronous, nil) - } + let ok = abortPipe(pipe) guard ok else { throw HostUsbTransferError.failed(errno: EIO) } return } var ok = true - if let controlObject { - ok = locked { - DoryIOUSBHostAbortDeviceRequests(controlObject, IOUSBHostAbortOption.synchronous, nil) - } + if controlObject != nil { + ok = abortControlRequests() } guard ok else { throw HostUsbTransferError.failed(errno: EIO) } for pipe in pipes.values { - let pipeOK = locked { - DoryIOUSBHostAbortPipe(pipe, IOUSBHostAbortOption.synchronous, nil) - } + let pipeOK = abortPipe(pipe) guard pipeOK else { throw HostUsbTransferError.failed(errno: EIO) } } } - private func locked(_ body: () throws -> T) rethrows -> T { - lock.lock() - defer { lock.unlock() } - return try body() + @discardableResult + private func abortControlRequests() -> Bool { + guard let controlObject else { return true } + abortLock.lock() + defer { abortLock.unlock() } + return DoryIOUSBHostAbortDeviceRequests(controlObject, IOUSBHostAbortOption.synchronous, nil) + } + + @discardableResult + private func abortPipe(_ pipe: IOUSBHostPipe) -> Bool { + abortLock.lock() + defer { abortLock.unlock() } + return DoryIOUSBHostAbortPipe(pipe, IOUSBHostAbortOption.synchronous, nil) } nonisolated static func errno(for status: IOReturn) -> Int32 { @@ -492,23 +697,109 @@ public final class IOUSBHostDeviceBackend: HostUsbBackend, @unchecked Sendable { default: EIO } } + + /// USB endpoint descriptor bits 0...1 define control, isochronous, bulk, or interrupt. Dory + /// never trusts the guest's interval field to reclassify the physical host pipe. + nonisolated static func transferKind(endpointAttributes: UInt8) throws -> HostUsbTransferKind { + switch endpointAttributes & 0x03 { + case 0x02: .bulk + case 0x03: .interrupt + case 0x01: throw HostUsbTransferError.failed(errno: ENOTSUP) + default: throw HostUsbTransferError.failed(errno: EPROTO) + } + } +} + +struct HostUsbIOCompletion: Equatable, Sendable { + var status: IOReturn + var count: Int +} + +enum HostUsbDeadlineWaiter { + typealias Completion = @Sendable (IOReturn, Int) -> Void + + static func perform( + timeout: TimeInterval, + enqueue: (_ completion: @escaping Completion) -> Bool, + abort: @escaping @Sendable () -> Void + ) throws -> HostUsbIOCompletion { + let finiteTimeout = timeout.isFinite && timeout > 0 ? min(timeout, 60) : 5 + let completion = IOUSBCompletionBox() + guard enqueue({ status, count in completion.complete(status: status, count: count) }) else { + throw HostUsbTransferError.failed(errno: EIO) + } + if completion.wait(timeout: finiteTimeout) { + return completion.result + } + + // Never let a synchronous IOUSBHost abort turn the watchdog into another unbounded wait. + // The closure retains the exact object/request lifetime until abort returns, and a late + // completion only touches the locked completion box. + let abortFinished = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + abort() + abortFinished.signal() + } + _ = completion.wait(timeout: min(0.25, finiteTimeout)) + _ = abortFinished.wait(timeout: .now() + min(0.25, finiteTimeout)) + throw HostUsbTransferError.failed(errno: ETIMEDOUT) + } } private final class IOUSBCompletionBox: @unchecked Sendable { private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) private var storedStatus: IOReturn = kIOReturnError private var storedCount = 0 + private var completed = false - func set(status: IOReturn, count: Int) { + func complete(status: IOReturn, count: Int) { lock.lock() + guard !completed else { lock.unlock(); return } storedStatus = status storedCount = count + completed = true lock.unlock() + semaphore.signal() } - var result: (status: IOReturn, count: Int) { + func wait(timeout: TimeInterval) -> Bool { + lock.lock() + let alreadyCompleted = completed + lock.unlock() + if alreadyCompleted { return true } + return semaphore.wait(timeout: .now() + timeout) == .success + } + + var result: HostUsbIOCompletion { lock.lock() defer { lock.unlock() } - return (storedStatus, storedCount) + return HostUsbIOCompletion(status: storedStatus, count: storedCount) + } +} + +private final class IOUSBObjectLifetime: @unchecked Sendable { + private let objects: [IOUSBHostObject] + + init(objects: [IOUSBHostObject]) { + self.objects = objects + } + + deinit { + for object in objects { + DoryIOUSBHostDestroyObject(object, []) + } + } +} + +private final class IOUSBRequestLease: @unchecked Sendable { + let data: NSMutableData + let resource: AnyObject + let lifetime: IOUSBObjectLifetime + + init(data: NSMutableData, resource: AnyObject, lifetime: IOUSBObjectLifetime) { + self.data = data + self.resource = resource + self.lifetime = lifetime } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlHandler.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlHandler.swift index 09688a2c..b19ce26d 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlHandler.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlHandler.swift @@ -1,14 +1,7 @@ +import DoryVMContracts import Foundation -public struct UsbAttachOutcome: Equatable, Sendable, Codable { - public var busID: String - public var port: Int - public var vsockPort: UInt32 - public var deviceID: UInt32 - public var speed: UInt32 -} - -public struct UsbAgentAttachRequest: Equatable, Sendable, Encodable { +public struct UsbAgentAttachRequest: Equatable, Sendable { public var busid: String public var port: Int public var vsock_port: UInt32 @@ -16,7 +9,7 @@ public struct UsbAgentAttachRequest: Equatable, Sendable, Encodable { public var speed: UInt32 } -public struct UsbAgentDetachRequest: Equatable, Sendable, Encodable { +public struct UsbAgentDetachRequest: Equatable, Sendable { public var busid: String public var port: Int } @@ -24,16 +17,48 @@ public struct UsbAgentDetachRequest: Equatable, Sendable, Encodable { public enum UsbControlError: Error, Equatable, Sendable, CustomStringConvertible { case alreadyAttached(String) case notAttached(String) + case transitionInProgress(busID: String, operation: String) + case invalidBusID(String) + case deviceIdentityMismatch(expected: String, actual: String) + case invalidDeviceIdentity(busID: String, busNumber: UInt32, deviceNumber: UInt32) + case invalidAttachmentMetadata(busID: String, vsockPort: UInt32, deviceID: UInt32, speed: UInt32) + case openModeNotAllowed(HostUsbOpenMode) + case managerStoppedDuringTransition(String) + case mutationRejected(operation: DoryUSBControlV1.Operation, busID: String, detail: String) + case outcomeUnknown(operation: DoryUSBControlV1.Operation, busID: String, detail: String) case guestAgentRPCUnavailable + public var failureDisposition: DoryUSBControlV1.FailureDisposition { + if case .outcomeUnknown = self { return .outcomeUnknown } + return .rejected + } + public var description: String { switch self { case .alreadyAttached(let busID): return "USB device is already attached: \(busID)" case .notAttached(let busID): return "USB device is not attached: \(busID)" + case let .transitionInProgress(busID, operation): + return "USB device \(busID) is already \(operation)" + case .invalidBusID(let busID): + return "USB bus ID is not canonical: \(busID)" + case let .deviceIdentityMismatch(expected, actual): + return "USB device identity changed while opening (expected \(expected), got \(actual))" + case let .invalidDeviceIdentity(busID, busNumber, deviceNumber): + return "USB device \(busID) has an invalid USB/IP identity \(busNumber):\(deviceNumber)" + case let .invalidAttachmentMetadata(busID, vsockPort, deviceID, speed): + return "USB device \(busID) has invalid attachment metadata (vsock port \(vsockPort), device ID \(deviceID), speed \(speed))" + case .openModeNotAllowed(let mode): + return "USB open mode is not authorized by the engine policy: \(mode)" + case .managerStoppedDuringTransition(let busID): + return "USB manager stopped during the \(busID) transition; the operation was rolled back" + case let .mutationRejected(operation, busID, detail): + return "USB \(operation.rawValue) was rejected for \(busID): \(detail)" + case let .outcomeUnknown(operation, busID, detail): + return "USB \(operation.rawValue) outcome is unknown for \(busID): \(detail)" case .guestAgentRPCUnavailable: - return "USB attach/detach is unavailable: dory-agent control protocol has no USB RPC" + return "USB attach/detach is unavailable: the guest does not expose usb-vhci@1" } } } @@ -44,85 +69,380 @@ public enum UsbControlError: Error, Equatable, Sendable, CustomStringConvertible /// fails) is unit-testable without real hardware, a socket, or a running guest. public final class UsbControlHandler: @unchecked Sendable { private let manager: UsbipManager - private let ensureSupported: () throws -> Void - private let openDevice: (String, HostUsbOpenMode) throws -> any UsbipExportedDevice - private let notifyAttach: (UsbAgentAttachRequest) async throws -> Void - private let notifyDetach: (UsbAgentDetachRequest) async throws -> Void + private let allowedOpenModes: Set + private let ensureSupported: @Sendable () async throws -> Void + private let openDevice: + @Sendable (String, HostUsbOpenMode) throws -> any UsbipExportedDevice + private let notifyAttach: @Sendable (UsbAgentAttachRequest) async throws -> Void + private let notifyDetach: @Sendable (UsbAgentDetachRequest) async throws -> Void + private let trace: @Sendable (String) -> Void private let lock = NSLock() - private var portByBusID: [String: Int] = [:] + private enum AttachmentState: Equatable { + case attaching(port: Int) + case attached(port: Int) + /// The host claim and USB/IP registration are deliberately retained because guest attach + /// may have committed and its compensating detach did not establish a terminal state. + case uncertain(port: Int) + case detaching(port: Int, priorWasUncertain: Bool) + + var port: Int { + switch self { + case .attaching(let port), .attached(let port), .uncertain(let port): + return port + case .detaching(let port, _): return port + } + } + + var operation: String { + switch self { + case .attaching: return "attaching" + case .attached: return "attached" + case .uncertain: return "in an uncertain attach state" + case .detaching: return "detaching" + } + } + } + + private var attachmentByBusID: [String: AttachmentState] = [:] private var usedPorts = Set() public init( manager: UsbipManager, - ensureSupported: @escaping () throws -> Void = {}, - openDevice: @escaping (String, HostUsbOpenMode) throws -> any UsbipExportedDevice, - notifyAttach: @escaping (UsbAgentAttachRequest) async throws -> Void, - notifyDetach: @escaping (UsbAgentDetachRequest) async throws -> Void + allowedOpenModes: Set = [.userAuthorized], + ensureSupported: @escaping @Sendable () async throws -> Void = {}, + openDevice: @escaping @Sendable (String, HostUsbOpenMode) throws + -> any UsbipExportedDevice, + notifyAttach: @escaping @Sendable (UsbAgentAttachRequest) async throws -> Void, + notifyDetach: @escaping @Sendable (UsbAgentDetachRequest) async throws -> Void, + trace: @escaping @Sendable (String) -> Void = { _ in } ) { self.manager = manager + self.allowedOpenModes = allowedOpenModes self.ensureSupported = ensureSupported self.openDevice = openDevice self.notifyAttach = notifyAttach self.notifyDetach = notifyDetach + self.trace = trace } - public func attach(busID: String, mode: HostUsbOpenMode = .userAuthorized) async throws -> UsbAttachOutcome { + public func attach( + busID: String, + mode: HostUsbOpenMode = .userAuthorized + ) async throws -> DoryUSBControlV1.Attachment { // Capability is checked before opening or claiming the host device. A missing guest RPC must // fail closed; briefly seizing hardware and rolling back is still an observable disruption. - try ensureSupported() - try lock.withLock { - guard portByBusID[busID] == nil else { throw UsbControlError.alreadyAttached(busID) } + guard DoryUSBControlV1.BusID.isValid(busID) else { + throw UsbControlError.invalidBusID(busID) + } + guard allowedOpenModes.contains(mode) else { + throw UsbControlError.openModeNotAllowed(mode) + } + let mutation = try manager.beginControlMutation(operation: .attach, busID: busID) + defer { manager.finishControlMutation(mutation) } + // Capability negotiation is also an admitted, potentially uncancellable RPC. Keep it under + // the manager generation lease so stop cannot report a clean drain while it is still running. + trace("attach \(busID): checking guest usb-vhci capability") + try await ensureSupported() + trace("attach \(busID): guest usb-vhci capability confirmed") + guard manager.isControlMutationCurrent(mutation) else { + throw UsbControlError.managerStoppedDuringTransition(busID) + } + let port = try lock.withLock { () -> Int in + guard let current = attachmentByBusID[busID] else { + let port = allocatePortLocked() + attachmentByBusID[busID] = .attaching(port: port) + return port + } + switch current { + case .attached: + throw UsbControlError.alreadyAttached(busID) + case .uncertain: + throw UsbControlError.outcomeUnknown( + operation: .attach, + busID: busID, + detail: "a prior attach may have committed; detach it before attaching again" + ) + case .attaching, .detaching: + throw UsbControlError.transitionInProgress( + busID: busID, + operation: current.operation + ) + } + } + let device: any UsbipExportedDevice + do { + trace("attach \(busID): opening host device") + device = try openDevice(busID, mode) + trace("attach \(busID): host device opened") + } catch { + lock.withLock { rollbackLocked(busID, expected: .attaching(port: port)) } + throw error } - let device = try openDevice(busID, mode) - manager.register(device) - let port = lock.withLock { allocatePortLocked(for: busID) } let descriptor = device.descriptor + guard descriptor.busID == busID else { + device.shutdown() + lock.withLock { rollbackLocked(busID, expected: .attaching(port: port)) } + throw UsbControlError.deviceIdentityMismatch( + expected: busID, + actual: descriptor.busID + ) + } + guard descriptor.busNumber <= UInt32(UInt16.max), + descriptor.deviceNumber > 0, + descriptor.deviceNumber <= UInt32(UInt16.max) else { + device.shutdown() + lock.withLock { rollbackLocked(busID, expected: .attaching(port: port)) } + throw UsbControlError.invalidDeviceIdentity( + busID: busID, + busNumber: descriptor.busNumber, + deviceNumber: descriptor.deviceNumber + ) + } + let deviceID = (descriptor.busNumber << 16) | descriptor.deviceNumber + let attachment: DoryUSBControlV1.Attachment + do { + attachment = try DoryUSBControlV1.Attachment( + port: port, + vsockPort: manager.port, + deviceID: deviceID, + speed: descriptor.speed + ) + } catch { + device.shutdown() + lock.withLock { rollbackLocked(busID, expected: .attaching(port: port)) } + throw UsbControlError.invalidAttachmentMetadata( + busID: busID, + vsockPort: manager.port, + deviceID: deviceID, + speed: descriptor.speed + ) + } + do { + try manager.register(device, under: mutation) + trace("attach \(busID): USB/IP export registered on vsock port \(manager.port)") + } catch { + lock.withLock { rollbackLocked(busID, expected: .attaching(port: port)) } + throw error + } let request = UsbAgentAttachRequest( busid: busID, port: port, - vsock_port: manager.port, - device_id: (descriptor.busNumber << 16) | descriptor.deviceNumber, - speed: descriptor.speed + vsock_port: attachment.vsockPort, + device_id: attachment.deviceID, + speed: attachment.speed ) + guard manager.isControlMutationCurrent(mutation) else { + _ = try manager.unregisterClaim(under: mutation) + lock.withLock { rollbackLocked(busID, expected: .attaching(port: port)) } + throw UsbControlError.managerStoppedDuringTransition(busID) + } do { + trace("attach \(busID): requesting Linux VHCI port \(port)") try await notifyAttach(request) + trace("attach \(busID): Linux VHCI attach acknowledged") } catch { - // The guest could not attach — undo the host-side claim so the device returns to macOS. - manager.unregister(busID: busID) - lock.withLock { releasePortLocked(busID) } - throw error + try await compensateAttachUncertainty( + busID: busID, + port: port, + mutation: mutation, + rejectionDetail: "guest attach RPC failed: \(error)" + ) + } + let committed = manager.withCurrentControlMutation(mutation) { + lock.withLock { + guard case .attaching(let currentPort) = attachmentByBusID[busID], + currentPort == port else { + preconditionFailure("USB attach state changed without owning the transition") + } + attachmentByBusID[busID] = .attached(port: port) + } + return true + } ?? false + guard committed else { + try await compensateAttachUncertainty( + busID: busID, + port: port, + mutation: mutation, + rejectionDetail: "manager stopped after the guest attach RPC committed" + ) } - return UsbAttachOutcome(busID: busID, port: port, vsockPort: request.vsock_port, deviceID: request.device_id, speed: request.speed) + trace("attach \(busID): committed") + return attachment } public func detach(busID: String) async throws { - try ensureSupported() - let port = try lock.withLock { () -> Int in - guard let port = portByBusID[busID] else { throw UsbControlError.notAttached(busID) } - return port + guard DoryUSBControlV1.BusID.isValid(busID) else { + throw UsbControlError.invalidBusID(busID) + } + let mutation = try manager.beginControlMutation(operation: .detach, busID: busID) + defer { manager.finishControlMutation(mutation) } + try await ensureSupported() + guard manager.isControlMutationCurrent(mutation) else { + throw UsbControlError.managerStoppedDuringTransition(busID) + } + let transition = try lock.withLock { () -> (port: Int, priorWasUncertain: Bool) in + guard let state = attachmentByBusID[busID] else { + throw UsbControlError.notAttached(busID) + } + let port: Int + let priorWasUncertain: Bool + switch state { + case .attached(let currentPort): + port = currentPort + priorWasUncertain = false + case .uncertain(let currentPort): + port = currentPort + priorWasUncertain = true + case .attaching, .detaching: + throw UsbControlError.transitionInProgress( + busID: busID, + operation: state.operation + ) + } + attachmentByBusID[busID] = .detaching( + port: port, + priorWasUncertain: priorWasUncertain + ) + return (port, priorWasUncertain) + } + let port = transition.port + guard manager.isControlMutationCurrent(mutation) else { + lock.withLock { + restoreAfterFailedDetachLocked( + busID, + port: port, + priorWasUncertain: transition.priorWasUncertain + ) + } + throw UsbControlError.managerStoppedDuringTransition(busID) + } + do { + try await notifyDetach(UsbAgentDetachRequest(busid: busID, port: port)) + } catch { + // A failed RPC does not prove whether vhci-detach committed. Retaining both the host + // claim and the prior certainty lets an explicit later detach safely reconcile it. + lock.withLock { + restoreAfterFailedDetachLocked( + busID, + port: port, + priorWasUncertain: transition.priorWasUncertain + ) + } + let retentionDetail: String + do { + try manager.preserveClaimForReconciliation(under: mutation) + retentionDetail = "" + } catch { + retentionDetail = "; preserving the host claim failed: \(error)" + } + throw UsbControlError.outcomeUnknown( + operation: .detach, + busID: busID, + detail: "guest detach RPC failed: \(error)\(retentionDetail)" + ) + } + _ = try manager.unregisterClaim(under: mutation) + lock.withLock { + rollbackLocked( + busID, + expected: .detaching( + port: port, + priorWasUncertain: transition.priorWasUncertain + ) + ) } - try await notifyDetach(UsbAgentDetachRequest(busid: busID, port: port)) - manager.unregister(busID: busID) - lock.withLock { releasePortLocked(busID) } } public var attachedBusIDs: [String] { - lock.withLock { portByBusID.keys.sorted() } + lock.withLock { + attachmentByBusID.compactMap { busID, state in + switch state { + case .attached, .uncertain: return busID + case .attaching, .detaching: return nil + } + }.sorted() + } + } + + public var uncertainBusIDs: [String] { + lock.withLock { + attachmentByBusID.compactMap { busID, state in + if case .uncertain = state { return busID } + return nil + }.sorted() + } + } + + private func compensateAttachUncertainty( + busID: String, + port: Int, + mutation: UsbipManagerControlMutationLease, + rejectionDetail: String + ) async throws -> Never { + do { + try await notifyDetach(UsbAgentDetachRequest(busid: busID, port: port)) + } catch { + let retentionDetail: String + do { + try manager.preserveClaimForReconciliation(under: mutation) + retentionDetail = "" + } catch { + retentionDetail = "; preserving the host claim failed: \(error)" + } + lock.withLock { + guard attachmentByBusID[busID] == .attaching(port: port) else { + preconditionFailure("USB attach compensation lost transition ownership") + } + attachmentByBusID[busID] = .uncertain(port: port) + } + throw UsbControlError.outcomeUnknown( + operation: .attach, + busID: busID, + detail: "\(rejectionDetail); guest detach compensation failed: \(error)\(retentionDetail)" + ) + } + // Only a successful guest detach establishes the pre-attach state. Release the host claim + // afterwards; reversing this order would make an uncertain guest attachment unrecoverable. + _ = try manager.unregisterClaim(under: mutation) + lock.withLock { rollbackLocked(busID, expected: .attaching(port: port)) } + throw UsbControlError.mutationRejected( + operation: .attach, + busID: busID, + detail: rejectionDetail + ) + } + + private func restoreAfterFailedDetachLocked( + _ busID: String, + port: Int, + priorWasUncertain: Bool + ) { + let expected = AttachmentState.detaching( + port: port, + priorWasUncertain: priorWasUncertain + ) + guard attachmentByBusID[busID] == expected else { + preconditionFailure("USB detach rollback lost transition ownership") + } + attachmentByBusID[busID] = priorWasUncertain + ? .uncertain(port: port) + : .attached(port: port) } - private func allocatePortLocked(for busID: String) -> Int { + private func allocatePortLocked() -> Int { var port = 0 while usedPorts.contains(port) { port += 1 } usedPorts.insert(port) - portByBusID[busID] = port return port } - private func releasePortLocked(_ busID: String) { - if let port = portByBusID.removeValue(forKey: busID) { - usedPorts.remove(port) + private func rollbackLocked(_ busID: String, expected: AttachmentState) { + guard attachmentByBusID[busID] == expected else { + preconditionFailure("USB transition rollback lost ownership") } + attachmentByBusID.removeValue(forKey: busID) + usedPorts.remove(expected.port) } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlServer.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlServer.swift index 4891ae65..a31d37b1 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlServer.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlServer.swift @@ -1,215 +1,1065 @@ import Darwin +import DoryVMContracts import Foundation -public struct UsbControlRequest: Codable, Equatable, Sendable { - public var cmd: String // "attach" | "detach" - public var busid: String - public var mode: String? // "userAuthorized" | "seize" | "capture" (attach only) +/// Serves the engine's USB control protocol on a unix socket. The engine owns the device claim, the +/// UsbipManager, and the guest agent channel. One request is accepted per connection. +public final class UsbControlServer: @unchecked Sendable { + private static let endpointMutationLock = NSLock() + private static let productionMaximumSessions = 8 + private static let maximumConfiguredSessions = 64 + private static let maximumConfiguredTimeout: TimeInterval = 30 + + private let path: String + private let handler: UsbControlHandler + private let lock = NSLock() + private let maximumSessions: Int + private let frameTimeout: TimeInterval + private let expectedPeerUID: uid_t + private let peerUIDResolver: @Sendable (Int32) -> uid_t? + private let beforeStaleEndpointUnlinkValidation: @Sendable () throws -> Void + private let afterListenerBindValidation: @Sendable () throws -> Void + private let log: @Sendable (String) -> Void + private var currentRun: UsbControlServerRun? - public init(cmd: String, busid: String, mode: String? = nil) { - self.cmd = cmd - self.busid = busid - self.mode = mode + public convenience init(path: String, handler: UsbControlHandler) { + self.init( + path: path, + handler: handler, + maximumSessions: Self.productionMaximumSessions, + frameTimeout: 5, + expectedPeerUID: geteuid(), + peerUIDResolver: UsbControlSocketIO.peerUID, + beforeStaleEndpointUnlinkValidation: {}, + afterListenerBindValidation: {}, + log: { NSLog("%@", $0) } + ) + } + + init( + path: String, + handler: UsbControlHandler, + maximumSessions: Int, + frameTimeout: TimeInterval, + expectedPeerUID: uid_t, + peerUIDResolver: @escaping @Sendable (Int32) -> uid_t?, + beforeStaleEndpointUnlinkValidation: @escaping @Sendable () throws -> Void = {}, + afterListenerBindValidation: @escaping @Sendable () throws -> Void = {}, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { + precondition((1...Self.maximumConfiguredSessions).contains(maximumSessions)) + precondition( + frameTimeout.isFinite + && frameTimeout > 0 + && frameTimeout <= Self.maximumConfiguredTimeout + ) + self.path = path + self.handler = handler + self.maximumSessions = maximumSessions + self.frameTimeout = frameTimeout + self.expectedPeerUID = expectedPeerUID + self.peerUIDResolver = peerUIDResolver + self.beforeStaleEndpointUnlinkValidation = beforeStaleEndpointUnlinkValidation + self.afterListenerBindValidation = afterListenerBindValidation + self.log = log + } + + public func start() throws { + lock.lock() + guard currentRun == nil else { + lock.unlock() + throw UsbControlServerError.alreadyStarted + } + let owned: UsbControlOwnedListener + do { + owned = try Self.makeOwnedListener( + path: path, + expectedUID: geteuid(), + beforeStaleEndpointUnlinkValidation: beforeStaleEndpointUnlinkValidation, + afterListenerBindValidation: afterListenerBindValidation + ) + } catch { + lock.unlock() + throw error + } + let run = UsbControlServerRun( + listener: owned, + maximumSessions: maximumSessions, + handler: handler, + frameTimeout: frameTimeout, + expectedPeerUID: expectedPeerUID, + peerUIDResolver: peerUIDResolver, + log: log + ) + currentRun = run + lock.unlock() + Thread.detachNewThread { [path, log] in + Self.acceptLoop(run: run, path: path, log: log) + } + } + + /// Stop never closes the listener or a client from the calling thread. It only issues shutdown + /// to wake their owner threads, then waits to the supplied absolute bound. + @discardableResult + public func stop(timeout: TimeInterval = 5) -> Bool { + lock.lock() + guard let run = currentRun else { + lock.unlock() + return true + } + lock.unlock() + run.requestStop() + let bounded = timeout.isFinite + ? min(max(0, timeout), Self.maximumConfiguredTimeout) + : 5 + let drained = run.waitUntilDrained(timeout: bounded) + if drained { + lock.lock() + if currentRun === run { currentRun = nil } + lock.unlock() + } else { + log("USB control server did not drain within \(bounded) seconds") + } + return drained + } + + var activeSessionCount: Int { + lock.lock(); defer { lock.unlock() } + return currentRun?.activeSessionCount ?? 0 } -} -public struct UsbControlResponse: Codable, Equatable, Sendable { - public var ok: Bool - public var port: Int? - public var vsockPort: UInt32? - public var deviceID: UInt32? - public var speed: UInt32? - public var error: String? + var rejectedSessionCount: UInt64 { + lock.lock(); defer { lock.unlock() } + return currentRun?.rejectedSessionCount ?? 0 + } + + deinit { + _ = stop() + } + + private static func acceptLoop( + run: UsbControlServerRun, + path: String, + log: @escaping @Sendable (String) -> Void + ) { + let descriptor = run.listener.descriptor + defer { + endpointMutationLock.lock() + retireEndpointIfOwned( + path: path, + identity: run.listener.identity, + parentIdentity: run.listener.parentIdentity + ) + // The listener worker is the sole close owner. The lifetime object serializes this + // close with stop's shutdown so a reused descriptor can never be targeted. + run.listenerOwnerDidFinish() + endpointMutationLock.unlock() + } + while true { + if run.isStopping { return } + var readiness = pollfd(fd: descriptor, events: Int16(POLLIN), revents: 0) + let result = poll(&readiness, 1, 100) + if result == 0 { continue } + if result < 0 { + if errno == EINTR { continue } + if !run.isStopping { log("USB control listener poll failed: errno \(errno)") } + return + } + if readiness.revents & Int16(POLLNVAL) != 0 { + if !run.isStopping { log("USB control listener became invalid") } + return + } + if readiness.revents & Int16(POLLIN) == 0 { + if run.isStopping { return } + continue + } + while true { + let client = accept(descriptor, nil, nil) + if client < 0 { + if errno == EINTR { continue } + if errno == EAGAIN || errno == EWOULDBLOCK { break } + if !run.isStopping { log("USB control accept failed: errno \(errno)") } + return + } + guard UsbControlSocketIO.configureOwnedSocket(client, nonBlocking: true) else { + let code = errno + close(client) + log("USB control accepted socket setup failed: errno \(code)") + continue + } + if !run.admit(client) { close(client) } + } + } + } - public static func success(_ outcome: UsbAttachOutcome) -> UsbControlResponse { - UsbControlResponse(ok: true, port: outcome.port, vsockPort: outcome.vsockPort, deviceID: outcome.deviceID, speed: outcome.speed, error: nil) + private static func makeOwnedListener( + path: String, + expectedUID: uid_t, + beforeStaleEndpointUnlinkValidation: @Sendable () throws -> Void, + afterListenerBindValidation: @Sendable () throws -> Void + ) throws -> UsbControlOwnedListener { + try UsbControlSocketIO.validateAbsolutePath(path) + endpointMutationLock.lock() + defer { endpointMutationLock.unlock() } + let parent = try UsbControlSocketIO.privateParentIdentity( + forEndpointPath: path, + expectedUID: expectedUID + ) + + var existing = stat() + if lstat(path, &existing) == 0 { + guard existing.st_mode & mode_t(S_IFMT) == mode_t(S_IFSOCK), + existing.st_uid == expectedUID else { + throw UsbControlServerError.untrustedEndpoint(path) + } + let initialIdentity = UsbControlSocketIO.endpointIdentity(existing) + guard !UsbControlSocketIO.endpointAcceptsConnections(path) else { + throw UsbControlServerError.endpointInUse(path) + } + try beforeStaleEndpointUnlinkValidation() + guard UsbControlSocketIO.privateDirectoryIdentity(parent.path) == parent.identity, + UsbControlSocketIO.endpointIdentity(path) == initialIdentity else { + throw UsbControlServerError.untrustedEndpoint(path) + } + guard unlink(path) == 0 else { + throw UsbControlServerError.systemCall( + operation: "remove stale USB control socket", + code: errno + ) + } + } else if errno != ENOENT { + throw UsbControlServerError.systemCall( + operation: "inspect USB control socket", + code: errno + ) + } + + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { + throw UsbControlServerError.systemCall(operation: "create USB control socket", code: errno) + } + var publishedIdentity: UsbControlEndpointIdentity? + do { + guard UsbControlSocketIO.configureOwnedSocket(descriptor, nonBlocking: true) else { + throw UsbControlServerError.systemCall( + operation: "configure USB control listener", + code: errno + ) + } + var address = try UsbControlSocketIO.address(for: path) + let bound = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + guard bound == 0 else { + throw UsbControlServerError.systemCall(operation: "bind USB control socket", code: errno) + } + guard let identity = UsbControlSocketIO.endpointIdentity(path), + identity.owner == expectedUID else { + throw UsbControlServerError.untrustedEndpoint(path) + } + publishedIdentity = identity + try afterListenerBindValidation() + guard UsbControlSocketIO.privateDirectoryIdentity(parent.path) == parent.identity else { + throw UsbControlServerError.untrustedParentDirectory(parent.path) + } + guard chmod(path, 0o600) == 0 else { + throw UsbControlServerError.systemCall(operation: "chmod USB control socket", code: errno) + } + guard Darwin.listen(descriptor, Int32(maximumConfiguredSessions)) == 0 else { + throw UsbControlServerError.systemCall(operation: "listen on USB control socket", code: errno) + } + guard UsbControlSocketIO.privateDirectoryIdentity(parent.path) == parent.identity, + UsbControlSocketIO.endpointIdentity(path) == identity else { + throw UsbControlServerError.untrustedEndpoint(path) + } + return UsbControlOwnedListener( + descriptor: descriptor, + identity: identity, + parentIdentity: parent.identity + ) + } catch { + if let publishedIdentity { + retireEndpointIfOwned( + path: path, + identity: publishedIdentity, + parentIdentity: parent.identity + ) + } + close(descriptor) + throw error + } } - public static func ok() -> UsbControlResponse { UsbControlResponse(ok: true, port: nil, vsockPort: nil, deviceID: nil, speed: nil, error: nil) } - public static func failure(_ message: String) -> UsbControlResponse { UsbControlResponse(ok: false, port: nil, vsockPort: nil, deviceID: nil, speed: nil, error: message) } + private static func retireEndpointIfOwned( + path: String, + identity: UsbControlEndpointIdentity, + parentIdentity: UsbControlDirectoryIdentity + ) { + let parentPath = (path as NSString).deletingLastPathComponent + guard UsbControlSocketIO.privateDirectoryIdentity(parentPath) == parentIdentity, + UsbControlSocketIO.endpointIdentity(path) == identity else { return } + _ = unlink(path) + } } -/// Codec for the newline-delimited JSON control protocol. Pure and unit-tested; the socket layer only -/// moves bytes. -public enum UsbControlCodec { - public static func encodeRequest(_ request: UsbControlRequest) throws -> Data { - var data = try JSONEncoder().encode(request) - data.append(0x0a) - return data +public enum UsbControlServerError: Error, Equatable, CustomStringConvertible, Sendable { + case invalidPath(String) + case untrustedParentDirectory(String) + case untrustedEndpoint(String) + case endpointInUse(String) + case alreadyStarted + case frameTooLarge(limit: Int) + case incompleteFrame + case unexpectedTrailingBytes + case timedOut(operation: String) + case peerIdentity(expected: uid_t, actual: uid_t?) + case systemCall(operation: String, code: Int32) + + public var description: String { + switch self { + case .invalidPath(let path): return "invalid absolute USB control socket path: \(path)" + case .untrustedParentDirectory(let path): + return "USB control socket parent is not an owner-private stable directory: \(path)" + case .untrustedEndpoint(let path): return "refusing untrusted USB control endpoint: \(path)" + case .endpointInUse(let path): return "USB control endpoint is already active: \(path)" + case .alreadyStarted: return "USB control server is already started" + case .frameTooLarge(let limit): return "USB control frame exceeds \(limit) bytes" + case .incompleteFrame: return "USB control frame ended without a newline" + case .unexpectedTrailingBytes: return "USB control frame contains bytes after its newline" + case .timedOut(let operation): return "USB control \(operation) timed out" + case let .peerIdentity(expected, actual): + return "USB control peer UID mismatch (expected \(expected), got \(actual.map(String.init) ?? "unknown"))" + case let .systemCall(operation, code): + return "\(operation) failed: errno \(code) (\(String(cString: strerror(code))))" + } } +} + +private struct UsbControlEndpointIdentity: Equatable, Sendable { + let device: dev_t + let inode: ino_t + let generation: UInt32 + let birthSeconds: Int64 + let birthNanoseconds: Int64 + let owner: uid_t +} + +private struct UsbControlDirectoryIdentity: Equatable, Sendable { + let device: dev_t + let inode: ino_t + let generation: UInt32 + let birthSeconds: Int64 + let birthNanoseconds: Int64 + let owner: uid_t + let permissions: mode_t +} + +private struct UsbControlOwnedListener: Sendable { + let descriptor: Int32 + let identity: UsbControlEndpointIdentity + let parentIdentity: UsbControlDirectoryIdentity +} + +/// Serializes cancellation shutdown with the sole owner's final close. The descriptor value is +/// never used outside this lease for either operation, preventing shutdown-after-close FD reuse. +final class UsbControlDescriptorLifetime: @unchecked Sendable { + typealias DescriptorOperation = @Sendable (Int32) -> Void - public static func decodeRequest(_ line: Data) throws -> UsbControlRequest { - try JSONDecoder().decode(UsbControlRequest.self, from: line) + private let lock = NSLock() + private var descriptor: Int32? + private let shutdownOperation: DescriptorOperation + private let closeOperation: DescriptorOperation + + init( + descriptor: Int32, + shutdownOperation: @escaping DescriptorOperation = { + _ = Darwin.shutdown($0, SHUT_RDWR) + }, + closeOperation: @escaping DescriptorOperation = { _ = Darwin.close($0) } + ) { + self.descriptor = descriptor + self.shutdownOperation = shutdownOperation + self.closeOperation = closeOperation } - public static func encodeResponse(_ response: UsbControlResponse) throws -> Data { - var data = try JSONEncoder().encode(response) - data.append(0x0a) - return data + /// A worker may borrow the stable value while it remains the sole close owner. + var borrowedDescriptor: Int32? { + lock.lock(); defer { lock.unlock() } + return descriptor } - public static func decodeResponse(_ line: Data) throws -> UsbControlResponse { - try JSONDecoder().decode(UsbControlResponse.self, from: line) + func requestShutdown() { + lock.lock() + if let descriptor { shutdownOperation(descriptor) } + lock.unlock() } - public static func mode(from raw: String?) -> HostUsbOpenMode { - switch raw { - case "seize": return .seize - case "capture": return .capture - default: return .userAuthorized + func closeByOwner() { + lock.lock() + guard let descriptor else { + lock.unlock() + return } + self.descriptor = nil + closeOperation(descriptor) + lock.unlock() } } -/// Serves the `dory usb attach/detach` control protocol on a unix socket in the engine process (which -/// owns the device claim, the UsbipManager, and the guest agent channel). One request per connection. -public final class UsbControlServer: @unchecked Sendable { - private let path: String +private final class UsbControlServerRun: @unchecked Sendable { + let listener: UsbControlOwnedListener + + private let condition = NSCondition() + private let maximumSessions: Int private let handler: UsbControlHandler - private let queue = DispatchQueue(label: "dory.usb.control") - private var listenFD: Int32 = -1 + private let frameTimeout: TimeInterval + private let expectedPeerUID: uid_t + private let peerUIDResolver: @Sendable (Int32) -> uid_t? + private let log: @Sendable (String) -> Void + private let listenerDescriptorLifetime: UsbControlDescriptorLifetime + private var sessions: [UUID: UsbControlServerSession] = [:] + private var stopping = false + private var listenerFinished = false + private var rejectedSessions: UInt64 = 0 - public init(path: String, handler: UsbControlHandler) { - self.path = path + init( + listener: UsbControlOwnedListener, + maximumSessions: Int, + handler: UsbControlHandler, + frameTimeout: TimeInterval, + expectedPeerUID: uid_t, + peerUIDResolver: @escaping @Sendable (Int32) -> uid_t?, + log: @escaping @Sendable (String) -> Void + ) { + self.listener = listener + self.maximumSessions = maximumSessions self.handler = handler + self.frameTimeout = frameTimeout + self.expectedPeerUID = expectedPeerUID + self.peerUIDResolver = peerUIDResolver + self.log = log + self.listenerDescriptorLifetime = UsbControlDescriptorLifetime( + descriptor: listener.descriptor + ) } - public func start() throws { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { throw UsbControlServerError.socket("socket: errno \(errno)") } - unlink(path) - var address = sockaddr_un() - address.sun_family = sa_family_t(AF_UNIX) - Self.copyPath(path, into: &address) - let bound = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, socklen_t(MemoryLayout.size)) } - } - guard bound == 0 else { close(fd); throw UsbControlServerError.socket("bind \(path): errno \(errno)") } - guard listen(fd, 8) == 0 else { close(fd); throw UsbControlServerError.socket("listen: errno \(errno)") } - listenFD = fd - queue.async { [weak self] in self?.acceptLoop() } + var isStopping: Bool { + condition.lock(); defer { condition.unlock() } + return stopping } - public func stop() { - if listenFD >= 0 { close(listenFD); listenFD = -1 } - unlink(path) + var activeSessionCount: Int { + condition.lock(); defer { condition.unlock() } + return sessions.count } - private func acceptLoop() { - while true { - let client = accept(listenFD, nil, nil) - guard client >= 0 else { return } - handleClient(client) + var rejectedSessionCount: UInt64 { + condition.lock(); defer { condition.unlock() } + return rejectedSessions + } + + /// Returns false without taking ownership; the listener thread must close rejected descriptors. + func admit(_ descriptor: Int32) -> Bool { + condition.lock() + guard !stopping, sessions.count < maximumSessions else { + if rejectedSessions < UInt64.max { rejectedSessions += 1 } + condition.unlock() + return false } + let token = UUID() + let session = UsbControlServerSession( + descriptor: descriptor, + handler: handler, + frameTimeout: frameTimeout, + expectedPeerUID: expectedPeerUID, + peerUIDResolver: peerUIDResolver, + log: log, + completion: { [weak self] in self?.sessionFinished(token) } + ) + sessions[token] = session + condition.unlock() + Thread.detachNewThread { session.run() } + return true } - private func handleClient(_ fd: Int32) { - defer { close(fd) } - guard let line = Self.readLine(fd) else { return } - let response: UsbControlResponse - if let request = try? UsbControlCodec.decodeRequest(line) { - response = runHandler(request) - } else { - response = .failure("malformed control request") + func requestStop() { + condition.lock() + if stopping { + condition.unlock() + return } - if let data = try? UsbControlCodec.encodeResponse(response) { - _ = data.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, $0.count) } + stopping = true + let active = Array(sessions.values) + condition.broadcast() + condition.unlock() + + // Keep descriptors allocated; shutdown only wakes the threads that remain sole close owners. + listenerDescriptorLifetime.requestShutdown() + for session in active { session.requestStop() } + } + + func listenerOwnerDidFinish() { + condition.lock() + listenerDescriptorLifetime.closeByOwner() + listenerFinished = true + condition.broadcast() + condition.unlock() + } + + func waitUntilDrained(timeout: TimeInterval) -> Bool { + let deadline = ProcessInfo.processInfo.systemUptime + max(0, timeout) + condition.lock() + defer { condition.unlock() } + while !listenerFinished || !sessions.isEmpty { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { return false } + _ = condition.wait(until: Date().addingTimeInterval(min(remaining, 0.05))) } + return true } - private func runHandler(_ request: UsbControlRequest) -> UsbControlResponse { - let semaphore = DispatchSemaphore(value: 0) - let box = ResultBox() + private func sessionFinished(_ token: UUID) { + condition.lock() + sessions.removeValue(forKey: token) + condition.broadcast() + condition.unlock() + } +} + +private final class UsbControlServerSession: @unchecked Sendable { + private let lock = NSLock() + private let handler: UsbControlHandler + private let frameTimeout: TimeInterval + private let expectedPeerUID: uid_t + private let peerUIDResolver: @Sendable (Int32) -> uid_t? + private let log: @Sendable (String) -> Void + private let descriptorLifetime: UsbControlDescriptorLifetime + private var completion: (@Sendable () -> Void)? + private var started = false + private var stopRequested = false + private var finished = false + + init( + descriptor: Int32, + handler: UsbControlHandler, + frameTimeout: TimeInterval, + expectedPeerUID: uid_t, + peerUIDResolver: @escaping @Sendable (Int32) -> uid_t?, + log: @escaping @Sendable (String) -> Void, + completion: @escaping @Sendable () -> Void + ) { + self.descriptorLifetime = UsbControlDescriptorLifetime(descriptor: descriptor) + self.handler = handler + self.frameTimeout = frameTimeout + self.expectedPeerUID = expectedPeerUID + self.peerUIDResolver = peerUIDResolver + self.log = log + self.completion = completion + } + + func run() { + lock.lock() + guard !started, let descriptor = descriptorLifetime.borrowedDescriptor else { + lock.unlock() + return + } + started = true + let shouldRun = !stopRequested + lock.unlock() + guard shouldRun else { + finish() + return + } + defer { finish() } + + let actualUID = peerUIDResolver(descriptor) + guard actualUID == expectedPeerUID else { + log(UsbControlServerError.peerIdentity(expected: expectedPeerUID, actual: actualUID).description) + return + } + + let request: DoryUSBControlV1.Request + do { + let deadline = ProcessInfo.processInfo.systemUptime + frameTimeout + let frame = try UsbControlSocketIO.readFrame( + descriptor: descriptor, + deadline: deadline + ) + request = try DoryUSBControlV1.decodeRequest(frame) + } catch { + guard !isStopping else { return } + writeFailure( + "malformed control request: \(error)", + disposition: .rejected, + descriptor: descriptor + ) + return + } + + let result = UsbControlAsyncResult() let handler = self.handler - Task { - let response: UsbControlResponse + // Reserve the mutation while holding the same lock used by requestStop. If stop wins first, + // no new hardware/RPC operation starts; if this reservation wins, stop continues tracking + // the session until the exact result arrives. + lock.lock() + guard !stopRequested, !finished else { + lock.unlock() + return + } + let operation = Task { + let response: DoryUSBControlV1.Response do { - switch request.cmd { - case "attach": - let outcome = try await handler.attach(busID: request.busid, mode: UsbControlCodec.mode(from: request.mode)) - response = .success(outcome) - case "detach": - try await handler.detach(busID: request.busid) - response = .ok() - default: - response = .failure("unknown command \(request.cmd)") + switch request { + case let .attach(busID, mode): + response = .attachSuccess( + try await handler.attach( + busID: busID.rawValue, + mode: Self.hostOpenMode(mode) + ) + ) + case let .detach(busID): + try await handler.detach(busID: busID.rawValue) + response = .detachSuccess } } catch { - response = .failure("\(error)") + let disposition = (error as? UsbControlError)?.failureDisposition ?? .rejected + response = .failure( + disposition: disposition, + error: DoryUSBControlV1.sanitizedFailureMessage(String(describing: error)) + ) } - box.value = response - semaphore.signal() + result.complete(response) + } + lock.unlock() + + // Deliberately no server-side operation timeout: the injected guest/hardware operations are + // not generally cancellable. The session remains tracked until their exact result arrives. + let response = withExtendedLifetime(operation) { result.wait() } + lock.lock() + let shouldReply = !stopRequested + lock.unlock() + guard shouldReply else { return } + do { + var payload = try DoryUSBControlV1.encodeResponse(response) + payload.append(0x0a) + try UsbControlSocketIO.writeAll( + descriptor: descriptor, + bytes: payload, + deadline: ProcessInfo.processInfo.systemUptime + frameTimeout + ) + } catch { + if !isStopping { log("USB control response write failed: \(error)") } + } + } + + func requestStop() { + lock.lock() + guard !stopRequested, !finished else { + lock.unlock() + return } - semaphore.wait() // one control request per connection; the accept loop serializes them - return box.value ?? .failure("no result") + stopRequested = true + lock.unlock() + descriptorLifetime.requestShutdown() } - private final class ResultBox: @unchecked Sendable { - var value: UsbControlResponse? + private var isStopping: Bool { + lock.lock(); defer { lock.unlock() } + return stopRequested || finished } - private static func readLine(_ fd: Int32) -> Data? { - var data = Data() - var byte: UInt8 = 0 - while data.count < 8192 { - let n = Darwin.read(fd, &byte, 1) - guard n == 1 else { return data.isEmpty ? nil : data } - if byte == 0x0a { return data } - data.append(byte) + private func writeFailure( + _ message: String, + disposition: DoryUSBControlV1.FailureDisposition, + descriptor: Int32 + ) { + do { + var payload = try DoryUSBControlV1.encodeResponse(.failure( + disposition: disposition, + error: DoryUSBControlV1.sanitizedFailureMessage(message) + )) + payload.append(0x0a) + try UsbControlSocketIO.writeAll( + descriptor: descriptor, + bytes: payload, + deadline: ProcessInfo.processInfo.systemUptime + frameTimeout + ) + } catch { + log("USB control error response write failed: \(error)") } - return data } - private static func copyPath(_ path: String, into address: inout sockaddr_un) { - withUnsafeMutableBytes(of: &address.sun_path) { destination in - let bytes = [UInt8](path.utf8.prefix(destination.count - 1)) - destination.copyBytes(from: bytes) + private static func hostOpenMode(_ mode: DoryUSBControlV1.OpenMode) -> HostUsbOpenMode { + switch mode { + case .userAuthorized: .userAuthorized + case .seize: .seize + case .capture: .capture } } -} -public enum UsbControlServerError: Error, Equatable, Sendable { - case socket(String) + private func finish() { + let callback: (@Sendable () -> Void)? + lock.lock() + guard !finished else { + lock.unlock() + return + } + finished = true + callback = completion + completion = nil + descriptorLifetime.closeByOwner() + lock.unlock() + callback?() + } } -/// The guest agent's usb.attach/usb.detach reply. We only need to know the call succeeded, so the -/// fields (`attached`/`detached`, `busid`, `port`) are optional and unused. -public struct UsbAgentReply: Decodable, Sendable { - public var busid: String? - public var port: Int? +private final class UsbControlAsyncResult: @unchecked Sendable { + private let condition = NSCondition() + private var response: DoryUSBControlV1.Response? + + func complete(_ response: DoryUSBControlV1.Response) { + condition.lock() + guard self.response == nil else { + condition.unlock() + return + } + self.response = response + condition.broadcast() + condition.unlock() + } + + func wait() -> DoryUSBControlV1.Response { + condition.lock() + while true { + if let response { + condition.unlock() + return response + } + condition.wait() + } + } } -/// Client used by `dory-hv usb attach/detach`: connect to the engine's control socket, send one -/// request, read one response. -public enum UsbControlClient { - public static func send(_ request: UsbControlRequest, socketPath: String) throws -> UsbControlResponse { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { throw UsbControlServerError.socket("socket: errno \(errno)") } - defer { close(fd) } +enum UsbControlSocketIO { + static let maximumFrameBytes = DoryUSBControlV1.maximumFrameBytes + typealias ReadOperation = (Int32, UnsafeMutableRawPointer?, Int) -> Int + typealias WriteOperation = (Int32, UnsafeRawPointer?, Int) -> Int + + static var maximumSocketPathBytes: Int { + let address = sockaddr_un() + return MemoryLayout.size(ofValue: address.sun_path) - 1 + } + + static func validateAbsolutePath(_ path: String) throws { + let bytes = Array(path.utf8) + let standardized = (path as NSString).standardizingPath + guard path.hasPrefix("/"), path == standardized, + (path as NSString).lastPathComponent.isEmpty == false, + !bytes.isEmpty, !bytes.contains(0), + bytes.count <= maximumSocketPathBytes else { + throw UsbControlServerError.invalidPath(path) + } + } + + fileprivate static func privateParentIdentity( + forEndpointPath path: String, + expectedUID: uid_t + ) throws -> (path: String, identity: UsbControlDirectoryIdentity) { + let parentPath = (path as NSString).deletingLastPathComponent + guard let identity = privateDirectoryIdentity(parentPath), + identity.owner == expectedUID, + identity.permissions & 0o077 == 0, + identity.permissions & 0o300 == 0o300 else { + throw UsbControlServerError.untrustedParentDirectory(parentPath) + } + return (parentPath, identity) + } + + static func address(for path: String) throws -> sockaddr_un { + try validateAbsolutePath(path) var address = sockaddr_un() address.sun_family = sa_family_t(AF_UNIX) - UsbControlServer_copyPath(socketPath, into: &address) - let connected = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, socklen_t(MemoryLayout.size)) } - } - guard connected == 0 else { throw UsbControlServerError.socket("connect \(socketPath): errno \(errno) (is the engine running?)") } - let payload = try UsbControlCodec.encodeRequest(request) - _ = payload.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, $0.count) } - var response = Data() - var byte: UInt8 = 0 - while response.count < 8192 { - let n = Darwin.read(fd, &byte, 1) - guard n == 1 else { break } - if byte == 0x0a { break } - response.append(byte) - } - return try UsbControlCodec.decodeResponse(response) + let bytes = Array(path.utf8) + withUnsafeMutableBytes(of: &address.sun_path) { destination in + bytes.withUnsafeBytes { source in + destination.baseAddress!.copyMemory( + from: source.baseAddress!, + byteCount: bytes.count + ) + } + } + return address } -} -private func UsbControlServer_copyPath(_ path: String, into address: inout sockaddr_un) { - withUnsafeMutableBytes(of: &address.sun_path) { destination in - let bytes = [UInt8](path.utf8.prefix(destination.count - 1)) - destination.copyBytes(from: bytes) + static func configureOwnedSocket(_ descriptor: Int32, nonBlocking: Bool) -> Bool { + let descriptorFlags = fcntl(descriptor, F_GETFD) + guard descriptorFlags >= 0, + fcntl(descriptor, F_SETFD, descriptorFlags | FD_CLOEXEC) == 0 else { + return false + } + var noSigpipe: Int32 = 1 + guard setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size) + ) == 0 else { return false } + guard nonBlocking else { return true } + let statusFlags = fcntl(descriptor, F_GETFL) + return statusFlags >= 0 + && fcntl(descriptor, F_SETFL, statusFlags | O_NONBLOCK) == 0 + } + + static func peerUID(_ descriptor: Int32) -> uid_t? { + var uid: uid_t = 0 + var gid: gid_t = 0 + return getpeereid(descriptor, &uid, &gid) == 0 ? uid : nil + } + + fileprivate static func endpointIdentity(_ path: String) -> UsbControlEndpointIdentity? { + var info = stat() + guard lstat(path, &info) == 0, + info.st_mode & mode_t(S_IFMT) == mode_t(S_IFSOCK) else { return nil } + return endpointIdentity(info) + } + + fileprivate static func endpointIdentity(_ info: stat) -> UsbControlEndpointIdentity { + return UsbControlEndpointIdentity( + device: info.st_dev, + inode: info.st_ino, + generation: info.st_gen, + birthSeconds: Int64(info.st_birthtimespec.tv_sec), + birthNanoseconds: Int64(info.st_birthtimespec.tv_nsec), + owner: info.st_uid + ) + } + + fileprivate static func privateDirectoryIdentity( + _ path: String + ) -> UsbControlDirectoryIdentity? { + var info = stat() + guard lstat(path, &info) == 0, + info.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) else { return nil } + return UsbControlDirectoryIdentity( + device: info.st_dev, + inode: info.st_ino, + generation: info.st_gen, + birthSeconds: Int64(info.st_birthtimespec.tv_sec), + birthNanoseconds: Int64(info.st_birthtimespec.tv_nsec), + owner: info.st_uid, + permissions: info.st_mode & 0o777 + ) + } + + /// Fail safe: only ECONNREFUSED/ENOENT proves an owner-private socket is stale enough to remove. + static func endpointAcceptsConnections(_ path: String) -> Bool { + guard let address = try? address(for: path) else { return true } + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { return true } + defer { close(descriptor) } + guard configureOwnedSocket(descriptor, nonBlocking: true) else { return true } + var mutableAddress = address + let result = withUnsafePointer(to: &mutableAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + if result == 0 { return true } + let code = errno + if code == ECONNREFUSED || code == ENOENT { return false } + guard code == EINPROGRESS || code == EAGAIN else { return true } + let deadline = ProcessInfo.processInfo.systemUptime + 0.1 + do { + try wait( + descriptor: descriptor, + events: Int16(POLLOUT), + deadline: deadline, + operation: "probe endpoint" + ) + } catch { + return true + } + var socketError: Int32 = 0 + var length = socklen_t(MemoryLayout.size) + guard getsockopt( + descriptor, + SOL_SOCKET, + SO_ERROR, + &socketError, + &length + ) == 0 else { return true } + return socketError != ECONNREFUSED && socketError != ENOENT + } + + static func readFrame( + descriptor: Int32, + deadline: TimeInterval + ) throws -> Data { + try readFrame( + descriptor: descriptor, + deadline: deadline, + readOperation: { Darwin.read($0, $1, $2) } + ) + } + + static func readFrame( + descriptor: Int32, + deadline: TimeInterval, + readOperation: ReadOperation + ) throws -> Data { + var frame = Data() + var buffer = [UInt8](repeating: 0, count: 4 * 1024) + while true { + try requireTimeRemaining(deadline: deadline, operation: "request frame") + let remainingCapacity = maximumFrameBytes - frame.count + 1 + guard remainingCapacity > 0 else { + throw UsbControlServerError.frameTooLarge(limit: maximumFrameBytes) + } + let requested = min(buffer.count, remainingCapacity) + let count = buffer.withUnsafeMutableBytes { + readOperation(descriptor, $0.baseAddress, requested) + } + try requireTimeRemaining(deadline: deadline, operation: "request frame") + if count > 0 { + guard count <= requested else { + throw UsbControlServerError.systemCall(operation: "read USB control frame", code: EIO) + } + let bytes = buffer[0.. 0 { + guard count <= buffer.count - offset else { + throw UsbControlServerError.systemCall( + operation: "write USB control frame", + code: EIO + ) + } + offset += count + continue + } + if count == 0 { + throw UsbControlServerError.systemCall( + operation: "write USB control frame", + code: EPIPE + ) + } + let code = errno + if code == EINTR { continue } + if code == EAGAIN || code == EWOULDBLOCK { + try wait( + descriptor: descriptor, + events: Int16(POLLOUT), + deadline: deadline, + operation: "response frame" + ) + continue + } + throw UsbControlServerError.systemCall( + operation: "write USB control frame", + code: code + ) + } + } + } + + static func wait( + descriptor: Int32, + events: Int16, + deadline: TimeInterval, + operation: String + ) throws { + while true { + let remaining = try requireTimeRemaining(deadline: deadline, operation: operation) + var readiness = pollfd(fd: descriptor, events: events, revents: 0) + let milliseconds = max(1, min(50, Int32(ceil(remaining * 1_000)))) + let result = poll(&readiness, 1, milliseconds) + if result == 0 { continue } + if result < 0 { + if errno == EINTR { continue } + throw UsbControlServerError.systemCall(operation: "poll USB control socket", code: errno) + } + if readiness.revents & Int16(POLLNVAL) != 0 { + throw UsbControlServerError.systemCall(operation: "poll USB control socket", code: EBADF) + } + return + } + } + + @discardableResult + private static func requireTimeRemaining( + deadline: TimeInterval, + operation: String + ) throws -> TimeInterval { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { + throw UsbControlServerError.timedOut(operation: operation) + } + return remaining } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipBridge.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipBridge.swift index 07fb6201..003177fd 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipBridge.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipBridge.swift @@ -6,100 +6,347 @@ import Foundation enum UsbipCommandFraming { static let fixedHeaderByteCount = UsbipSubmitCommand.headerByteCount - static func outPayloadLength(_ header: [UInt8]) -> Int { - guard header.count >= fixedHeaderByteCount else { return 0 } - func be32(_ offset: Int) -> UInt32 { - (UInt32(header[offset]) << 24) | (UInt32(header[offset + 1]) << 16) - | (UInt32(header[offset + 2]) << 8) | UInt32(header[offset + 3]) + enum Frame: Equatable { + case submit(UsbipSubmitCommand.HeaderMetadata) + case unlink + } + + static func inspect(_ header: [UInt8]) throws -> Frame { + guard header.count == fixedHeaderByteCount else { + if header.count < fixedHeaderByteCount { throw UsbipProtocolError.shortFrame } + throw UsbipProtocolError.invalidFrameLength(expected: fixedHeaderByteCount, actual: header.count) + } + let basic = try UsbipHeaderBasic(decoding: header) + switch basic.command { + case .cmdSubmit: + return .submit(try UsbipSubmitCommand.inspectHeader(header)) + case .cmdUnlink: + _ = try UsbipUnlinkCommand(decoding: header) + return .unlink + case .retSubmit, .retUnlink: + throw UsbipProtocolError.unexpectedOperation(expected: .cmdSubmit, actual: basic.command) } - guard be32(0) == UsbipOperation.cmdSubmit.rawValue, - be32(12) == UsbipDirection.out.rawValue else { return 0 } - return Int(min(be32(24), UsbipSubmitCommand.maxTransferBytes)) } } /// Bridges one guest usbip vsock connection to one claimed host USB device. The guest agent dials /// `VsockPorts.usbip` and performs the OP_REQ_IMPORT handshake; this bridge answers via `UsbipServer`, /// then pumps USBIP_CMD_SUBMIT/UNLINK frames to the device and writes the replies back — until the -/// guest closes the connection (`isPeerClosed`), at which point `onClose` fires so the engine releases -/// the device. The serve loop runs on its own queue, never the vsock dispatch queue, because a host +/// guest closes the connection (`isPeerClosed`), at which point `onClose` fires so the manager releases +/// the connection and any import lease it owns. The serve loop runs on its own queue, never the vsock +/// dispatch queue, because a host /// device submit blocks on the transfer completing. public final class UsbipBridge: @unchecked Sendable { + private static let maximumHandshakeTimeout: TimeInterval = 30 + private static let maximumWriteTimeoutNanoseconds: UInt64 = 30_000_000_000 + private let connection: VsockConnection private let server: UsbipServer - private let onClose: () -> Void + private let stateLock = NSLock() private let queue: DispatchQueue + private let context = UsbipRequestContext() + private let handshakeTimeout: TimeInterval + private let writeTimeoutNanoseconds: UInt64 + private let authorizeImport: @Sendable (String) -> Bool + private let log: @Sendable (String) -> Void + private var onClose: (@Sendable () -> Void)? + private var started = false + private var stopRequested = false + private var finished = false /// Backed by a server that may export several claimed devices; the busID is read from the guest's /// OP_REQ_IMPORT frame, so one listener can serve whichever device the guest asked for. - public init(connection: VsockConnection, server: UsbipServer, label: String = "shared", onClose: @escaping () -> Void = {}) { + public init( + connection: VsockConnection, + server: UsbipServer, + label: String = "shared", + handshakeTimeout: TimeInterval = 5, + writeTimeoutNanoseconds: UInt64 = 5_000_000_000, + authorizeImport: @escaping @Sendable (String) -> Bool, + log: @escaping @Sendable (String) -> Void = { NSLog("%@", $0) }, + onClose: @escaping @Sendable () -> Void = {} + ) { + precondition( + handshakeTimeout.isFinite + && handshakeTimeout > 0 + && handshakeTimeout <= Self.maximumHandshakeTimeout + ) + precondition( + writeTimeoutNanoseconds > 0 + && writeTimeoutNanoseconds <= Self.maximumWriteTimeoutNanoseconds + ) self.connection = connection self.server = server self.onClose = onClose + self.handshakeTimeout = handshakeTimeout + self.writeTimeoutNanoseconds = writeTimeoutNanoseconds + self.authorizeImport = authorizeImport + self.log = log self.queue = DispatchQueue(label: "dory.usbip.bridge.\(label)") } /// Single-device convenience. - public convenience init(connection: VsockConnection, device: any UsbipExportedDevice, onClose: @escaping () -> Void = {}) { - self.init(connection: connection, server: UsbipServer(devices: [device]), label: device.descriptor.busID, onClose: onClose) + public convenience init( + connection: VsockConnection, + device: any UsbipExportedDevice, + handshakeTimeout: TimeInterval = 5, + log: @escaping @Sendable (String) -> Void = { NSLog("%@", $0) }, + onClose: @escaping @Sendable () -> Void = {} + ) { + let ownedBusID = device.descriptor.busID + self.init( + connection: connection, + server: UsbipServer(devices: [device]), + label: ownedBusID, + handshakeTimeout: handshakeTimeout, + authorizeImport: { $0 == ownedBusID }, + log: log, + onClose: onClose + ) } public func start() { - queue.async { self.serve() } + guard claimRun() else { return } + queue.async { self.runClaimed() } + } + + /// Wakes an idle import/command read. `VsockConnection.close` is idempotent and is the only + /// cancellation primitive needed; `finish` remains the sole completion owner. + public func requestStop() { + stateLock.lock() + guard !stopRequested, !finished else { + stateLock.unlock() + return + } + stopRequested = true + let finishWithoutRun = !started + stateLock.unlock() + connection.close() + if finishWithoutRun { finish(importedBusID: nil) } } /// Runs the serve loop synchronously; returns when the connection ends. Exposed for the loopback /// integration test to drive the bridge without a real queue/thread. public func serve() { - defer { - connection.close() - onClose() + guard claimRun() else { return } + runClaimed() + } + + private func claimRun() -> Bool { + stateLock.lock() + guard !started, !finished else { + stateLock.unlock() + return false } - guard let importFrame = readExact(UsbipImportRequest.byteCount), - let busID = (try? UsbipImportRequest(decoding: importFrame))?.busID, - let importReply = try? server.handleImport(importFrame) else { return } - write(importReply) + started = true + let canRun = !stopRequested + stateLock.unlock() + if !canRun { finish(importedBusID: nil) } + return canRun + } + + private func runClaimed() { + var importedBusID: String? + defer { finish(importedBusID: importedBusID) } + let importDeadline = ProcessInfo.processInfo.systemUptime + handshakeTimeout + guard let importFrame = readExact( + UsbipImportRequest.byteCount, + deadline: importDeadline + ) else { return } + let busID: String + let importReply: [UInt8] + do { + busID = try UsbipImportRequest(decoding: importFrame).busID + } catch { + log("USB/IP import request rejected: \(error)") + return + } + log("USB/IP import request received for \(busID)") + guard authorizeImport(busID) else { + log("USB/IP import authorization expired for \(busID)") + _ = write(UsbipImportReply(status: 1, device: nil).encoded()) + return + } + do { + importReply = try server.handleImport(importFrame) + } catch { + log("USB/IP import request rejected: \(error)") + return + } + guard write(importReply) else { return } + do { + guard try UsbipOperationHeader(decoding: importReply).status == 0 else { return } + } catch { + log("USB/IP generated an invalid import reply: \(error)") + return + } + importedBusID = busID + log("USB/IP import reply accepted for \(busID); awaiting Linux URBs") while true { - guard let header = readExact(UsbipCommandFraming.fixedHeaderByteCount) else { return } + guard let header = readExact( + UsbipCommandFraming.fixedHeaderByteCount, + deadline: nil + ) else { return } + let framing: UsbipCommandFraming.Frame + do { + framing = try UsbipCommandFraming.inspect(header) + } catch { + log("USB/IP command header rejected: \(error)") + return + } var frame = header - let extra = UsbipCommandFraming.outPayloadLength(header) - if extra > 0 { - guard let payload = readExact(extra) else { return } - frame += payload + switch framing { + case .submit(let metadata): + if metadata.isIsochronous { + // The fixed header is sufficient to reject isochronous URBs. Close afterwards: + // an OUT payload may already be on the stream and must never be reinterpreted + // as another command header. + let reply: [UInt8] + do { + reply = try server.handleURB(header, busID: busID, context: context) + } catch { + log("USB/IP isochronous rejection failed: \(error)") + return + } + _ = write(reply) + return + } + if metadata.outPayloadByteCount > 0 { + let totalCount = UsbipCommandFraming.fixedHeaderByteCount + + metadata.outPayloadByteCount + guard totalCount <= UsbipCommandFraming.fixedHeaderByteCount + + Int(UsbipSubmitCommand.maxTransferBytes) else { return } + frame = [UInt8](repeating: 0, count: totalCount) + frame.replaceSubrange(0.. Bool { + do { + try connection.write(bytes, timeoutNanoseconds: writeTimeoutNanoseconds) + return true + } catch { + if !shouldStop { log("USB/IP response write failed: \(error)") } + return false + } } /// Reads exactly `count` bytes, polling the non-blocking vsock connection with backoff. Returns /// nil on EOF (the peer closed with fewer than `count` bytes remaining). "No data yet" is a wait, /// not an error, so an idle device is never torn down — only a real peer close ends the loop. - private func readExact(_ count: Int) -> [UInt8]? { - guard count > 0 else { return [] } - var result = [UInt8]() - result.reserveCapacity(count) - var buffer = [UInt8](repeating: 0, count: count) - var pollInterval: useconds_t = 1_000 - let maxPollInterval: useconds_t = 16_000 - while result.count < count { - let read = (try? buffer.withUnsafeMutableBytes { - try connection.read(into: UnsafeMutableRawBufferPointer(rebasing: $0[0..<(count - result.count)])) - }) ?? 0 - if read == 0 { - if connection.isPeerClosed { return nil } - usleep(pollInterval) - pollInterval = min(pollInterval * 2, maxPollInterval) + private func readExact(_ count: Int, deadline: TimeInterval?) -> [UInt8]? { + guard count >= 0, + count <= UsbipCommandFraming.fixedHeaderByteCount + + Int(UsbipSubmitCommand.maxTransferBytes) else { + log("USB/IP refused an out-of-range frame allocation of \(count) bytes") + return nil + } + var result = [UInt8](repeating: 0, count: count) + guard fillExact(&result, range: 0.., + deadline: TimeInterval? + ) -> Bool { + guard range.lowerBound >= 0, + range.upperBound <= bytes.count else { return false } + var offset = range.lowerBound + while offset < range.upperBound { + if shouldStop { return false } + if let deadline, + ProcessInfo.processInfo.systemUptime >= deadline { + log("USB/IP import handshake timed out") + return false + } + let count: Int + do { + count = try bytes.withUnsafeMutableBytes { buffer in + try connection.read( + into: UnsafeMutableRawBufferPointer( + rebasing: buffer[offset..= deadline { + log("USB/IP import handshake timed out") + return false + } + guard count >= 0, count <= range.upperBound - offset else { + log("USB/IP stream returned an invalid read length of \(count)") + return false + } + if count > 0 { + offset += count continue } - result.append(contentsOf: buffer.prefix(read)) - pollInterval = 1_000 + if connection.isPeerClosed { return false } + let timeoutNanoseconds: UInt64? + if let deadline { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { + log("USB/IP import handshake timed out") + return false + } + timeoutNanoseconds = UInt64(min(remaining, 0.05) * 1_000_000_000) + } else { + timeoutNanoseconds = 50_000_000 + } + _ = connection.waitForReadable(timeoutNanoseconds: timeoutNanoseconds) } - return result + return true + } + + private var shouldStop: Bool { + stateLock.lock(); defer { stateLock.unlock() } + return stopRequested || finished + } + + private func finish(importedBusID: String?) { + let completion: (@Sendable () -> Void)? + stateLock.lock() + guard !finished else { + stateLock.unlock() + return + } + finished = true + completion = onClose + onClose = nil + stateLock.unlock() + + server.closeSession(context, busID: importedBusID) + connection.close() + completion?() + } + + deinit { + requestStop() } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipManager.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipManager.swift index c1c0b910..e481a8ae 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipManager.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipManager.swift @@ -1,54 +1,785 @@ +import DoryVMContracts import Foundation -/// Owns the engine's usbip listener and the set of currently-claimed host devices. `dory usb attach` -/// claims a device (via the control plane) and `register`s it here; the guest agent then dials -/// `VsockPorts.usbip`, and the accepted connection is served by a `UsbipBridge` backed by whatever -/// devices are registered — the guest's OP_REQ_IMPORT busID selects which one. Detach `unregister`s it. +public enum UsbipManagerError: Error, Equatable, Sendable { + case duplicateBusID(String) + case deviceCapacityReached(limit: Int) + case listenerAlreadyAttached + case invalidControlMutationLease + case controlMutationBusIDMismatch(expected: String, actual: String) + case claimLeaseMismatch(String) + case claimNotRegistered(String) + case stopped +} + +/// Result of crossing the VM-owner's terminal guest-execution boundary. Once the guest can no +/// longer execute, an uncertain vhci result no longer requires a guest detach RPC: destroying the +/// VM has established the detached guest state. If a host-side mutation is still admitted, the +/// manager retains itself and every claim until that mutation drains and terminal retirement runs. +public enum UsbipManagerGuestTerminationOutcome: Equatable, Sendable { + case completed + case authorityRetained(retainedClaimBusIDs: [String]) +} + +/// Exact authority for one host-device/guest-agent mutation. It binds the operation and bus ID as +/// well as the manager generation, so a lease can neither mutate another physical claim nor turn a +/// post-stop detach reconciliation into a new attach admission. +struct UsbipManagerControlMutationLease: Sendable, Equatable { + fileprivate let id: UUID + fileprivate let operation: DoryUSBControlV1.Operation + fileprivate let busID: String + fileprivate let authority: Authority + + fileprivate enum Authority: Sendable, Equatable { + case active(lifecycleGeneration: UUID, claimGeneration: UUID?) + case reconciliation(claimGeneration: UUID) + } +} + +/// Owns one guest-initiated USB/IP listener, every admitted bridge, and every claimed host device. +/// Listener registration is one-shot: replacing it in place would make teardown generation-unsafe. public final class UsbipManager: @unchecked Sendable { + private enum ListenerState { + case idle + case attaching + case attached(VirtioVsockListenerRegistration) + case stopped + } + + private enum ControlLifecycle { + case active + /// New mutations are closed while stop waits every mutation admitted by the prior epoch. + case quiescing + /// The data path is stopped. Only detach reconciliation for an explicitly uncertain claim + /// may be admitted. + case quiesced + } + + private enum ClaimDisposition: Equatable { + case provisional(attachMutationID: UUID) + case stable + case uncertain + } + + private struct DeviceRecord { + let device: any UsbipExportedDevice + let generation: UUID + var disposition: ClaimDisposition + } + + private enum BridgeAuthorization { + case awaitingImport + case imported(busID: String, deviceGeneration: UUID) + case invalidated + } + + private struct BridgeRecord { + let snapshotGenerations: [String: UUID] + let completion: DispatchGroup + var bridge: UsbipBridge? + var authorization: BridgeAuthorization + } + + private struct DeviceRetirement { + let busID: String + let record: DeviceRecord + let affectedBridges: [(bridge: UsbipBridge?, completion: DispatchGroup)] + } + private let lock = NSLock() - private var devices: [String: any UsbipExportedDevice] = [:] + private let stopLock = NSLock() + private let listenerAttachmentCompletion = DispatchGroup() + private let controlMutationCompletion = DispatchGroup() + private let bridgeCompletion = DispatchGroup() + private let deviceShutdownCompletion = DispatchGroup() + private let deviceShutdownQueue = DispatchQueue( + label: "dory.usbip.device-shutdown", + attributes: .concurrent + ) + private let terminalRetirementQueue = DispatchQueue( + label: "dory.usbip.terminal-retirement", + qos: .userInitiated + ) + private var devices: [String: DeviceRecord] = [:] + private var bridgeRecords: [UUID: BridgeRecord] = [:] + private var controlMutationLeases: [UUID: UsbipManagerControlMutationLease] = [:] + private var lifecycleGeneration = UUID() + private var listenerState: ListenerState = .idle + private var controlLifecycle: ControlLifecycle = .active + private var guestExecutionEnded = false + private var terminalRetirementScheduled = false private let vsockPort: UInt32 + private let maxActiveConnections: Int + private let maxClaimedDevices: Int + private let stopWaitLimit: TimeInterval + private let log: @Sendable (String) -> Void + private var rejectedConnections: UInt64 = 0 - public init(vsockPort: UInt32 = VsockPorts.usbip) { + public init( + vsockPort: UInt32 = VsockPorts.usbip, + maxActiveConnections: Int = 8, + maxClaimedDevices: Int = 32, + stopWaitLimit: TimeInterval = 5, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { + precondition((1...64).contains(maxActiveConnections)) + precondition((1...256).contains(maxClaimedDevices)) + precondition(stopWaitLimit.isFinite && stopWaitLimit > 0 && stopWaitLimit <= 30) self.vsockPort = vsockPort + self.maxActiveConnections = maxActiveConnections + self.maxClaimedDevices = maxClaimedDevices + self.stopWaitLimit = stopWaitLimit + self.log = log } public var port: UInt32 { vsockPort } - /// Registers the listener on the engine's vsock so guest usbip dials are served on their own - /// bridge queue (never the vsock dispatch queue). - public func attachListener(to vsock: VirtioVsock) { - vsock.listen(port: vsockPort) { [weak self] connection in - guard let self else { connection.close(); return } - let exported = self.exportedDevices() - guard !exported.isEmpty else { connection.close(); return } - UsbipBridge(connection: connection, server: UsbipServer(devices: exported)).start() + /// Registers exactly one listener generation. The retained token is closed by `stop`/deinit; + /// a duplicate call never replaces the active handler. The in-progress registration is itself + /// tracked so stop cannot return while a newly created token is still able to publish. + public func attachListener(to vsock: VirtioVsock) throws { + lock.lock() + switch listenerState { + case .idle: + listenerState = .attaching + listenerAttachmentCompletion.enter() + case .attaching, .attached: + lock.unlock() + throw UsbipManagerError.listenerAlreadyAttached + case .stopped: + lock.unlock() + throw UsbipManagerError.stopped + } + lock.unlock() + defer { listenerAttachmentCompletion.leave() } + + let registration: VirtioVsockListenerRegistration + do { + registration = try vsock.registerServiceListener( + port: vsockPort, + service: .usbip + ) { [weak self] connection in + guard let self else { + connection.close() + return + } + self.accept(connection) + } + } catch { + lock.lock() + if case .attaching = listenerState { listenerState = .idle } + lock.unlock() + throw error } + + lock.lock() + guard case .attaching = listenerState else { + let stopped: Bool + if case .stopped = listenerState { stopped = true } else { stopped = false } + lock.unlock() + registration.close() + throw stopped ? UsbipManagerError.stopped : UsbipManagerError.listenerAlreadyAttached + } + listenerState = .attached(registration) + lock.unlock() } - public func register(_ device: any UsbipExportedDevice) { - lock.lock(); defer { lock.unlock() } - devices[device.descriptor.busID] = device + /// Publishes the physical claim acquired by one exact attach mutation. Every rejection closes + /// the passed device, including stale/cross-bus authority, so a caller cannot accidentally leak + /// a host claim after the manager refuses ownership. + func register( + _ device: any UsbipExportedDevice, + under lease: UsbipManagerControlMutationLease + ) throws { + let busID = device.descriptor.busID + let rejection: UsbipManagerError? + lock.lock() + if controlMutationLeases[lease.id] != lease || !controlMutationIsCurrentLocked(lease) { + rejection = .invalidControlMutationLease + } else if lease.operation != .attach { + rejection = .claimLeaseMismatch(lease.busID) + } else if lease.busID != busID { + rejection = .controlMutationBusIDMismatch(expected: lease.busID, actual: busID) + } else if case .stopped = listenerState { + rejection = .stopped + } else if devices[busID] != nil { + rejection = .duplicateBusID(busID) + } else if devices.count >= maxClaimedDevices { + rejection = .deviceCapacityReached(limit: maxClaimedDevices) + } else { + devices[busID] = DeviceRecord( + device: device, + generation: UUID(), + disposition: .provisional(attachMutationID: lease.id) + ) + rejection = nil + } + lock.unlock() + if let rejection { + device.shutdown() + throw rejection + } } + /// Removes only the claim generation authorized by this exact attach-compensation or detach + /// lease. The bus ID comes from the sealed lease, preventing cross-bus cleanup. @discardableResult - public func unregister(busID: String) -> (any UsbipExportedDevice)? { + func unregisterClaim( + under lease: UsbipManagerControlMutationLease + ) throws -> (any UsbipExportedDevice) { + let retirement: DeviceRetirement + lock.lock() + do { + try validateClaimAuthorityLocked(lease) + guard let current = devices[lease.busID] else { + throw UsbipManagerError.claimNotRegistered(lease.busID) + } + try validateClaimGenerationLocked(current, for: lease) + retirement = removeDeviceLocked(busID: lease.busID, expectedGeneration: current.generation) + } catch { + lock.unlock() + throw error + } + lock.unlock() + retireSynchronously(retirement) + return retirement.record.device + } + + /// Marks a still-owned claim as outcome-unknown before the handler publishes that uncertainty. + /// Stop preserves these exact claim generations and only a later detach lease may retire them. + func preserveClaimForReconciliation( + under lease: UsbipManagerControlMutationLease + ) throws { lock.lock(); defer { lock.unlock() } - return devices.removeValue(forKey: busID) + try validateClaimAuthorityLocked(lease) + guard var current = devices[lease.busID] else { + throw UsbipManagerError.claimNotRegistered(lease.busID) + } + try validateClaimGenerationLocked(current, for: lease) + current.disposition = .uncertain + devices[lease.busID] = current } - public func exportedDevice(busID: String) -> (any UsbipExportedDevice)? { + func exportedDevice(busID: String) -> (any UsbipExportedDevice)? { lock.lock(); defer { lock.unlock() } - return devices[busID] + return devices[busID]?.device } - public func exportedDevices() -> [any UsbipExportedDevice] { + func exportedDevices() -> [any UsbipExportedDevice] { lock.lock(); defer { lock.unlock() } - return Array(devices.values) + return devices.values.map(\.device) } - public var claimedBusIDs: [String] { + var claimedBusIDs: [String] { lock.lock(); defer { lock.unlock() } return devices.keys.sorted() } + + var activeConnectionCount: Int { + lock.lock(); defer { lock.unlock() } + return bridgeRecords.count + } + + var rejectedConnectionCount: UInt64 { + lock.lock(); defer { lock.unlock() } + return rejectedConnections + } + + var isStopped: Bool { + lock.lock(); defer { lock.unlock() } + if case .stopped = listenerState { return true } + return false + } + + /// Admits an operation in the active epoch, or an exact detach reconciliation for an uncertain + /// claim after data-path quiescence. A stopped manager never admits a new attach. + func beginControlMutation( + operation: DoryUSBControlV1.Operation, + busID: String + ) throws -> UsbipManagerControlMutationLease { + lock.lock() + guard !guestExecutionEnded else { + lock.unlock() + throw UsbipManagerError.stopped + } + let authority: UsbipManagerControlMutationLease.Authority + switch controlLifecycle { + case .active: + authority = .active( + lifecycleGeneration: lifecycleGeneration, + claimGeneration: devices[busID]?.generation + ) + case .quiescing: + lock.unlock() + throw UsbipManagerError.stopped + case .quiesced: + guard operation == .detach, + let record = devices[busID], + record.disposition == .uncertain else { + lock.unlock() + throw UsbipManagerError.stopped + } + authority = .reconciliation(claimGeneration: record.generation) + } + let lease = UsbipManagerControlMutationLease( + id: UUID(), + operation: operation, + busID: busID, + authority: authority + ) + controlMutationLeases[lease.id] = lease + controlMutationCompletion.enter() + lock.unlock() + return lease + } + + func isControlMutationCurrent(_ lease: UsbipManagerControlMutationLease) -> Bool { + lock.lock(); defer { lock.unlock() } + return controlMutationIsCurrentLocked(lease) + } + + /// Linearizes an attach commit against stop and makes its claim provably stable in that same + /// critical section. Stop can therefore distinguish commit-before-stop from an invalidated + /// attach that still requires compensation. + func withCurrentControlMutation( + _ lease: UsbipManagerControlMutationLease, + _ body: () -> Result + ) -> Result? { + lock.lock(); defer { lock.unlock() } + guard controlMutationIsCurrentLocked(lease) else { return nil } + let result = body() + if lease.operation == .attach, + var record = devices[lease.busID], + record.disposition == .provisional(attachMutationID: lease.id) { + record.disposition = .stable + devices[lease.busID] = record + } + return result + } + + func finishControlMutation(_ lease: UsbipManagerControlMutationLease) { + lock.lock() + let owned = controlMutationLeases.removeValue(forKey: lease.id) == lease + // Fail closed if an attach exits without either committing, unregistering, or explicitly + // publishing uncertainty. Retaining authority is safer than releasing a claim whose + // guest-side outcome was never established. + if owned, + lease.operation == .attach, + var record = devices[lease.busID], + record.disposition == .provisional(attachMutationID: lease.id) { + record.disposition = .uncertain + devices[lease.busID] = record + } + lock.unlock() + precondition(owned, "USB/IP control mutation lease finished more than once") + controlMutationCompletion.leave() + } + + /// Executes one serialized quiescence transaction. Admission closes and the active generation + /// is invalidated before waiting admitted mutations. Only after they finish are stable claims + /// retired; outcome-unknown claims remain registered and make the result false until an exact + /// later detach reconciliation removes them. + @discardableResult + public func stop(timeout: TimeInterval? = nil) -> Bool { + quiesce(timeout: timeout, retireAllClaimsAfterGuestTermination: false) + } + + private func quiesce( + timeout: TimeInterval?, + retireAllClaimsAfterGuestTermination: Bool + ) -> Bool { + stopLock.lock() + defer { stopLock.unlock() } + let requested = timeout ?? stopWaitLimit + let bounded = requested.isFinite ? min(max(0, requested), stopWaitLimit) : stopWaitLimit + let deadline = ProcessInfo.processInfo.systemUptime + bounded + let registration: VirtioVsockListenerRegistration? + let activeBridges: [UsbipBridge] + + lock.lock() + switch controlLifecycle { + case .active: + controlLifecycle = .quiescing + lifecycleGeneration = UUID() + if case .attached(let current) = listenerState { registration = current } + else { registration = nil } + listenerState = .stopped + activeBridges = bridgeRecords.values.compactMap(\.bridge) + for token in Array(bridgeRecords.keys) { + bridgeRecords[token]?.authorization = .invalidated + } + case .quiescing: + registration = nil + activeBridges = bridgeRecords.values.compactMap(\.bridge) + for token in Array(bridgeRecords.keys) { + bridgeRecords[token]?.authorization = .invalidated + } + case .quiesced: + // Temporarily close reconciliation admission so the mutation group has a stable epoch. + controlLifecycle = .quiescing + registration = nil + activeBridges = bridgeRecords.values.compactMap(\.bridge) + for token in Array(bridgeRecords.keys) { + bridgeRecords[token]?.authorization = .invalidated + } + } + lock.unlock() + + registration?.close() + for bridge in activeBridges { bridge.requestStop() } + + let listenerDrained = wait(listenerAttachmentCompletion, until: deadline) + let mutationsDrained = wait(controlMutationCompletion, until: deadline) + let bridgesDrained = wait(bridgeCompletion, until: deadline) + var safeRetirements: [DeviceRetirement] = [] + let unresolvedBusIDs: [String] + lock.lock() + if mutationsDrained { + // A provisional record after the group drains is an internal invariant breach. Preserve + // it as uncertain instead of silently releasing physical authority. + for busID in Array(devices.keys) { + guard var record = devices[busID] else { continue } + if case .provisional = record.disposition { + record.disposition = .uncertain + devices[busID] = record + } + } + let safeClaims = devices.compactMap { busID, record in + if retireAllClaimsAfterGuestTermination || record.disposition == .stable { + return (busID, record.generation) + } + return nil + } + safeRetirements.reserveCapacity(safeClaims.count) + for (busID, generation) in safeClaims { + safeRetirements.append( + removeDeviceLocked(busID: busID, expectedGeneration: generation) + ) + } + } + unresolvedBusIDs = devices.keys.sorted() + controlLifecycle = .quiesced + lock.unlock() + + let shutdownCompletion = deviceShutdownCompletion + for retirement in safeRetirements { + for affected in retirement.affectedBridges { affected.bridge?.requestStop() } + deviceShutdownQueue.async { + retirement.record.device.shutdown() + shutdownCompletion.leave() + } + } + let devicesDrained = wait(deviceShutdownCompletion, until: deadline) + let drained = listenerDrained + && mutationsDrained + && bridgesDrained + && devicesDrained + && unresolvedBusIDs.isEmpty + if !drained { + if !unresolvedBusIDs.isEmpty { + log("USB/IP quiescence retained outcome-unknown claims pending detach: \(unresolvedBusIDs.joined(separator: ", "))") + } else { + log("USB/IP teardown did not drain within \(bounded) seconds") + } + } + return drained + } + + /// Crosses the owner-proven boundary where the VM has either never started or `Machine.run()` + /// has returned. Guest execution can no longer retain a vhci attachment, so outcome-unknown + /// claims are now safe to retire without another guest RPC. A bounded initial quiescence keeps + /// normal shutdown responsive. If an admitted host mutation or terminal drain outlives it, one + /// self-retaining worker completes that exact retirement; releasing the external owner cannot + /// release a physical claim underneath the mutation. + public func stopAfterGuestExecutionEnded( + timeout: TimeInterval? = nil + ) -> UsbipManagerGuestTerminationOutcome { + lock.lock() + guestExecutionEnded = true + lock.unlock() + + if quiesce(timeout: timeout, retireAllClaimsAfterGuestTermination: true) { + return .completed + } + + let retainedClaimBusIDs: [String] + let scheduleRetirement: Bool + lock.lock() + retainedClaimBusIDs = devices.keys.sorted() + if terminalRetirementScheduled { + scheduleRetirement = false + } else { + terminalRetirementScheduled = true + scheduleRetirement = true + } + lock.unlock() + + if scheduleRetirement { + terminalRetirementQueue.async { [self] in + completeTerminalRetirement() + } + } + return .authorityRetained(retainedClaimBusIDs: retainedClaimBusIDs) + } + + deinit { + _ = stop() + } + + private func accept(_ connection: VsockConnection) { + let token = UUID() + let exported: [any UsbipExportedDevice] + lock.lock() + guard case .attached = listenerState, + bridgeRecords.count < maxActiveConnections else { + incrementRejectedConnectionsLocked() + lock.unlock() + connection.close() + return + } + guard !devices.isEmpty else { + incrementRejectedConnectionsLocked() + lock.unlock() + connection.close() + return + } + exported = devices.values.map(\.device) + let snapshotGenerations = devices.mapValues(\.generation) + let completion = DispatchGroup() + completion.enter() + bridgeCompletion.enter() + bridgeRecords[token] = BridgeRecord( + snapshotGenerations: snapshotGenerations, + completion: completion, + bridge: nil, + authorization: .awaitingImport + ) + lock.unlock() + log("USB/IP guest connection accepted with \(exported.count) exported device(s)") + + let bridge = UsbipBridge( + connection: connection, + server: UsbipServer(devices: exported), + authorizeImport: { [weak self] busID in + self?.authorizeImport(busID: busID, bridgeToken: token) ?? false + }, + log: log, + onClose: { [weak self] in self?.bridgeFinished(token: token) } + ) + + lock.lock() + let shouldStart: Bool + if var record = bridgeRecords[token] { + record.bridge = bridge + bridgeRecords[token] = record + if case .attached = listenerState, + case .awaitingImport = record.authorization { + shouldStart = true + } else { + shouldStart = false + } + } else { + shouldStart = false + } + lock.unlock() + + if let ownedConnection = connection as? ServiceOwnedVsockConnection, + !ownedConnection.replaceServiceStopAction({ bridge.requestStop() }) { + bridge.requestStop() + } + + if shouldStart { + log("USB/IP bridge started") + bridge.start() + } + else { bridge.requestStop() } + } + + private func authorizeImport(busID: String, bridgeToken: UUID) -> Bool { + lock.lock(); defer { lock.unlock() } + guard case .attached = listenerState, + var bridge = bridgeRecords[bridgeToken], + case .awaitingImport = bridge.authorization, + let expectedGeneration = bridge.snapshotGenerations[busID], + devices[busID]?.generation == expectedGeneration, + !bridgeRecords.contains(where: { token, record in + guard token != bridgeToken else { return false } + if case let .imported(importedBusID, importedGeneration) = record.authorization { + return importedBusID == busID + && importedGeneration == expectedGeneration + } + return false + }) else { + return false + } + bridge.authorization = .imported( + busID: busID, + deviceGeneration: expectedGeneration + ) + bridgeRecords[bridgeToken] = bridge + log("USB/IP import authorized for \(busID)") + return true + } + + private func bridgeFinished(token: UUID) { + let completion: DispatchGroup? + lock.lock() + completion = bridgeRecords.removeValue(forKey: token)?.completion + lock.unlock() + guard let completion else { return } + completion.leave() + bridgeCompletion.leave() + } + + private func controlMutationIsCurrentLocked( + _ lease: UsbipManagerControlMutationLease + ) -> Bool { + guard controlMutationLeases[lease.id] == lease else { return false } + switch lease.authority { + case .active(let admittedGeneration, _): + guard case .active = controlLifecycle else { return false } + return lifecycleGeneration == admittedGeneration + case .reconciliation(let claimGeneration): + guard lease.operation == .detach, + let record = devices[lease.busID], + record.generation == claimGeneration, + record.disposition == .uncertain else { return false } + switch controlLifecycle { + case .active: return false + case .quiescing, .quiesced: return true + } + } + } + + private func validateClaimAuthorityLocked( + _ lease: UsbipManagerControlMutationLease + ) throws { + guard controlMutationLeases[lease.id] == lease else { + throw UsbipManagerError.invalidControlMutationLease + } + } + + private func validateClaimGenerationLocked( + _ record: DeviceRecord, + for lease: UsbipManagerControlMutationLease + ) throws { + switch lease.operation { + case .attach: + guard record.disposition == .provisional(attachMutationID: lease.id) else { + throw UsbipManagerError.claimLeaseMismatch(lease.busID) + } + case .detach: + let expectedGeneration: UUID? + switch lease.authority { + case .active(_, let claimGeneration): expectedGeneration = claimGeneration + case .reconciliation(let claimGeneration): expectedGeneration = claimGeneration + } + guard expectedGeneration == record.generation else { + throw UsbipManagerError.claimLeaseMismatch(lease.busID) + } + if case .provisional = record.disposition { + throw UsbipManagerError.claimLeaseMismatch(lease.busID) + } + } + } + + /// Removes the live registry generation while holding `lock`, invalidates every bridge whose + /// immutable snapshot retained it, and transfers shutdown ownership to a retirement object. + private func removeDeviceLocked( + busID: String, + expectedGeneration: UUID + ) -> DeviceRetirement { + guard let current = devices[busID], current.generation == expectedGeneration else { + preconditionFailure("USB/IP claim generation changed during serialized retirement") + } + devices.removeValue(forKey: busID) + deviceShutdownCompletion.enter() + let affectedTokens = bridgeRecords.compactMap { token, bridge -> UUID? in + switch bridge.authorization { + case .awaitingImport: + return bridge.snapshotGenerations[busID] == current.generation ? token : nil + case let .imported(importedBusID, generation): + return importedBusID == busID && generation == current.generation ? token : nil + case .invalidated: + return nil + } + } + var affected: [(bridge: UsbipBridge?, completion: DispatchGroup)] = [] + affected.reserveCapacity(affectedTokens.count) + for token in affectedTokens { + guard var bridge = bridgeRecords[token] else { continue } + bridge.authorization = .invalidated + bridgeRecords[token] = bridge + affected.append((bridge.bridge, bridge.completion)) + } + return DeviceRetirement( + busID: busID, + record: current, + affectedBridges: affected + ) + } + + private func retireSynchronously(_ retirement: DeviceRetirement) { + for affected in retirement.affectedBridges { affected.bridge?.requestStop() } + // Keep the claim strongly held while terminal abort/drain runs. HostUsbDevice bounds this + // wait and preserves late-completion object lifetime if the framework misses its deadline. + retirement.record.device.shutdown() + deviceShutdownCompletion.leave() + let deadline = ProcessInfo.processInfo.systemUptime + stopWaitLimit + let drained = retirement.affectedBridges.allSatisfy { + wait($0.completion, until: deadline) + } + if !drained { + log("USB/IP connections for \(retirement.busID) did not drain within \(stopWaitLimit) seconds") + } + } + + /// Runs only after the guest-execution boundary has permanently closed admission. This wait is + /// intentionally not timed out: abandoning the worker would abandon physical claim authority. + /// All production control RPCs and host requests have their own finite deadlines; an internal + /// invariant failure therefore leaks authority fail-closed instead of releasing it unsafely. + private func completeTerminalRetirement() { + stopLock.lock() + defer { stopLock.unlock() } + + listenerAttachmentCompletion.wait() + controlMutationCompletion.wait() + + let retirements: [DeviceRetirement] + let activeBridges: [UsbipBridge] + lock.lock() + precondition(guestExecutionEnded) + activeBridges = bridgeRecords.values.compactMap(\.bridge) + for token in Array(bridgeRecords.keys) { + bridgeRecords[token]?.authorization = .invalidated + } + let liveClaimGenerations = devices.map { ($0.key, $0.value.generation) } + retirements = liveClaimGenerations.map { busID, generation in + removeDeviceLocked(busID: busID, expectedGeneration: generation) + } + controlLifecycle = .quiesced + lock.unlock() + + for bridge in activeBridges { bridge.requestStop() } + for retirement in retirements { + retirement.record.device.shutdown() + deviceShutdownCompletion.leave() + } + + bridgeCompletion.wait() + deviceShutdownCompletion.wait() + log("USB/IP terminal guest boundary retired all host claim authority") + } + + private func incrementRejectedConnectionsLocked() { + if rejectedConnections < UInt64.max { rejectedConnections += 1 } + } + + private func wait(_ group: DispatchGroup, until deadline: TimeInterval) -> Bool { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { + return group.wait(timeout: .now()) == .success + } + return group.wait(timeout: .now() + remaining) == .success + } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipProtocol.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipProtocol.swift index bbe89ad5..084bc679 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipProtocol.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipProtocol.swift @@ -2,8 +2,27 @@ import Foundation public enum UsbipProtocolError: Error, Equatable { case shortFrame + case invalidFrameLength(expected: Int, actual: Int) case invalidString + case invalidVersion(UInt16) + case unexpectedOpCode(UInt16) + case nonzeroOperationStatus(UInt32) + case unknownOperation(UInt32) + case unknownDirection(UInt32) + case unexpectedOperation(expected: UsbipOperation, actual: UsbipOperation) + case invalidEndpoint(UInt32) + case invalidDeviceID(UInt32) + case unexpectedDeviceID(expected: UInt32, actual: UInt32) + case invalidSequenceNumber(UInt32) + case invalidStartFrame(UInt32) + case invalidInterval(UInt32) + case invalidSetup + case nonzeroReservedField + case unsupportedIsochronous(UInt32) case transferBufferTooLarge(UInt32) + case unknownTransferFlags(UInt32) + case transferDirectionFlagMismatch(flags: UInt32, direction: UsbipDirection) + case unsupportedTransferFlags(UInt32) } public enum UsbipOperation: UInt32, Sendable { @@ -18,6 +37,50 @@ public enum UsbipDirection: UInt32, Sendable { case `in` = 1 } +/// Stable USB/IP UAPI flag values. Allocation/DMA flags describe the sending kernel's buffer +/// bookkeeping and have no remote semantic on Dory's copied buffers; they are accepted explicitly. +/// Flags whose wire-visible behavior Dory cannot reproduce are rejected by `inspectHeader`. +public enum UsbipTransferFlag { + public static let shortNotOK: UInt32 = 0x0000_0001 + public static let isoAsSoonAsPossible: UInt32 = 0x0000_0002 + public static let noTransferDMAMap: UInt32 = 0x0000_0004 + public static let zeroPacket: UInt32 = 0x0000_0040 + public static let noInterrupt: UInt32 = 0x0000_0080 + public static let freeBuffer: UInt32 = 0x0000_0100 + public static let directionIn: UInt32 = 0x0000_0200 + public static let dmaMapSingle: UInt32 = 0x0001_0000 + public static let dmaMapPage: UInt32 = 0x0002_0000 + public static let dmaMapScatterGather: UInt32 = 0x0004_0000 + public static let mapLocal: UInt32 = 0x0008_0000 + public static let setupMapSingle: UInt32 = 0x0010_0000 + public static let setupMapLocal: UInt32 = 0x0020_0000 + public static let dmaScatterGatherCombined: UInt32 = 0x0040_0000 + public static let alignedTemporaryBuffer: UInt32 = 0x0080_0000 + + /// Safe to ignore after the stream copied setup/data into Dory-owned memory. + public static let senderMemoryManagement: UInt32 = noTransferDMAMap + | freeBuffer + | dmaMapSingle + | dmaMapPage + | dmaMapScatterGather + | mapLocal + | setupMapSingle + | setupMapLocal + | dmaScatterGatherCombined + | alignedTemporaryBuffer + + /// A host-controller interrupt scheduling hint; Dory completes synchronously and preserves the + /// completion result, so accepting it does not change guest-visible transfer semantics. + public static let senderSchedulingHints: UInt32 = noInterrupt + + public static let known: UInt32 = shortNotOK + | isoAsSoonAsPossible + | senderMemoryManagement + | zeroPacket + | senderSchedulingHints + | directionIn +} + public enum UsbipOpCode: UInt16, Sendable { case reqImport = 0x8003 case repImport = 0x0003 @@ -102,7 +165,7 @@ public struct UsbipDeviceDescriptor: Codable, Equatable, Sendable { } public init(decoding bytes: [UInt8]) throws { - guard bytes.count >= Self.byteCount else { throw UsbipProtocolError.shortFrame } + try bytes.requireExactCount(Self.byteCount) self.init( path: try bytes.cString(at: 0, length: 256), busID: try bytes.cString(at: 256, length: 32), @@ -151,10 +214,20 @@ public struct UsbipImportRequest: Equatable, Sendable { } public init(decoding bytes: [UInt8]) throws { - guard bytes.count >= Self.byteCount else { throw UsbipProtocolError.shortFrame } + try bytes.requireExactCount(Self.byteCount) let header = try UsbipOperationHeader(decoding: bytes) - guard header.code == UsbipOpCode.reqImport.rawValue else { throw UsbipProtocolError.shortFrame } - self.init(busID: try bytes.cString(at: 8, length: 32)) + guard header.version == UsbipOperationHeader.version else { + throw UsbipProtocolError.invalidVersion(header.version) + } + guard header.code == UsbipOpCode.reqImport.rawValue else { + throw UsbipProtocolError.unexpectedOpCode(header.code) + } + guard header.status == 0 else { + throw UsbipProtocolError.nonzeroOperationStatus(header.status) + } + let busID = try bytes.cString(at: 8, length: 32) + guard Self.isValidBusID(busID) else { throw UsbipProtocolError.invalidString } + self.init(busID: busID) } public func encoded() -> [UInt8] { @@ -162,6 +235,16 @@ public struct UsbipImportRequest: Equatable, Sendable { bytes.appendCString(busID, width: 32) return bytes } + + /// Canonical USB/IP bus-ID grammar shared by the guest import and local control boundaries. + static func isValidBusID(_ value: String) -> Bool { + guard !value.isEmpty, value.utf8.count < 32 else { return false } + return value.utf8.allSatisfy { + ($0 >= 0x30 && $0 <= 0x39) || ($0 >= 0x41 && $0 <= 0x5a) + || ($0 >= 0x61 && $0 <= 0x7a) || $0 == 0x2d || $0 == 0x2e + || $0 == 0x3a || $0 == 0x5f + } + } } public struct UsbipImportReply: Equatable, Sendable { @@ -201,12 +284,36 @@ public struct UsbipHeaderBasic: Equatable, Sendable { public init(decoding bytes: [UInt8]) throws { guard bytes.count >= Self.byteCount else { throw UsbipProtocolError.shortFrame } + let rawOperation = bytes.beUInt32(at: 0) + guard let operation = UsbipOperation(rawValue: rawOperation) else { + throw UsbipProtocolError.unknownOperation(rawOperation) + } + let sequenceNumber = bytes.beUInt32(at: 4) + guard sequenceNumber != 0 else { + throw UsbipProtocolError.invalidSequenceNumber(sequenceNumber) + } + let deviceID = bytes.beUInt32(at: 8) + let rawDirection = bytes.beUInt32(at: 12) + guard let direction = UsbipDirection(rawValue: rawDirection) else { + throw UsbipProtocolError.unknownDirection(rawDirection) + } + let endpoint = bytes.beUInt32(at: 16) + guard endpoint <= 15 else { throw UsbipProtocolError.invalidEndpoint(endpoint) } + switch operation { + case .cmdSubmit, .cmdUnlink: + guard deviceID != 0 else { throw UsbipProtocolError.invalidDeviceID(deviceID) } + case .retSubmit, .retUnlink: + guard deviceID == 0 else { throw UsbipProtocolError.invalidDeviceID(deviceID) } + guard direction == .out, endpoint == 0 else { + throw UsbipProtocolError.invalidEndpoint(endpoint) + } + } self.init( - command: UsbipOperation(rawValue: bytes.beUInt32(at: 0)) ?? .cmdSubmit, - sequenceNumber: bytes.beUInt32(at: 4), - deviceID: bytes.beUInt32(at: 8), - direction: UsbipDirection(rawValue: bytes.beUInt32(at: 12)) ?? .out, - endpoint: bytes.beUInt32(at: 16) + command: operation, + sequenceNumber: sequenceNumber, + deviceID: deviceID, + direction: direction, + endpoint: endpoint ) } @@ -225,6 +332,27 @@ public struct UsbipSubmitCommand: Equatable, Sendable { public static let headerByteCount = 48 public static let maxTransferBytes: UInt32 = 4 * 1024 * 1024 + public struct HeaderMetadata: Equatable, Sendable { + public var header: UsbipHeaderBasic + public var transferFlags: UInt32 + public var transferBufferLength: UInt32 + public var startFrame: UInt32 + public var numberOfPackets: UInt32 + public var interval: UInt32 + public var setup: [UInt8] + + public var isIsochronous: Bool { + // Linux's protocol document reserves -1 for non-iso, while its current + // usbip_pack_cmd_submit() copies the non-iso URB value (0) verbatim. + // Both are canonical Linux peer encodings; every positive packet count is iso. + numberOfPackets != 0 && numberOfPackets != UInt32.max + } + + public var outPayloadByteCount: Int { + header.direction == .out ? Int(transferBufferLength) : 0 + } + } + public var header: UsbipHeaderBasic public var transferFlags: UInt32 public var transferBufferLength: UInt32 @@ -246,27 +374,95 @@ public struct UsbipSubmitCommand: Equatable, Sendable { } public init(decoding bytes: [UInt8]) throws { + let metadata = try Self.inspectHeader(bytes) + guard !metadata.isIsochronous else { + throw UsbipProtocolError.unsupportedIsochronous(metadata.numberOfPackets) + } + let expectedByteCount = Self.headerByteCount + metadata.outPayloadByteCount + try bytes.requireExactCount(expectedByteCount) + self.init( + header: metadata.header, + transferFlags: metadata.transferFlags, + transferBufferLength: metadata.transferBufferLength, + startFrame: metadata.startFrame, + numberOfPackets: metadata.numberOfPackets, + interval: metadata.interval, + setup: metadata.setup, + transferBuffer: Array(bytes[48.. HeaderMetadata { guard bytes.count >= Self.headerByteCount else { throw UsbipProtocolError.shortFrame } let header = try UsbipHeaderBasic(decoding: bytes) - let rawTransferLength = bytes.beUInt32(at: 24) - guard rawTransferLength <= Self.maxTransferBytes else { - throw UsbipProtocolError.transferBufferTooLarge(rawTransferLength) + guard header.command == .cmdSubmit else { + throw UsbipProtocolError.unexpectedOperation(expected: .cmdSubmit, actual: header.command) } - let transferLength = Int(rawTransferLength) - let payloadLength = header.direction == .out ? transferLength : 0 - guard bytes.count >= Self.headerByteCount + payloadLength else { throw UsbipProtocolError.shortFrame } - self.init( + let transferFlags = bytes.beUInt32(at: 20) + try validateTransferFlags(transferFlags, direction: header.direction) + let transferLength = bytes.beUInt32(at: 24) + guard transferLength <= Self.maxTransferBytes else { + throw UsbipProtocolError.transferBufferTooLarge(transferLength) + } + let startFrame = bytes.beUInt32(at: 28) + let numberOfPackets = bytes.beUInt32(at: 32) + let isIsochronous = numberOfPackets != 0 && numberOfPackets != UInt32.max + guard isIsochronous || startFrame == 0 || startFrame == UInt32.max else { + throw UsbipProtocolError.invalidStartFrame(startFrame) + } + let interval = bytes.beUInt32(at: 36) + guard interval <= UInt32(UInt8.max) else { + throw UsbipProtocolError.invalidInterval(interval) + } + let setup = Array(bytes[40..<48]) + if header.endpoint == 0 { + let setupDirection: UsbipDirection = setup[0] & 0x80 == 0 ? .out : .in + let setupLength = UInt32(setup[6]) | (UInt32(setup[7]) << 8) + guard setupDirection == header.direction, setupLength == transferLength else { + throw UsbipProtocolError.invalidSetup + } + } else if setup.contains(where: { $0 != 0 }) { + throw UsbipProtocolError.invalidSetup + } + return HeaderMetadata( header: header, - transferFlags: bytes.beUInt32(at: 20), - transferBufferLength: UInt32(transferLength), - startFrame: bytes.beUInt32(at: 28), - numberOfPackets: bytes.beUInt32(at: 32), - interval: bytes.beUInt32(at: 36), - setup: Array(bytes[40..<48]), - transferBuffer: Array(bytes[48..<(48 + payloadLength)]) + transferFlags: transferFlags, + transferBufferLength: transferLength, + startFrame: startFrame, + numberOfPackets: numberOfPackets, + interval: interval, + setup: setup ) } + public static func validateTransferFlags( + _ flags: UInt32, + direction: UsbipDirection + ) throws { + let unknown = flags & ~UsbipTransferFlag.known + guard unknown == 0 else { throw UsbipProtocolError.unknownTransferFlags(unknown) } + let flagSaysIn = flags & UsbipTransferFlag.directionIn != 0 + guard flagSaysIn == (direction == .in) else { + throw UsbipProtocolError.transferDirectionFlagMismatch( + flags: flags, + direction: direction + ) + } + let unsupported = flags + & (UsbipTransferFlag.isoAsSoonAsPossible | UsbipTransferFlag.zeroPacket) + guard unsupported == 0 else { + throw UsbipProtocolError.unsupportedTransferFlags(unsupported) + } + guard direction == .in || flags & UsbipTransferFlag.shortNotOK == 0 else { + throw UsbipProtocolError.unsupportedTransferFlags( + flags & UsbipTransferFlag.shortNotOK + ) + } + } + public func encoded() -> [UInt8] { var bytes = header.encoded() bytes.appendBE(transferFlags) @@ -331,8 +527,22 @@ public struct UsbipUnlinkCommand: Equatable, Sendable { } public init(decoding bytes: [UInt8]) throws { - guard bytes.count >= Self.byteCount else { throw UsbipProtocolError.shortFrame } - self.init(header: try UsbipHeaderBasic(decoding: bytes), unlinkSequenceNumber: bytes.beUInt32(at: 20)) + try bytes.requireExactCount(Self.byteCount) + let header = try UsbipHeaderBasic(decoding: bytes) + guard header.command == .cmdUnlink else { + throw UsbipProtocolError.unexpectedOperation(expected: .cmdUnlink, actual: header.command) + } + guard header.direction == .out, header.endpoint == 0 else { + throw UsbipProtocolError.invalidEndpoint(header.endpoint) + } + let unlinkSequenceNumber = bytes.beUInt32(at: 20) + guard unlinkSequenceNumber != 0, unlinkSequenceNumber != header.sequenceNumber else { + throw UsbipProtocolError.invalidSequenceNumber(unlinkSequenceNumber) + } + guard bytes[24.. [UInt8] { @@ -363,6 +573,13 @@ public struct UsbipUnlinkReply: Equatable, Sendable { } private extension Array where Element == UInt8 { + func requireExactCount(_ expected: Int) throws { + guard count >= expected else { throw UsbipProtocolError.shortFrame } + guard count == expected else { + throw UsbipProtocolError.invalidFrameLength(expected: expected, actual: count) + } + } + mutating func appendBE(_ value: UInt16) { Swift.withUnsafeBytes(of: value.bigEndian) { append(contentsOf: $0) } } @@ -393,10 +610,19 @@ private extension Array where Element == UInt8 { let end = offset + length guard count >= end else { throw UsbipProtocolError.shortFrame } let slice = self[offset.. UsbipSubmitReply - func unlink(_ command: UsbipUnlinkCommand) throws -> UsbipUnlinkReply + func submit(_ command: UsbipSubmitCommand, context: UsbipRequestContext) throws -> UsbipSubmitReply + func unlink(_ command: UsbipUnlinkCommand, context: UsbipRequestContext) throws -> UsbipUnlinkReply + func closeSession(_ context: UsbipRequestContext) + func shutdown() } public enum UsbipServerError: Error, Equatable { case unknownDevice(String) - case unsupportedIsochronous + case invalidDeviceDescriptor(String) } public final class UsbipServer: @unchecked Sendable { private let devicesByBusID: [String: any UsbipExportedDevice] public init(devices: [any UsbipExportedDevice]) { - self.devicesByBusID = Dictionary(uniqueKeysWithValues: devices.map { ($0.descriptor.busID, $0) }) + var mapped: [String: any UsbipExportedDevice] = [:] + for device in devices where mapped[device.descriptor.busID] == nil { + mapped[device.descriptor.busID] = device + } + self.devicesByBusID = mapped } public func handleImport(_ bytes: [UInt8]) throws -> [UInt8] { @@ -27,23 +42,53 @@ public final class UsbipServer: @unchecked Sendable { return UsbipImportReply(status: 0, device: device.descriptor).encoded() } - public func handleURB(_ bytes: [UInt8], busID: String) throws -> [UInt8] { + public func handleURB(_ bytes: [UInt8], busID: String, context: UsbipRequestContext) throws -> [UInt8] { guard let device = devicesByBusID[busID] else { throw UsbipServerError.unknownDevice(busID) } let basic = try UsbipHeaderBasic(decoding: bytes) + try validateDeviceID(basic.deviceID, for: device.descriptor) switch basic.command { case .cmdSubmit: - let command = try UsbipSubmitCommand(decoding: bytes) - guard command.numberOfPackets == 0 || command.numberOfPackets == 0xffff_ffff else { - let replyHeader = UsbipHeaderBasic(command: .retSubmit, sequenceNumber: command.header.sequenceNumber, deviceID: 0, direction: .out, endpoint: 0) - return UsbipSubmitReply(header: replyHeader, status: -EPIPE, actualLength: 0, numberOfPackets: command.numberOfPackets).encoded() + let metadata = try UsbipSubmitCommand.inspectHeader(bytes) + if metadata.isIsochronous { + let replyHeader = UsbipHeaderBasic( + command: .retSubmit, + sequenceNumber: metadata.header.sequenceNumber, + deviceID: 0, + direction: .out, + endpoint: 0 + ) + return UsbipSubmitReply( + header: replyHeader, + status: -EPIPE, + actualLength: 0, + numberOfPackets: metadata.numberOfPackets + ).encoded() } - return try device.submit(command).encoded() + let command = try UsbipSubmitCommand(decoding: bytes) + return try device.submit(command, context: context).encoded() case .cmdUnlink: - return try device.unlink(try UsbipUnlinkCommand(decoding: bytes)).encoded() + return try device.unlink(try UsbipUnlinkCommand(decoding: bytes), context: context).encoded() case .retSubmit, .retUnlink: - throw UsbipProtocolError.shortFrame + throw UsbipProtocolError.unexpectedOperation(expected: .cmdSubmit, actual: basic.command) + } + } + + public func closeSession(_ context: UsbipRequestContext, busID: String?) { + guard let busID, let device = devicesByBusID[busID] else { return } + device.closeSession(context) + } + + private func validateDeviceID(_ actual: UInt32, for descriptor: UsbipDeviceDescriptor) throws { + guard descriptor.busNumber <= UInt32(UInt16.max), + descriptor.deviceNumber > 0, + descriptor.deviceNumber <= UInt32(UInt16.max) else { + throw UsbipServerError.invalidDeviceDescriptor(descriptor.busID) + } + let expected = (descriptor.busNumber << 16) | descriptor.deviceNumber + guard actual == expected else { + throw UsbipProtocolError.unexpectedDeviceID(expected: expected, actual: actual) } } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/VirtualUVCCamera.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/VirtualUVCCamera.swift new file mode 100644 index 00000000..bc34f8f7 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/VirtualUVCCamera.swift @@ -0,0 +1,431 @@ +import Darwin +import Foundation + +/// Supplies already-compressed MJPEG frames to the virtual UVC transport. The runner owns the +/// permission-sensitive macOS capture implementation; this USB layer remains deterministic and +/// independently testable. +public protocol DoryUVCCameraFrameSource: Sendable { + func nextJPEGFrame(width: Int, height: Int, timeout: TimeInterval) -> Data? + func stop() +} + +public enum DoryVirtualUVCCamera { + public static let busID = "255-1" + public static let busNumber: UInt32 = 255 + public static let deviceNumber: UInt32 = 1 + public static let deviceID = (busNumber << 16) | deviceNumber + public static let speedHigh: UInt32 = 3 + + public static func descriptor() -> UsbipDeviceDescriptor { + UsbipDeviceDescriptor( + path: "/dory/virtual/camera/0", + busID: busID, + busNumber: busNumber, + deviceNumber: deviceNumber, + speed: speedHigh, + vendorID: 0xD0F1, + productID: 0xCA01, + bcdDevice: 0x0100, + deviceClass: 0xEF, + deviceSubClass: 0x02, + deviceProtocol: 0x01, + configurationValue: 1, + configurationCount: 1, + interfaceCount: 2 + ) + } +} + +/// A bounded USB 2.0 Video Class 1.1 camera. It exposes one bulk MJPEG endpoint so USB/IP never +/// needs to reinterpret or emulate isochronous scheduling. Linux binds its upstream `uvcvideo` +/// driver and presents the device through the normal V4L2 API. +public final class DoryVirtualUVCCameraBackend: HostUsbBackend, @unchecked Sendable { + private enum StandardRequest { + static let getStatus: UInt8 = 0x00 + static let clearFeature: UInt8 = 0x01 + static let getDescriptor: UInt8 = 0x06 + static let getConfiguration: UInt8 = 0x08 + static let setConfiguration: UInt8 = 0x09 + static let getInterface: UInt8 = 0x0A + static let setInterface: UInt8 = 0x0B + } + + private enum UVCRequest { + static let setCurrent: UInt8 = 0x01 + static let getCurrent: UInt8 = 0x81 + static let getMinimum: UInt8 = 0x82 + static let getMaximum: UInt8 = 0x83 + static let getResolution: UInt8 = 0x84 + static let getLength: UInt8 = 0x85 + static let getInfo: UInt8 = 0x86 + static let getDefault: UInt8 = 0x87 + } + + private struct StreamControl { + // UVC 1.1 probe/commit controls are 34 bytes. Returning the 26-byte UVC 1.0 shape while + // advertising bcdUVC 1.10 makes mainline uvcvideo reject enumeration as a short control + // transfer. + static let byteCount = 34 + static let defaultFrameInterval: UInt32 = 333_333 + static let maximumPayloadBytes: UInt32 = 16 * 1_024 + static let clockFrequency: UInt32 = 48_000_000 + + var formatIndex: UInt8 = 1 + var frameIndex: UInt8 = 2 + var frameInterval: UInt32 = defaultFrameInterval + + init() {} + + init?(_ bytes: [UInt8]) { + guard bytes.count >= Self.byteCount, + bytes[2] == 1, + (1...2).contains(bytes[3]) else { return nil } + let interval = Self.leUInt32(bytes, at: 4) + guard interval >= 333_333, interval <= 1_000_000 else { return nil } + formatIndex = bytes[2] + frameIndex = bytes[3] + frameInterval = interval + } + + var encoded: [UInt8] { + let dimensions = dimensions + let maximumFrameBytes = UInt32(dimensions.0 * dimensions.1 * 2) + var bytes = [UInt8](repeating: 0, count: Self.byteCount) + // bmHint: dwFrameInterval is fixed by the host. + Self.putLE(UInt16(1), into: &bytes, at: 0) + bytes[2] = formatIndex + bytes[3] = frameIndex + Self.putLE(frameInterval, into: &bytes, at: 4) + Self.putLE(maximumFrameBytes, into: &bytes, at: 18) + Self.putLE(Self.maximumPayloadBytes, into: &bytes, at: 22) + Self.putLE(Self.clockFrequency, into: &bytes, at: 26) + return bytes + } + + var dimensions: (Int, Int) { + frameIndex == 1 ? (640, 480) : (1_280, 720) + } + + private static func leUInt32(_ bytes: [UInt8], at offset: Int) -> UInt32 { + UInt32(bytes[offset]) + | UInt32(bytes[offset + 1]) << 8 + | UInt32(bytes[offset + 2]) << 16 + | UInt32(bytes[offset + 3]) << 24 + } + + private static func putLE( + _ value: T, + into bytes: inout [UInt8], + at offset: Int + ) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { raw in + bytes.replaceSubrange(offset..<(offset + raw.count), with: raw) + } + } + } + + private let frameSource: any DoryUVCCameraFrameSource + private let state = NSLock() + private var configured = false + private var streamingAlternateSetting: UInt8 = 0 + private var probe = StreamControl() + private var commit = StreamControl() + private var activeFrame = Data() + private var activeFrameOffset = 0 + private var frameIdentifier: UInt8 = 0 + private var stopped = false + + public init(frameSource: any DoryUVCCameraFrameSource) { + self.frameSource = frameSource + } + + public func control( + _ setup: HostUsbControlSetup, + payload: [UInt8], + direction: UsbipDirection, + timeout: TimeInterval + ) throws -> HostUsbTransferResult { + let requestClass = setup.requestType & 0x60 + switch requestClass { + case 0x00: + return try standardControl(setup, direction: direction) + case 0x20: + return try videoClassControl(setup, payload: payload, direction: direction) + default: + throw HostUsbTransferError.failed(errno: EPIPE) + } + } + + public func transfer( + endpointAddress: UInt8, + payload: [UInt8], + expectedLength: UInt32, + direction: UsbipDirection, + kind: HostUsbTransferKind, + timeout: TimeInterval + ) throws -> HostUsbTransferResult { + guard endpointAddress == 0x81, + direction == .in, + kind == .bulk, + expectedLength > 2, + expectedLength <= UsbipSubmitCommand.maxTransferBytes else { + throw HostUsbTransferError.endpointNotFound(endpointAddress) + } + + state.lock() + guard !stopped, configured, streamingAlternateSetting == 0 else { + state.unlock() + throw HostUsbTransferError.failed(errno: EPIPE) + } + let needsFrame = activeFrameOffset >= activeFrame.count + let frameDimensions = commit.dimensions + state.unlock() + + if needsFrame { + let boundedTimeout = max(0.001, min(timeout, 1.0)) + guard let frame = frameSource.nextJPEGFrame( + width: frameDimensions.0, + height: frameDimensions.1, + timeout: boundedTimeout + ), + !frame.isEmpty, + frame.count <= 1_280 * 720 * 2 else { + return HostUsbTransferResult(status: 0, actualLength: 0) + } + state.lock() + guard !stopped else { + state.unlock() + throw HostUsbTransferError.failed(errno: ENODEV) + } + if activeFrameOffset >= activeFrame.count { + activeFrame = frame + activeFrameOffset = 0 + } + state.unlock() + } + + state.lock() + defer { state.unlock() } + guard !stopped, activeFrameOffset < activeFrame.count else { + throw HostUsbTransferError.failed(errno: ENODEV) + } + let payloadCapacity = Int(expectedLength) - 2 + let remaining = activeFrame.count - activeFrameOffset + let amount = min(payloadCapacity, remaining) + let reachesEnd = amount == remaining + var result = [UInt8](repeating: 0, count: 2 + amount) + result[0] = 2 + // EOH | EOF (when applicable) | FID. + result[1] = 0x80 | (reachesEnd ? 0x02 : 0) | frameIdentifier + result.replaceSubrange( + 2.. HostUsbTransferResult { + switch (setup.request, direction) { + case (StandardRequest.getDescriptor, .in): + let descriptorType = UInt8(setup.value >> 8) + let descriptorIndex = UInt8(setup.value & 0xFF) + let bytes: [UInt8] + switch descriptorType { + case 0x01: bytes = Self.deviceDescriptor + case 0x02: bytes = Self.configurationDescriptor + case 0x03: bytes = try Self.stringDescriptor(index: descriptorIndex) + default: throw HostUsbTransferError.failed(errno: EPIPE) + } + return Self.inputResult(bytes, length: setup.length) + case (StandardRequest.setConfiguration, .out): + guard setup.value == 0 || setup.value == 1 else { + throw HostUsbTransferError.failed(errno: EINVAL) + } + state.withLock { + configured = setup.value == 1 + if !configured { streamingAlternateSetting = 0 } + } + return HostUsbTransferResult(status: 0, actualLength: 0) + case (StandardRequest.getConfiguration, .in): + let value: UInt8 = state.withLock { configured ? 1 : 0 } + return Self.inputResult([value], length: setup.length) + case (StandardRequest.setInterface, .out): + guard setup.index == 1, setup.value == 0 else { + throw HostUsbTransferError.failed(errno: EINVAL) + } + state.withLock { streamingAlternateSetting = 0 } + return HostUsbTransferResult(status: 0, actualLength: 0) + case (StandardRequest.getInterface, .in): + guard setup.index <= 1 else { throw HostUsbTransferError.failed(errno: EINVAL) } + let alternate: UInt8 = setup.index == 1 + ? state.withLock { streamingAlternateSetting } : 0 + return Self.inputResult([alternate], length: setup.length) + case (StandardRequest.getStatus, .in): + return Self.inputResult([0, 0], length: setup.length) + case (StandardRequest.clearFeature, .out): + return HostUsbTransferResult(status: 0, actualLength: 0) + default: + throw HostUsbTransferError.failed(errno: EPIPE) + } + } + + private func videoClassControl( + _ setup: HostUsbControlSetup, + payload: [UInt8], + direction: UsbipDirection + ) throws -> HostUsbTransferResult { + let selector = UInt8(setup.value >> 8) + let interface = UInt8(setup.index & 0xFF) + guard interface == 1, selector == 1 || selector == 2 else { + throw HostUsbTransferError.failed(errno: EPIPE) + } + switch (setup.request, direction) { + case (UVCRequest.setCurrent, .out): + guard let value = StreamControl(payload) else { + throw HostUsbTransferError.failed(errno: EINVAL) + } + state.withLock { + if selector == 1 { probe = value } else { commit = value } + } + return HostUsbTransferResult( + status: 0, + actualLength: UInt32(min(payload.count, Int(setup.length))) + ) + case (UVCRequest.getCurrent, .in): + let value = state.withLock { selector == 1 ? probe : commit } + return Self.inputResult(value.encoded, length: setup.length) + case (UVCRequest.getMinimum, .in), (UVCRequest.getResolution, .in), + (UVCRequest.getDefault, .in): + return Self.inputResult(StreamControl().encoded, length: setup.length) + case (UVCRequest.getMaximum, .in): + var maximum = StreamControl() + maximum.frameIndex = 2 + maximum.frameInterval = 1_000_000 + return Self.inputResult(maximum.encoded, length: setup.length) + case (UVCRequest.getLength, .in): + return Self.inputResult([UInt8(StreamControl.byteCount), 0], length: setup.length) + case (UVCRequest.getInfo, .in): + // GET and SET are both implemented. + return Self.inputResult([0x03], length: setup.length) + default: + throw HostUsbTransferError.failed(errno: EPIPE) + } + } + + private static func inputResult(_ bytes: [UInt8], length: UInt16) -> HostUsbTransferResult { + let result = Array(bytes.prefix(Int(length))) + return HostUsbTransferResult( + status: 0, + actualLength: UInt32(result.count), + data: result + ) + } + + private static func stringDescriptor(index: UInt8) throws -> [UInt8] { + if index == 0 { return [4, 3, 0x09, 0x04] } + let value: String + switch index { + case 1: value = "Dory" + case 2: value = "Dory Camera" + case 3: value = "DORY-CAMERA-1" + default: throw HostUsbTransferError.failed(errno: EPIPE) + } + var bytes: [UInt8] = [UInt8(2 + value.utf16.count * 2), 3] + for scalar in value.utf16 { + bytes.append(UInt8(truncatingIfNeeded: scalar)) + bytes.append(UInt8(truncatingIfNeeded: scalar >> 8)) + } + return bytes + } + + private static let deviceDescriptor: [UInt8] = [ + 18, 0x01, 0x00, 0x02, 0xEF, 0x02, 0x01, 64, + 0xF1, 0xD0, 0x01, 0xCA, 0x00, 0x01, 1, 2, 3, 1, + ] + + private static let configurationDescriptor: [UInt8] = { + var bytes: [UInt8] = [ + // Configuration and Video IAD. + 9, 0x02, 186, 0, 2, 1, 0, 0x80, 250, + 8, 0x0B, 0, 2, 0x0E, 0x03, 0x00, 2, + // VideoControl interface. + 9, 0x04, 0, 0, 0, 0x0E, 0x01, 0x00, 0, + 13, 0x24, 0x01, 0x10, 0x01, 53, 0, + 0x00, 0x6C, 0xDC, 0x02, 1, 1, + // Camera input terminal. + 18, 0x24, 0x02, 1, 0x01, 0x02, 0, 0, + 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, + // Processing unit and USB streaming output terminal. + 13, 0x24, 0x05, 2, 1, 0, 0, 3, 0, 0, 0, 0, 0, + 9, 0x24, 0x03, 3, 0x01, 0x01, 0, 2, 0, + // A bulk UVC stream has one alternate setting and its endpoint lives on setting 0. + // Linux uses `num_altsetting == 1` to select its bulk decoder. + 9, 0x04, 1, 0, 1, 0x0E, 0x02, 0x00, 0, + 14, 0x24, 0x01, 1, 91, 0, 0x81, 0, 3, 0, 0, 0, 1, 0, + 11, 0x24, 0x06, 1, 2, 0, 2, 0, 0, 0, 0, + ] + func appendFrame(index: UInt8, width: UInt16, height: UInt16) { + let pixels = UInt32(width) * UInt32(height) + let minimumBitRate = pixels * 8 * 5 + let maximumBitRate = pixels * 16 * 30 + let maximumFrameBytes = pixels * 2 + bytes.append(contentsOf: [30, 0x24, 0x07, index, 0]) + appendLE(width, to: &bytes) + appendLE(height, to: &bytes) + appendLE(minimumBitRate, to: &bytes) + appendLE(maximumBitRate, to: &bytes) + appendLE(maximumFrameBytes, to: &bytes) + appendLE(UInt32(333_333), to: &bytes) + bytes.append(1) + appendLE(UInt32(333_333), to: &bytes) + } + appendFrame(index: 1, width: 640, height: 480) + appendFrame(index: 2, width: 1_280, height: 720) + bytes.append(contentsOf: [6, 0x24, 0x0D, 1, 1, 4]) + bytes.append(contentsOf: [7, 0x05, 0x81, 0x02, 0x00, 0x02, 0]) + precondition(bytes.count == 186) + return bytes + }() + + private static func appendLE(_ value: T, to bytes: inout [UInt8]) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { bytes.append(contentsOf: $0) } + } +} + +private extension NSLock { + func withLock(_ body: () throws -> Result) rethrows -> Result { + lock() + defer { unlock() } + return try body() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift deleted file mode 100644 index c1b311e9..00000000 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirglRenderer.swift +++ /dev/null @@ -1,594 +0,0 @@ -import Darwin -import Foundation - -public final class VirglRenderer: VirtioGPURenderer, @unchecked Sendable { - public let libraryPath: String - public let moltenVKICDPath: String - public let capsets: [VirtioGPUCapset] - - private let handle: UnsafeMutableRawPointer - private let callbacks: UnsafeMutablePointer - private let functions: Functions - - // Fence completions arrive on virglrenderer's internal threads (ASYNC_FENCE_CB); the C - // callbacks have no per-instance cookie routing here, so a single active renderer is registered - // globally (one renderer per engine process) and forwards into the device's sink. - private static let fenceLock = NSLock() - nonisolated(unsafe) private static weak var activeRenderer: VirglRenderer? - nonisolated(unsafe) private var fenceSink: ((UInt32, UInt32, UInt64) -> Void)? - - public var onFenceSignaled: ((UInt32, UInt32, UInt64) -> Void)? { - get { Self.fenceLock.lock(); defer { Self.fenceLock.unlock() }; return fenceSink } - set { Self.fenceLock.lock(); fenceSink = newValue; Self.fenceLock.unlock() } - } - - fileprivate static func signalFence(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64) { - fenceLock.lock() - let sink = activeRenderer?.fenceSink - fenceLock.unlock() - sink?(contextID, ringIndex, fenceID) - } - - public static func discover( - environment: [String: String] = ProcessInfo.processInfo.environment - ) throws -> VirglRenderer { - guard let libraryPath = firstExistingPath(candidates: virglRendererCandidates(environment: environment)) else { - throw VMError.invalidConfiguration( - "gpu=venus requires libvirglrenderer.dylib; set DORY_VIRGLRENDERER_PATH or bundle it in Contents/Frameworks" - ) - } - guard let moltenVKICD = firstExistingPath(candidates: moltenVKICDCandidates(environment: environment)) else { - throw VMError.invalidConfiguration( - "gpu=venus requires MoltenVK_icd.json; set DORY_MOLTENVK_ICD or bundle it in Contents/Resources/vulkan/icd.d" - ) - } - return try VirglRenderer(libraryPath: libraryPath, moltenVKICDPath: moltenVKICD) - } - - public init(libraryPath: String, moltenVKICDPath: String) throws { - guard FileManager.default.fileExists(atPath: libraryPath) else { - throw VMError.invalidConfiguration("virglrenderer library not found: \(libraryPath)") - } - guard FileManager.default.fileExists(atPath: moltenVKICDPath) else { - throw VMError.invalidConfiguration("MoltenVK ICD not found: \(moltenVKICDPath)") - } - guard let handle = dlopen(libraryPath, RTLD_NOW | RTLD_LOCAL) else { - let message = dlerror().map { String(cString: $0) } ?? "unknown dlopen failure" - throw VMError.invalidConfiguration("cannot load virglrenderer at \(libraryPath): \(message)") - } - - do { - let functions = try Functions(handle: handle) - guard functions.resourceMap != nil || functions.resourceGetMapPtr != nil else { - throw VMError.invalidConfiguration( - "libvirglrenderer at \(libraryPath) exports neither virgl_renderer_resource_map nor virgl_renderer_resource_get_map_ptr; Dory's Venus path needs one to expose host-visible blobs to the guest" - ) - } - - setenv("VK_ICD_FILENAMES", moltenVKICDPath, 1) - - if let sym = dlsym(handle, "virgl_set_log_callback") { - typealias SetLogCallback = @convention(c) ( - (@convention(c) (Int32, UnsafePointer?, UnsafeMutableRawPointer?) -> Void)?, - UnsafeMutableRawPointer?, - UnsafeMutableRawPointer? - ) -> Void - unsafeBitCast(sym, to: SetLogCallback.self)(doryVirglLog, nil, nil) - } - - let callbacks = UnsafeMutablePointer.allocate(capacity: 1) - callbacks.initialize(to: VirglRendererCallbacks( - version: 4, - writeFence: doryVirglWriteFence, - createGLContext: nil, - destroyGLContext: nil, - makeCurrent: nil, - getDRMFD: nil, - writeContextFence: doryVirglWriteContextFence, - getServerFD: nil, - getEGLDisplay: nil - )) - - // Host-allocated HOST3D blobs (the zero-copy map model libkrun uses): do NOT set - // USE_GUEST_VRAM, which would make virglrenderer choose guest-backed storage that returns - // no mappable host pointer. ASYNC_FENCE_CB delivers fence completions from the renderer's - // own threads, so no poll loop is needed for real fence signalling. - let flags = RendererFlags.venus | RendererFlags.noVirgl | RendererFlags.asyncFenceCB - let initStatus = functions.initialize(nil, flags, UnsafeMutableRawPointer(callbacks)) - guard initStatus == 0 else { - callbacks.deinitialize(count: 1) - callbacks.deallocate() - throw VMError.invalidConfiguration("virgl_renderer_init(Venus) failed with status \(initStatus)") - } - - var maxVersion: UInt32 = 0 - var maxSize: UInt32 = 0 - functions.getCapSet(VirtioGPUCapsetID.venus, &maxVersion, &maxSize) - // Venus reports maxVersion == 0 (it negotiates its protocol via the capset data, not the - // capset version number, unlike virgl). Gate on maxSize only. - guard maxSize > 0 else { - functions.cleanup(nil) - callbacks.deinitialize(count: 1) - callbacks.deallocate() - throw VMError.invalidConfiguration("virglrenderer did not report a Venus capset") - } - - var capsetData = [UInt8](repeating: 0, count: Int(maxSize)) - capsetData.withUnsafeMutableBytes { buffer in - functions.fillCaps(VirtioGPUCapsetID.venus, maxVersion, buffer.baseAddress) - } - - self.libraryPath = libraryPath - self.moltenVKICDPath = moltenVKICDPath - self.capsets = [VirtioGPUCapset(id: VirtioGPUCapsetID.venus, maxVersion: maxVersion, data: capsetData)] - self.handle = handle - self.callbacks = callbacks - self.functions = functions - Self.fenceLock.lock() - Self.activeRenderer = self - Self.fenceLock.unlock() - } catch { - dlclose(handle) - throw error - } - } - - deinit { - Self.fenceLock.lock() - if Self.activeRenderer === self { Self.activeRenderer = nil } - Self.fenceLock.unlock() - functions.cleanup(nil) - callbacks.deinitialize(count: 1) - callbacks.deallocate() - dlclose(handle) - } - - /// Registers a host fence so virglrenderer signals it (via the async callbacks) once all GPU - /// work submitted before it has completed. Context fences carry Venus's per-ring ordering; - /// plain fences ride the global ctx0 timeline. - public func createFence(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64, contextFence: Bool) throws { - if contextFence, let contextCreateFence = functions.contextCreateFence { - try check( - contextCreateFence(contextID, 0, ringIndex, fenceID), - "virgl_renderer_context_create_fence" - ) - return - } - try check( - functions.createFence(Int32(truncatingIfNeeded: fenceID), contextID), - "virgl_renderer_create_fence" - ) - } - - public func createContext(id: UInt32, flags: UInt32, name: String) throws { - let status = name.withCString { pointer in - functions.contextCreateWithFlags(id, flags, UInt32(name.utf8.count), pointer) - } - try check(status, "virgl_renderer_context_create_with_flags") - } - - public func destroyContext(id: UInt32) throws { - functions.contextDestroy(id) - } - - public func attachResource(contextID: UInt32, resourceID: UInt32) throws { - functions.contextAttachResource(Int32(bitPattern: contextID), Int32(bitPattern: resourceID)) - } - - public func detachResource(contextID: UInt32, resourceID: UInt32) throws { - functions.contextDetachResource(Int32(bitPattern: contextID), Int32(bitPattern: resourceID)) - } - - public func submit3D(contextID: UInt32, command: [UInt8]) throws { - var command = command - while command.count % 4 != 0 { command.append(0) } - let dwordCount = Int32(command.count / 4) - let status = command.withUnsafeMutableBytes { buffer in - functions.submitCommand(buffer.baseAddress, Int32(bitPattern: contextID), dwordCount) - } - try check(status, "virgl_renderer_submit_cmd") - } - - public func createResource3D(_ resource: VirtioGPUResourceCreate3D, entries: [VirtioGPUMemoryEntry]) throws { - var args = VirglRendererResourceCreateArgs( - handle: resource.resourceID, - target: resource.target, - format: resource.format, - bind: resource.bind, - width: resource.width, - height: resource.height, - depth: resource.depth, - arraySize: resource.arraySize, - lastLevel: resource.lastLevel, - samples: resource.samples, - flags: resource.flags - ) - let status = try withIOVecs(entries) { pointer, count in - withUnsafeMutablePointer(to: &args) { argsPointer in - functions.resourceCreate(UnsafeMutableRawPointer(argsPointer), pointer, count) - } - } - try check(status, "virgl_renderer_resource_create") - } - - public func createBlob( - resourceID: UInt32, - contextID: UInt32, - blobMemory: UInt32, - blobFlags: UInt32, - blobID: UInt64, - size: UInt64, - entries: [VirtioGPUMemoryEntry] - ) throws { - var args = VirglRendererResourceCreateBlobArgs( - resourceHandle: resourceID, - contextID: contextID, - blobMemory: blobMemory, - blobFlags: blobFlags, - blobID: blobID, - size: size, - iovecs: nil, - iovecCount: 0 - ) - let status = try withIOVecs(entries) { pointer, count in - args.iovecs = pointer - args.iovecCount = count - return withUnsafePointer(to: &args) { argsPointer in - functions.resourceCreateBlob(UnsafeRawPointer(argsPointer)) - } - } - try check(status, "virgl_renderer_resource_create_blob") - } - - public func attachBacking(resourceID: UInt32, entries: [VirtioGPUMemoryEntry]) throws { - let status = try withIOVecs(entries) { pointer, count in - functions.resourceAttachIOV(Int32(bitPattern: resourceID), pointer, Int32(count)) - } - try check(status, "virgl_renderer_resource_attach_iov") - } - - public func detachBacking(resourceID: UInt32) throws { - var detached: UnsafeMutablePointer? - var count: Int32 = 0 - functions.resourceDetachIOV(Int32(bitPattern: resourceID), &detached, &count) - } - - public func unrefResource(resourceID: UInt32) throws { - functions.resourceUnref(resourceID) - } - - public func mapBlob(resourceID: UInt32) throws -> VirtioGPUBlobMapping { - var pointer: UnsafeMutableRawPointer? - var size: UInt64 = 0 - // On macOS the blob is MoltenVK-backed (Apple handle); virgl_renderer_resource_map returns - // -EINVAL for it by design. get_map_ptr returns the vkMapMemory host VA to hv_vm_map into the - // guest — the exact path libkrun/krunkit use. Fall back to resource_map only if absent. - if let getPtr = functions.resourceGetMapPtr { - var address: UInt64 = 0 - try check(getPtr(resourceID, &address), "virgl_renderer_resource_get_map_ptr") - pointer = UnsafeMutableRawPointer(bitPattern: UInt(address)) - } else if let map = functions.resourceMap { - try check(map(resourceID, &pointer, &size), "virgl_renderer_resource_map") - } else { - throw VMError.invalidConfiguration("virglrenderer has no blob map entrypoint") - } - guard let hostPointer = pointer else { - throw VMError.invalidConfiguration("virglrenderer returned a null host pointer for blob resource \(resourceID)") - } - var mapInfo: UInt32 = 0 - try check(functions.resourceGetMapInfo(resourceID, &mapInfo), "virgl_renderer_resource_get_map_info") - return VirtioGPUBlobMapping(hostPointer: hostPointer, size: size, mapInfo: mapInfo & 0x0f) - } - - public func unmapBlob(resourceID: UInt32) throws { - try check(functions.resourceUnmap(resourceID), "virgl_renderer_resource_unmap") - } - - public func transferToHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws { - var box = VirglBox(values: transfer.box) - let status = try withIOVecs(entries) { pointer, count in - withUnsafePointer(to: &box) { boxPointer in - functions.transferWriteIOV( - transfer.resourceID, - transfer.contextID, - Int32(bitPattern: transfer.level), - transfer.stride, - transfer.layerStride, - UnsafeRawPointer(boxPointer), - transfer.offset, - pointer, - count - ) - } - } - try check(status, "virgl_renderer_transfer_write_iov") - } - - public func transferFromHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws { - var box = VirglBox(values: transfer.box) - let status = try withIOVecs(entries) { pointer, count in - withUnsafePointer(to: &box) { boxPointer in - functions.transferReadIOV( - transfer.resourceID, - transfer.contextID, - transfer.level, - transfer.stride, - transfer.layerStride, - UnsafeRawPointer(boxPointer), - transfer.offset, - pointer, - Int32(count) - ) - } - } - try check(status, "virgl_renderer_transfer_read_iov") - } - - private func withIOVecs( - _ entries: [VirtioGPUMemoryEntry], - _ body: (UnsafePointer?, UInt32) throws -> T - ) throws -> T { - let iovecs = entries.map { iovec(iov_base: $0.pointer, iov_len: $0.length) } - if iovecs.isEmpty { - return try body(nil, 0) - } - return try iovecs.withUnsafeBufferPointer { buffer in - try body(buffer.baseAddress, UInt32(buffer.count)) - } - } - - private func check(_ status: Int32, _ operation: String) throws { - guard status == 0 else { - throw VMError.invalidConfiguration("\(operation) failed with status \(status)") - } - } - - private static func virglRendererCandidates(environment: [String: String]) -> [String] { - var candidates = [ - environment["DORY_VIRGLRENDERER_PATH"], - environment["DORY_VIRGLRENDERER"], - Bundle.main.privateFrameworksPath.map { "\($0)/libvirglrenderer.dylib" }, - Bundle.main.resourcePath.map { "\($0)/libvirglrenderer.dylib" }, - ].compactMap { $0?.isEmpty == false ? $0 : nil } - if let executable = CommandLine.arguments.first { - let directory = URL(fileURLWithPath: executable).deletingLastPathComponent().path - candidates.append("\(directory)/../Frameworks/libvirglrenderer.dylib") - candidates.append("\(directory)/libvirglrenderer.dylib") - } - candidates.append(contentsOf: [ - "/opt/homebrew/lib/libvirglrenderer.dylib", - "/usr/local/lib/libvirglrenderer.dylib", - ]) - return candidates - } - - private static func moltenVKICDCandidates(environment: [String: String]) -> [String] { - var candidates = [String]() - if let override = environment["DORY_MOLTENVK_ICD"], !override.isEmpty { - candidates.append(override) - } - if let existing = environment["VK_ICD_FILENAMES"], !existing.isEmpty { - candidates.append(contentsOf: existing.split(separator: ":").map(String.init)) - } - if let executable = CommandLine.arguments.first { - let directory = URL(fileURLWithPath: executable).deletingLastPathComponent().path - candidates.append("\(directory)/../Resources/vulkan/icd.d/MoltenVK_icd.json") - candidates.append("\(directory)/../Resources/MoltenVK_icd.json") - } - candidates.append(contentsOf: [ - Bundle.main.resourcePath.map { "\($0)/vulkan/icd.d/MoltenVK_icd.json" }, - Bundle.main.resourcePath.map { "\($0)/MoltenVK_icd.json" }, - "/opt/homebrew/etc/vulkan/icd.d/MoltenVK_icd.json", - "/opt/homebrew/share/vulkan/icd.d/MoltenVK_icd.json", - "/usr/local/etc/vulkan/icd.d/MoltenVK_icd.json", - "/usr/local/share/vulkan/icd.d/MoltenVK_icd.json", - ].compactMap { $0 }) - return candidates - } - - private static func firstExistingPath(candidates: [String]) -> String? { - candidates.first { FileManager.default.fileExists(atPath: URL(fileURLWithPath: $0).standardizedFileURL.path) } - .map { URL(fileURLWithPath: $0).standardizedFileURL.path } - } -} - -private enum RendererFlags { - static let venus: Int32 = 1 << 6 - static let noVirgl: Int32 = 1 << 7 - static let asyncFenceCB: Int32 = 1 << 8 - static let useGuestVRAM: Int32 = 1 << 14 -} - -private enum VirtioGPUCapsetID { - static let venus: UInt32 = 4 -} - -private typealias WriteFenceCallback = @convention(c) (UnsafeMutableRawPointer?, UInt32) -> Void -private typealias WriteContextFenceCallback = @convention(c) (UnsafeMutableRawPointer?, UInt32, UInt32, UInt64) -> Void -private typealias CreateGLContextCallback = @convention(c) ( - UnsafeMutableRawPointer?, - Int32, - UnsafeMutableRawPointer? -) -> UnsafeMutableRawPointer? -private typealias DestroyGLContextCallback = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Void -private typealias MakeCurrentCallback = @convention(c) (UnsafeMutableRawPointer?, Int32, UnsafeMutableRawPointer?) -> Int32 -private typealias GetDRMFDCallback = @convention(c) (UnsafeMutableRawPointer?) -> Int32 -private typealias GetServerFDCallback = @convention(c) (UnsafeMutableRawPointer?, UInt32) -> Int32 -private typealias GetEGLDisplayCallback = @convention(c) (UnsafeMutableRawPointer?) -> UnsafeMutableRawPointer? - -private let doryVirglLog: @convention(c) (Int32, UnsafePointer?, UnsafeMutableRawPointer?) -> Void = { level, message, _ in - let text = message.map { String(cString: $0) } ?? "" - FileHandle.standardError.write(Data("virgl[\(level)]: \(text)\n".utf8)) -} - -// ctx0 fences ride the global timeline: write_fence has no context/ring, so they complete under -// (context 0, ring 0). Context fences carry their real coordinates. -private let doryVirglWriteFence: WriteFenceCallback = { _, fence in - VirglRenderer.signalFence(contextID: 0, ringIndex: 0, fenceID: UInt64(fence)) -} -private let doryVirglWriteContextFence: WriteContextFenceCallback = { _, contextID, ringIndex, fenceID in - VirglRenderer.signalFence(contextID: contextID, ringIndex: ringIndex, fenceID: fenceID) -} - -private struct VirglRendererCallbacks { - var version: Int32 - var writeFence: WriteFenceCallback? - var createGLContext: CreateGLContextCallback? - var destroyGLContext: DestroyGLContextCallback? - var makeCurrent: MakeCurrentCallback? - var getDRMFD: GetDRMFDCallback? - var writeContextFence: WriteContextFenceCallback? - var getServerFD: GetServerFDCallback? - var getEGLDisplay: GetEGLDisplayCallback? -} - -private struct VirglRendererResourceCreateArgs { - var handle: UInt32 - var target: UInt32 - var format: UInt32 - var bind: UInt32 - var width: UInt32 - var height: UInt32 - var depth: UInt32 - var arraySize: UInt32 - var lastLevel: UInt32 - var samples: UInt32 - var flags: UInt32 -} - -private struct VirglRendererResourceCreateBlobArgs { - var resourceHandle: UInt32 - var contextID: UInt32 - var blobMemory: UInt32 - var blobFlags: UInt32 - var blobID: UInt64 - var size: UInt64 - var iovecs: UnsafePointer? - var iovecCount: UInt32 -} - -private struct VirglBox { - var x: UInt32 - var y: UInt32 - var z: UInt32 - var width: UInt32 - var height: UInt32 - var depth: UInt32 - - init(values: [UInt32]) { - let padded = values + Array(repeating: 0, count: max(0, 6 - values.count)) - x = padded[0] - y = padded[1] - z = padded[2] - width = padded[3] - height = padded[4] - depth = padded[5] - } -} - -private struct Functions { - typealias Initialize = @convention(c) (UnsafeMutableRawPointer?, Int32, UnsafeMutableRawPointer?) -> Int32 - typealias Cleanup = @convention(c) (UnsafeMutableRawPointer?) -> Void - typealias GetCapSet = @convention(c) (UInt32, UnsafeMutablePointer?, UnsafeMutablePointer?) -> Void - typealias FillCaps = @convention(c) (UInt32, UInt32, UnsafeMutableRawPointer?) -> Void - typealias ContextCreateWithFlags = @convention(c) (UInt32, UInt32, UInt32, UnsafePointer?) -> Int32 - typealias ContextDestroy = @convention(c) (UInt32) -> Void - typealias ContextResource = @convention(c) (Int32, Int32) -> Void - typealias SubmitCommand = @convention(c) (UnsafeMutableRawPointer?, Int32, Int32) -> Int32 - typealias ResourceCreate = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, UInt32) -> Int32 - typealias ResourceCreateBlob = @convention(c) (UnsafeRawPointer?) -> Int32 - typealias ResourceAttachIOV = @convention(c) (Int32, UnsafePointer?, Int32) -> Int32 - typealias ResourceDetachIOV = @convention(c) (Int32, UnsafeMutablePointer?>?, UnsafeMutablePointer?) -> Void - typealias ResourceUnref = @convention(c) (UInt32) -> Void - typealias ResourceMapFixed = @convention(c) (UInt32, UnsafeMutableRawPointer?) -> Int32 - typealias ResourceMap = @convention(c) (UInt32, UnsafeMutablePointer?, UnsafeMutablePointer?) -> Int32 - typealias ResourceGetMapPtr = @convention(c) (UInt32, UnsafeMutablePointer?) -> Int32 - typealias ResourceUnmap = @convention(c) (UInt32) -> Int32 - typealias ResourceGetMapInfo = @convention(c) (UInt32, UnsafeMutablePointer?) -> Int32 - typealias TransferWriteIOV = @convention(c) ( - UInt32, - UInt32, - Int32, - UInt32, - UInt32, - UnsafeRawPointer?, - UInt64, - UnsafePointer?, - UInt32 - ) -> Int32 - typealias TransferReadIOV = @convention(c) ( - UInt32, - UInt32, - UInt32, - UInt32, - UInt32, - UnsafeRawPointer?, - UInt64, - UnsafePointer?, - Int32 - ) -> Int32 - typealias CreateFence = @convention(c) (Int32, UInt32) -> Int32 - typealias ContextCreateFence = @convention(c) (UInt32, UInt32, UInt32, UInt64) -> Int32 - - let initialize: Initialize - let cleanup: Cleanup - let getCapSet: GetCapSet - let fillCaps: FillCaps - let contextCreateWithFlags: ContextCreateWithFlags - let contextDestroy: ContextDestroy - let contextAttachResource: ContextResource - let contextDetachResource: ContextResource - let submitCommand: SubmitCommand - let resourceCreate: ResourceCreate - let resourceCreateBlob: ResourceCreateBlob - let resourceAttachIOV: ResourceAttachIOV - let resourceDetachIOV: ResourceDetachIOV - let resourceUnref: ResourceUnref - let resourceMapFixed: ResourceMapFixed? - let resourceMap: ResourceMap? - let resourceGetMapPtr: ResourceGetMapPtr? - let resourceUnmap: ResourceUnmap - let resourceGetMapInfo: ResourceGetMapInfo - let transferWriteIOV: TransferWriteIOV - let transferReadIOV: TransferReadIOV - let createFence: CreateFence - let contextCreateFence: ContextCreateFence? - - init(handle: UnsafeMutableRawPointer) throws { - initialize = try Self.required(handle, "virgl_renderer_init") - cleanup = try Self.required(handle, "virgl_renderer_cleanup") - getCapSet = try Self.required(handle, "virgl_renderer_get_cap_set") - fillCaps = try Self.required(handle, "virgl_renderer_fill_caps") - contextCreateWithFlags = try Self.required(handle, "virgl_renderer_context_create_with_flags") - contextDestroy = try Self.required(handle, "virgl_renderer_context_destroy") - contextAttachResource = try Self.required(handle, "virgl_renderer_ctx_attach_resource") - contextDetachResource = try Self.required(handle, "virgl_renderer_ctx_detach_resource") - submitCommand = try Self.required(handle, "virgl_renderer_submit_cmd") - resourceCreate = try Self.required(handle, "virgl_renderer_resource_create") - resourceCreateBlob = try Self.required(handle, "virgl_renderer_resource_create_blob") - resourceAttachIOV = try Self.required(handle, "virgl_renderer_resource_attach_iov") - resourceDetachIOV = try Self.required(handle, "virgl_renderer_resource_detach_iov") - resourceUnref = try Self.required(handle, "virgl_renderer_resource_unref") - resourceMapFixed = Self.optional(handle, "virgl_renderer_resource_map_fixed") - resourceMap = Self.optional(handle, "virgl_renderer_resource_map") - resourceGetMapPtr = Self.optional(handle, "virgl_renderer_resource_get_map_ptr") - resourceUnmap = try Self.required(handle, "virgl_renderer_resource_unmap") - resourceGetMapInfo = try Self.required(handle, "virgl_renderer_resource_get_map_info") - transferWriteIOV = try Self.required(handle, "virgl_renderer_transfer_write_iov") - transferReadIOV = try Self.required(handle, "virgl_renderer_transfer_read_iov") - createFence = try Self.required(handle, "virgl_renderer_create_fence") - contextCreateFence = Self.optional(handle, "virgl_renderer_context_create_fence") - } - - private static func required(_ handle: UnsafeMutableRawPointer, _ name: String) throws -> T { - guard let symbol = dlsym(handle, name) else { - throw VMError.invalidConfiguration("libvirglrenderer missing required symbol \(name)") - } - return unsafeBitCast(symbol, to: T.self) - } - - private static func optional(_ handle: UnsafeMutableRawPointer, _ name: String) -> T? { - guard let symbol = dlsym(handle, name) else { return nil } - return unsafeBitCast(symbol, to: T.self) - } -} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBalloon.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBalloon.swift index c247ba42..d2e255a3 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBalloon.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBalloon.swift @@ -1,71 +1,665 @@ -import Darwin import Foundation +import Synchronization -/// virtio-balloon with free page reporting (VIRTIO_BALLOON_F_REPORTING): the guest batches ranges -/// of free pages onto the reporting queue and `GuestMemory.releaseRange` hands them straight back to -/// macOS via `MADV_FREE_REUSABLE`, which drops them from the process's physical footprint immediately -/// (refaults come back through `MADV_FREE_REUSE`). This is the mechanism Virtualization.framework -/// lacks, and the reason dory-hv exists: the host footprint tracks what the guest is actually using -/// instead of its high-water mark. -public final class VirtioBalloon: VirtioDeviceBackend { +public struct VirtioBalloonStatistics: Equatable, Sendable { + public var reportRequests: UInt64 + public var reportRejected: UInt64 + public var reportBytes: UInt64 + public var reclaimedBytes: UInt64 + public var releaseFailures: UInt64 + public var classicRequests: UInt64 + public var invalidClassicRequests: UInt64 + public var queueFaults: UInt64 + public var boundedDrainStops: UInt64 + public var workerTurns: UInt64 + public var workerYields: UInt64 + public var coalescedWorkerRequests: UInt64 + public var revokedWorkerTurns: UInt64 + public var reportProcessingNanoseconds: UInt64 + public var maximumReportProcessingNanoseconds: UInt64 +} + +struct VirtioBalloonLimits: Equatable, Sendable { + /// Linux's page-reporting core currently submits at most 32 scatterlist entries, normally one + /// pageblock (2 MiB on a 4 KiB-page arm64 kernel) per entry. Matching that 64 MiB transaction + /// ceiling bounds host reclaim work without fragmenting a conforming Linux report. + static let production = VirtioBalloonLimits( + maximumReportRanges: 32, + maximumReportBytes: 64 * 1_024 * 1_024, + // A reporting chain can itself cover 64 MiB. One chain per worker turn bounds latency and + // lets notifications for the other compacted queues enter between large reports. + maximumChainsPerWorkerTurn: 1 + ) + + let maximumReportRanges: Int + let maximumReportBytes: Int + let maximumChainsPerWorkerTurn: Int + + init( + maximumReportRanges: Int, + maximumReportBytes: Int, + maximumChainsPerWorkerTurn: Int + ) { + precondition(maximumReportRanges > 0) + precondition(maximumReportBytes >= Int(HostPage.size)) + precondition(maximumChainsPerWorkerTurn > 0) + precondition(maximumChainsPerWorkerTurn <= Int(Virtqueue.maximumSize)) + self.maximumReportRanges = maximumReportRanges + self.maximumReportBytes = maximumReportBytes + self.maximumChainsPerWorkerTurn = maximumChainsPerWorkerTurn + } +} + +/// virtio-balloon with VIRTIO_BALLOON_F_PAGE_REPORTING. Linux offers isolated free pages as +/// device-writable scatter/gather buffers, waits for the used-ring acknowledgement, and then may +/// reuse them. Dory validates the complete report before any host-memory mutation, releases only +/// fully covered host pages, and acknowledges the report even when macOS elects not to reclaim a +/// particular range (the feature is an optimization, not guest memory ownership transfer). +public final class VirtioBalloon: VirtioDeviceBackend, @unchecked Sendable { public let deviceID: UInt32 = 5 - public let queueCount = 3 // inflate, deflate, reporting - public var deviceFeatures: UInt64 { 1 << 5 } // VIRTIO_BALLOON_F_REPORTING + // On virtio-mmio, Linux compacts optional named queues. With only PAGE_REPORTING negotiated, + // physical queue 2 is reporting_vq after inflateq and deflateq. + public let queueCount = 3 + public var deviceFeatures: UInt64 { 1 << 5 } // VIRTIO_BALLOON_F_PAGE_REPORTING + public let kickSynchronization: VirtioKickSynchronization = .backendManaged + + private final class WeakTransportReference: @unchecked Sendable { + weak var value: VirtioMMIOTransport? + + init(_ value: VirtioMMIOTransport) { + self.value = value + } + } + + private struct QueueWorkerState { + var generation: UInt64 = 1 + var scheduled = false + var kickPending = false + } + + private struct WorkerState { + var transport: WeakTransportReference? + var queues = Array(repeating: QueueWorkerState(), count: 3) + } + + private enum PreparedWork { + case empty + case completed(wantsInterrupt: Bool) + case report( + chain: VirtqueueChain, + ranges: [GuestRange], + reportedBytes: Int + ) + case fault + case stale + } + + private enum ReportCompletion { + case published(wantsInterrupt: Bool) + case fault + case stale + } + + private struct GuestRange: Equatable, Sendable { + var address: UInt64 + var length: UInt64 + + var end: UInt64 { address + length } + } + + private enum ReportAdmission { + case accepted(ranges: [GuestRange], reportedBytes: Int) + case invalid + case revoked + } private let memory: GuestMemory - private let log: (String) -> Void - public private(set) var reclaimedBytes: UInt64 = 0 - public private(set) var reportEvents: UInt64 = 0 + private let limits: VirtioBalloonLimits + private let releaseRange: @Sendable (UInt64, UInt64) -> GuestMemoryReleaseResult + private let log: @Sendable (String) -> Void + private let submitWork: (@escaping @Sendable () -> Void) -> Void + private let workerStateLock = NSLock() + // Device reset and queue reconfiguration hold the transport lock before entering this fence. + // A worker never waits for the transport lock while holding the fence, which prevents a + // reset/worker lock cycle while still forbidding reclaim from starting after revocation. + private let lifecycleFence = NSLock() + private var workerState = WorkerState() + private let reportRequests = Atomic(0) + private let reportRejected = Atomic(0) + private let reportBytes = Atomic(0) + private let reclaimedByteCount = Atomic(0) + private let releaseFailures = Atomic(0) + private let classicRequests = Atomic(0) + private let invalidClassicRequests = Atomic(0) + private let queueFaults = Atomic(0) + private let boundedDrainStops = Atomic(0) + private let workerTurns = Atomic(0) + private let workerYields = Atomic(0) + private let coalescedWorkerRequests = Atomic(0) + private let revokedWorkerTurns = Atomic(0) + private let reportProcessingNanoseconds = Atomic(0) + private let maximumReportProcessingNanoseconds = Mutex(0) - private static let hostPageSize: UInt64 = HostPage.size + public convenience init( + memory: GuestMemory, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { + let worker = DispatchQueue( + label: "dev.dory.virtio-balloon", + qos: .utility + ) + self.init( + memory: memory, + limits: .production, + releaseRange: { [memory] address, length in + memory.releaseRange(guestAddress: address, length: length) + }, + log: log, + submitWork: { operation in worker.async(execute: operation) } + ) + } - public init(memory: GuestMemory, log: @escaping (String) -> Void = { _ in }) { + init( + memory: GuestMemory, + limits: VirtioBalloonLimits, + releaseRange: @escaping @Sendable (UInt64, UInt64) -> GuestMemoryReleaseResult, + log: @escaping @Sendable (String) -> Void = { _ in }, + submitWork: @escaping (@escaping @Sendable () -> Void) -> Void = { operation in + operation() + } + ) { self.memory = memory + self.limits = limits + self.releaseRange = releaseRange self.log = log + self.submitWork = submitWork } public var configSpace: [UInt8] { - // num_pages = 0 (no inflation requested), actual = 0. + // num_pages = 0 (classic inflation is parked), actual = 0. [UInt8](repeating: 0, count: 8) } public func handleKick(queue: Int, transport: VirtioMMIOTransport) { - let virtqueue = transport.queues[queue] - var interrupt = false - while let chain = (try? virtqueue.pop()) ?? nil { + guard transport.queues.indices.contains(queue), queue < queueCount else { return } + let generation = workerStateLock.withLock { () -> UInt64? in + if let reference = workerState.transport { + if let existing = reference.value { + guard existing === transport else { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return nil + } + } else { + // A backend is normally owned by exactly one transport. If a synthetic caller + // outlives that transport, revoke every orphaned scheduled turn before binding + // a replacement so its kick cannot be coalesced into a dead worker task. + for index in workerState.queues.indices { + advanceWorkerGenerationLocked(queue: index) + } + workerState.transport = WeakTransportReference(transport) + } + } else { + workerState.transport = WeakTransportReference(transport) + } + if workerState.queues[queue].scheduled { + workerState.queues[queue].kickPending = true + coalescedWorkerRequests.wrappingAdd(1, ordering: .relaxed) + return nil + } + workerState.queues[queue].scheduled = true + return workerState.queues[queue].generation + } + guard let generation else { return } + submitWorkerTurn(queue: queue, generation: generation, transport: transport) + } + + public func deviceReset(transport: VirtioMMIOTransport) { + revokeWorker(queue: nil, transport: transport) + } + + public func queueStateChanged( + queue: Int, + ready: Bool, + transport: VirtioMMIOTransport + ) { + _ = ready + guard (0.. PreparedWork { + transport.withQueueLock { + guard isCurrentWorker( + queue: queue, + generation: generation, + transport: transport + ) else { return .stale } + let virtqueue = transport.queues[queue] + let chain: VirtqueueChain + do { + guard let next = try virtqueue.pop() else { return .empty } + chain = next + } catch { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return .fault + } + if queue == 2 { - reclaim(chain: chain) + switch admitReport(chain) { + case .invalid: + reportRejected.wrappingAdd(1, ordering: .relaxed) + case .revoked: + queueFaults.wrappingAdd(1, ordering: .relaxed) + return .fault + case let .accepted(ranges, reportedBytes): + return .report( + chain: chain, + ranges: ranges, + reportedBytes: reportedBytes + ) + } + } else { + processClassicRequest(chain) + } + + do { + return .completed(wantsInterrupt: try virtqueue.push(chain, written: 0)) + } catch { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return .fault } - // Inflate and deflate chains complete as no-ops: the ceiling is enforced by RAM size - // and reporting handles elasticity, so the classic balloon stays parked at zero. - let wants = (try? virtqueue.push(chain, written: 0)) ?? false - interrupt = interrupt || wants } - if interrupt { - transport.notifyUsed() + } + + private func completeReport( + _ chain: VirtqueueChain, + ranges: [GuestRange], + reportedBytes: Int, + queue: Int, + generation: UInt64, + transport: VirtioMMIOTransport + ) -> ReportCompletion { + let startedAt = Self.monotonicNanoseconds() + lifecycleFence.lock() + guard isCurrentWorker( + queue: queue, + generation: generation, + transport: transport + ) else { + lifecycleFence.unlock() + return .stale + } + processAcceptedReport(ranges: ranges, reportedBytes: reportedBytes) + lifecycleFence.unlock() + + let elapsed = Self.monotonicNanoseconds() &- startedAt + reportProcessingNanoseconds.wrappingAdd(elapsed, ordering: .relaxed) + maximumReportProcessingNanoseconds.withLock { maximum in + maximum = max(maximum, elapsed) + } + + return transport.withQueueLock { + guard isCurrentWorker( + queue: queue, + generation: generation, + transport: transport + ) else { return .stale } + do { + return .published(wantsInterrupt: try transport.queues[queue].push( + chain, + written: 0 + )) + } catch { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return .fault + } + } + } + + private func pendingWork( + queue: Int, + generation: UInt64, + transport: VirtioMMIOTransport + ) -> Bool { + transport.withQueueLock { + guard isCurrentWorker( + queue: queue, + generation: generation, + transport: transport + ) else { return false } + do { + return try transport.queues[queue].pendingCount() > 0 + } catch { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return false + } } } - /// Every segment of a reporting chain IS a run of free guest pages. Stage-2 mappings pin the - /// backing pages, so each range is unmapped from the guest and only then marked reusable; - /// GuestMemory.releaseRange does both. The guest tolerates zero-filled refaults on reported - /// pages by contract, and the RAM-fault path in the run loop remaps blocks on first touch. - private func reclaim(chain: VirtqueueChain) { - let hostBase = UInt64(UInt(bitPattern: memory.hostBase)) - for segment in chain.segments { - let start = UInt64(UInt(bitPattern: segment.pointer)) - let end = start + UInt64(segment.length) - let alignedStart = (start + Self.hostPageSize - 1) & ~(Self.hostPageSize - 1) - let alignedEnd = end & ~(Self.hostPageSize - 1) - guard alignedEnd > alignedStart else { continue } - let guestAddress = memory.guestBase + (alignedStart - hostBase) - if memory.releaseRange(guestAddress: guestAddress, length: alignedEnd - alignedStart) { - reclaimedBytes &+= alignedEnd - alignedStart + private func finishWorkerTurn( + queue: Int, + generation: UInt64, + transport: VirtioMMIOTransport, + wantsInterrupt: Bool, + knownPendingWork: Bool + ) { + if wantsInterrupt { + transport.withQueueLock { + if isCurrentWorker( + queue: queue, + generation: generation, + transport: transport + ) { + transport.notifyUsed() + } } } - reportEvents &+= 1 - if reportEvents <= 30 || reportEvents % 64 == 0 { - log("balloon: report #\(reportEvents), \(chain.segments.count) ranges, total \(reclaimedBytes >> 20) MiB reclaimed") + + let shouldContinue = workerStateLock.withLock { () -> Bool in + guard isCurrentWorkerLocked( + queue: queue, + generation: generation, + transport: transport + ) else { return false } + let pending = knownPendingWork || workerState.queues[queue].kickPending + workerState.queues[queue].kickPending = false + if !pending { + workerState.queues[queue].scheduled = false + } + return pending + } + if shouldContinue { + workerYields.wrappingAdd(1, ordering: .relaxed) + submitWorkerTurn(queue: queue, generation: generation, transport: transport) + } + } + + private func revokeWorker( + queue: Int?, + transport: VirtioMMIOTransport + ) { + lifecycleFence.lock() + workerStateLock.withLock { + if let existing = workerState.transport?.value, existing !== transport { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return + } + if workerState.transport == nil { + workerState.transport = WeakTransportReference(transport) + } + let indices = queue.map { [$0] } ?? Array(workerState.queues.indices) + for index in indices { + advanceWorkerGenerationLocked(queue: index) + } + } + lifecycleFence.unlock() + } + + private func isCurrentWorker( + queue: Int, + generation: UInt64, + transport: VirtioMMIOTransport + ) -> Bool { + workerStateLock.withLock { + isCurrentWorkerLocked( + queue: queue, + generation: generation, + transport: transport + ) + } + } + + private func isCurrentWorkerLocked( + queue: Int, + generation: UInt64, + transport: VirtioMMIOTransport + ) -> Bool { + workerState.queues.indices.contains(queue) + && workerState.transport?.value === transport + && workerState.queues[queue].generation == generation + && workerState.queues[queue].scheduled + } + + private func processClassicRequest(_ chain: VirtqueueChain) { + classicRequests.wrappingAdd(1, ordering: .relaxed) + let valid = chain.withLeaseHeld { access in + // inflateq/deflateq contain a device-readable array of 32-bit balloon PFNs. Dory's + // target is zero, so valid unsolicited arrays are acknowledged as no-ops. + !chain.containsZeroLengthDescriptor + && access.readableSegmentCount > 0 + && access.writableSegmentCount == 0 + && access.readableByteCount > 0 + && access.readableByteCount % MemoryLayout.size == 0 + && access.readableByteCount <= 256 * MemoryLayout.size + } ?? false + if !valid { + invalidClassicRequests.wrappingAdd(1, ordering: .relaxed) } } + + private func processAcceptedReport(ranges: [GuestRange], reportedBytes: Int) { + let event = reportRequests.wrappingAdd(1, ordering: .relaxed).newValue + reportBytes.wrappingAdd(UInt64(reportedBytes), ordering: .relaxed) + var reclaimedThisReport: UInt64 = 0 + for range in ranges { + let result = releaseRange(range.address, range.length) + if result.hostMemoryWasReclaimed { + reclaimedThisReport &+= range.length + } else { + releaseFailures.wrappingAdd(1, ordering: .relaxed) + } + } + if reclaimedThisReport > 0 { + reclaimedByteCount.wrappingAdd(reclaimedThisReport, ordering: .relaxed) + } + if event <= 30 || event % 64 == 0 { + let total = reclaimedByteCount.load(ordering: .relaxed) + log( + "balloon: report #\(event), \(ranges.count) ranges, " + + "\(reportedBytes >> 20) MiB reported, \(total >> 20) MiB reclaimed" + ) + } + } + + private static func monotonicNanoseconds() -> UInt64 { + DispatchTime.now().uptimeNanoseconds + } + + private func admitReport(_ chain: VirtqueueChain) -> ReportAdmission { + chain.withLeaseHeld { access -> ReportAdmission in + guard !chain.containsZeroLengthDescriptor, + access.readableSegmentCount == 0, + access.writableSegmentCount > 0, + access.writableSegmentCount <= limits.maximumReportRanges, + access.writableByteCount > 0, + access.writableByteCount <= limits.maximumReportBytes else { + return .invalid + } + + let hostBase = UInt64(UInt(bitPattern: memory.hostBase)) + let (hostEnd, hostEndOverflow) = hostBase.addingReportingOverflow(memory.size) + guard !hostEndOverflow else { return .invalid } + + var staged = [GuestRange]() + staged.reserveCapacity(access.writableSegmentCount) + for segment in access.segments { + guard segment.isDeviceWritable, segment.length > 0 else { return .invalid } + let start = UInt64(UInt(bitPattern: segment.pointer)) + let (end, endOverflow) = start.addingReportingOverflow(UInt64(segment.length)) + guard !endOverflow, start >= hostBase, end <= hostEnd else { return .invalid } + + let guestStart = memory.guestBase + (start - hostBase) + let (guestEnd, guestEndOverflow) = guestStart.addingReportingOverflow( + UInt64(segment.length) + ) + guard !guestEndOverflow else { return .invalid } + let alignedStart = Self.roundUpToHostPage(guestStart) + let alignedEnd = guestEnd & ~(HostPage.size - 1) + if let alignedStart, alignedEnd > alignedStart { + staged.append(GuestRange( + address: alignedStart, + length: alignedEnd - alignedStart + )) + } + } + return .accepted( + ranges: Self.mergeOverlappingRanges(staged), + reportedBytes: access.writableByteCount + ) + } ?? .revoked + } + + private static func roundUpToHostPage(_ value: UInt64) -> UInt64? { + let (adjusted, overflow) = value.addingReportingOverflow(HostPage.size - 1) + guard !overflow else { return nil } + return adjusted & ~(HostPage.size - 1) + } + + private static func mergeOverlappingRanges(_ input: [GuestRange]) -> [GuestRange] { + let sorted = input.sorted { + if $0.address == $1.address { return $0.length < $1.length } + return $0.address < $1.address + } + var result = [GuestRange]() + result.reserveCapacity(sorted.count) + for range in sorted { + guard var last = result.last else { + result.append(range) + continue + } + if range.address <= last.end { + let end = max(last.end, range.end) + last.length = end - last.address + result[result.count - 1] = last + } else { + result.append(range) + } + } + return result + } + + public var statistics: VirtioBalloonStatistics { + VirtioBalloonStatistics( + reportRequests: reportRequests.load(ordering: .relaxed), + reportRejected: reportRejected.load(ordering: .relaxed), + reportBytes: reportBytes.load(ordering: .relaxed), + reclaimedBytes: reclaimedByteCount.load(ordering: .relaxed), + releaseFailures: releaseFailures.load(ordering: .relaxed), + classicRequests: classicRequests.load(ordering: .relaxed), + invalidClassicRequests: invalidClassicRequests.load(ordering: .relaxed), + queueFaults: queueFaults.load(ordering: .relaxed), + boundedDrainStops: boundedDrainStops.load(ordering: .relaxed), + workerTurns: workerTurns.load(ordering: .relaxed), + workerYields: workerYields.load(ordering: .relaxed), + coalescedWorkerRequests: coalescedWorkerRequests.load(ordering: .relaxed), + revokedWorkerTurns: revokedWorkerTurns.load(ordering: .relaxed), + reportProcessingNanoseconds: reportProcessingNanoseconds.load(ordering: .relaxed), + maximumReportProcessingNanoseconds: maximumReportProcessingNanoseconds.withLock { $0 } + ) + } + + /// Compatibility snapshots for existing telemetry callers. New code should consume + /// `statistics` so failures and rejected reports are not mistaken for successful reclaim. + public var reclaimedBytes: UInt64 { statistics.reclaimedBytes } + public var reportEvents: UInt64 { statistics.reportRequests } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift index 78a1fe31..647016b2 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift @@ -1,13 +1,298 @@ import Darwin import Foundation -/// virtio-blk backed by a raw disk image. Requests use zero-copy pread/pwrite straight into guest +/// I/O, queue, and request event totals wrap modulo 2^64; the legacy flush totals saturate. +/// Queue depth and in-flight transfers are sampled gauges. High-watermarks and maximum latency +/// retain the largest value observed by this backend. +public struct VirtioBlkStatistics: Equatable, Sendable { + public var flushes: UInt64 + public var maximumFlushLatencyNanoseconds: UInt64 + public var slowFlushes: UInt64 + public var invalidRequests: UInt64 + public var queuePopFaults: UInt64 + public var completionFaults: UInt64 + public var boundedDrainStops: UInt64 + public var queueWorkTurns: UInt64 + public var queueDepth: UInt64 + public var queueHighWatermark: UInt64 + public var requestCompletions: UInt64 + public var revokedRequests: UInt64 + public var requestServiceLatencyNanoseconds: UInt64 + public var maximumRequestServiceLatencyNanoseconds: UInt64 + public var readRequests: UInt64 + public var writeRequests: UInt64 + public var readBytes: UInt64 + public var writeBytes: UInt64 + public var readSystemCalls: UInt64 + public var writeSystemCalls: UInt64 + public var partialIOSystemCalls: UInt64 + public var interruptedIOSystemCalls: UInt64 + public var failedIOSystemCalls: UInt64 + public var hostIOBudgetExhaustions: UInt64 + public var transferSegments: UInt64 + public var inFlightTransfers: UInt64 + public var maximumInFlightTransfers: UInt64 + public var discardRequests: UInt64 + public var discardRequestedBytes: UInt64 + public var discardHostOperations: UInt64 + public var discardIgnoredRanges: UInt64 + public var writeZeroesRequests: UInt64 + public var writeZeroesRequestedBytes: UInt64 + public var writeZeroesHostWrittenBytes: UInt64 + public var writeZeroesHostOperations: UInt64 + public var rangePartialHostOperations: UInt64 + public var rangeInterruptedHostOperations: UInt64 + public var rangeFailedHostOperations: UInt64 + public var rangeHostOperationBudgetExhaustions: UInt64 + public var rangeSegments: UInt64 + public var rangeTurnBudgetStops: UInt64 + + public init( + flushes: UInt64, + maximumFlushLatencyNanoseconds: UInt64, + slowFlushes: UInt64, + invalidRequests: UInt64 = 0, + queuePopFaults: UInt64 = 0, + completionFaults: UInt64 = 0, + boundedDrainStops: UInt64 = 0, + queueWorkTurns: UInt64 = 0, + queueDepth: UInt64 = 0, + queueHighWatermark: UInt64 = 0, + requestCompletions: UInt64 = 0, + revokedRequests: UInt64 = 0, + requestServiceLatencyNanoseconds: UInt64 = 0, + maximumRequestServiceLatencyNanoseconds: UInt64 = 0, + readRequests: UInt64 = 0, + writeRequests: UInt64 = 0, + readBytes: UInt64 = 0, + writeBytes: UInt64 = 0, + readSystemCalls: UInt64 = 0, + writeSystemCalls: UInt64 = 0, + partialIOSystemCalls: UInt64 = 0, + interruptedIOSystemCalls: UInt64 = 0, + failedIOSystemCalls: UInt64 = 0, + hostIOBudgetExhaustions: UInt64 = 0, + transferSegments: UInt64 = 0, + inFlightTransfers: UInt64 = 0, + maximumInFlightTransfers: UInt64 = 0, + discardRequests: UInt64 = 0, + discardRequestedBytes: UInt64 = 0, + discardHostOperations: UInt64 = 0, + discardIgnoredRanges: UInt64 = 0, + writeZeroesRequests: UInt64 = 0, + writeZeroesRequestedBytes: UInt64 = 0, + writeZeroesHostWrittenBytes: UInt64 = 0, + writeZeroesHostOperations: UInt64 = 0, + rangePartialHostOperations: UInt64 = 0, + rangeInterruptedHostOperations: UInt64 = 0, + rangeFailedHostOperations: UInt64 = 0, + rangeHostOperationBudgetExhaustions: UInt64 = 0, + rangeSegments: UInt64 = 0, + rangeTurnBudgetStops: UInt64 = 0 + ) { + self.flushes = flushes + self.maximumFlushLatencyNanoseconds = maximumFlushLatencyNanoseconds + self.slowFlushes = slowFlushes + self.invalidRequests = invalidRequests + self.queuePopFaults = queuePopFaults + self.completionFaults = completionFaults + self.boundedDrainStops = boundedDrainStops + self.queueWorkTurns = queueWorkTurns + self.queueDepth = queueDepth + self.queueHighWatermark = queueHighWatermark + self.requestCompletions = requestCompletions + self.revokedRequests = revokedRequests + self.requestServiceLatencyNanoseconds = requestServiceLatencyNanoseconds + self.maximumRequestServiceLatencyNanoseconds = maximumRequestServiceLatencyNanoseconds + self.readRequests = readRequests + self.writeRequests = writeRequests + self.readBytes = readBytes + self.writeBytes = writeBytes + self.readSystemCalls = readSystemCalls + self.writeSystemCalls = writeSystemCalls + self.partialIOSystemCalls = partialIOSystemCalls + self.interruptedIOSystemCalls = interruptedIOSystemCalls + self.failedIOSystemCalls = failedIOSystemCalls + self.hostIOBudgetExhaustions = hostIOBudgetExhaustions + self.transferSegments = transferSegments + self.inFlightTransfers = inFlightTransfers + self.maximumInFlightTransfers = maximumInFlightTransfers + self.discardRequests = discardRequests + self.discardRequestedBytes = discardRequestedBytes + self.discardHostOperations = discardHostOperations + self.discardIgnoredRanges = discardIgnoredRanges + self.writeZeroesRequests = writeZeroesRequests + self.writeZeroesRequestedBytes = writeZeroesRequestedBytes + self.writeZeroesHostWrittenBytes = writeZeroesHostWrittenBytes + self.writeZeroesHostOperations = writeZeroesHostOperations + self.rangePartialHostOperations = rangePartialHostOperations + self.rangeInterruptedHostOperations = rangeInterruptedHostOperations + self.rangeFailedHostOperations = rangeFailedHostOperations + self.rangeHostOperationBudgetExhaustions = rangeHostOperationBudgetExhaustions + self.rangeSegments = rangeSegments + self.rangeTurnBudgetStops = rangeTurnBudgetStops + } +} + +struct VirtioBlkLimits: Equatable, Sendable { + static let production = VirtioBlkLimits( + maximumTransferBytes: 16 * 1_024 * 1_024, + maximumChainsPerDrain: 64, + maximumTransferBytesPerDrain: 64 * 1_024 * 1_024, + maximumIOVectorsPerSystemCall: 256, + maximumHostIOOperationsPerRequest: 1_024, + maximumDiscardSegmentsPerRequest: 64, + maximumWriteZeroesSegmentsPerRequest: 4, + maximumWriteZeroesBytesPerRequest: 16 * 1_024 * 1_024, + maximumRangeHostOperationsPerRequest: 64, + maximumRangeHostOperationsPerDrain: 64 + ) + + let maximumTransferBytes: Int + let maximumChainsPerDrain: Int + let maximumTransferBytesPerDrain: Int + let maximumIOVectorsPerSystemCall: Int + let maximumHostIOOperationsPerRequest: Int + let maximumDiscardSegmentsPerRequest: Int + let maximumWriteZeroesSegmentsPerRequest: Int + let maximumWriteZeroesBytesPerRequest: Int + let maximumRangeHostOperationsPerRequest: Int + let maximumRangeHostOperationsPerDrain: Int + + init( + maximumTransferBytes: Int, + maximumChainsPerDrain: Int, + maximumTransferBytesPerDrain: Int = 64 * 1_024 * 1_024, + maximumIOVectorsPerSystemCall: Int = 256, + maximumHostIOOperationsPerRequest: Int = 1_024, + maximumDiscardSegmentsPerRequest: Int = 64, + maximumWriteZeroesSegmentsPerRequest: Int = 4, + maximumWriteZeroesBytesPerRequest: Int = 16 * 1_024 * 1_024, + maximumRangeHostOperationsPerRequest: Int = 64, + maximumRangeHostOperationsPerDrain: Int = 64 + ) { + precondition(maximumTransferBytes >= 512) + precondition(maximumTransferBytes % 512 == 0) + precondition(maximumChainsPerDrain > 0) + precondition(maximumChainsPerDrain <= Int(Virtqueue.maximumSize)) + precondition(maximumTransferBytesPerDrain >= maximumTransferBytes) + precondition(maximumIOVectorsPerSystemCall > 0) + precondition(maximumIOVectorsPerSystemCall <= 256) + precondition(maximumHostIOOperationsPerRequest > 0) + precondition((1...256).contains(maximumDiscardSegmentsPerRequest)) + precondition((1...256).contains(maximumWriteZeroesSegmentsPerRequest)) + precondition(maximumWriteZeroesBytesPerRequest >= 512) + precondition(maximumWriteZeroesBytesPerRequest % 512 == 0) + precondition( + maximumWriteZeroesBytesPerRequest / 512 + >= maximumWriteZeroesSegmentsPerRequest + ) + precondition( + UInt64( + maximumWriteZeroesBytesPerRequest + / maximumWriteZeroesSegmentsPerRequest / 512 + ) <= UInt64(UInt32.max) + ) + precondition(maximumRangeHostOperationsPerRequest > 0) + precondition(maximumDiscardSegmentsPerRequest <= maximumRangeHostOperationsPerRequest) + precondition(maximumRangeHostOperationsPerDrain > 0) + self.maximumTransferBytes = maximumTransferBytes + self.maximumChainsPerDrain = maximumChainsPerDrain + self.maximumTransferBytesPerDrain = maximumTransferBytesPerDrain + self.maximumIOVectorsPerSystemCall = maximumIOVectorsPerSystemCall + self.maximumHostIOOperationsPerRequest = maximumHostIOOperationsPerRequest + self.maximumDiscardSegmentsPerRequest = maximumDiscardSegmentsPerRequest + self.maximumWriteZeroesSegmentsPerRequest = maximumWriteZeroesSegmentsPerRequest + self.maximumWriteZeroesBytesPerRequest = maximumWriteZeroesBytesPerRequest + self.maximumRangeHostOperationsPerRequest = maximumRangeHostOperationsPerRequest + self.maximumRangeHostOperationsPerDrain = maximumRangeHostOperationsPerDrain + } +} + +struct VirtioBlkHostIOResult: Equatable, Sendable { + var count: Int + var code: Int32 +} + +struct VirtioBlkIOOperations: @unchecked Sendable { + typealias VectoredOperation = ( + Int32, + UnsafePointer, + Int32, + off_t + ) -> VirtioBlkHostIOResult + + var read: VectoredOperation + var write: VectoredOperation + var monotonicNanoseconds: () -> UInt64 + + static var production: Self { + Self( + read: { descriptor, vectors, count, offset in + let result = Darwin.preadv(descriptor, vectors, count, offset) + return VirtioBlkHostIOResult( + count: result, + code: result < 0 ? errno : 0 + ) + }, + write: { descriptor, vectors, count, offset in + let result = Darwin.pwritev(descriptor, vectors, count, offset) + return VirtioBlkHostIOResult( + count: result, + code: result < 0 ? errno : 0 + ) + }, + monotonicNanoseconds: { DispatchTime.now().uptimeNanoseconds } + ) + } +} + +struct VirtioBlkRangeOperations: @unchecked Sendable { + typealias PunchHoleOperation = (Int32, off_t, off_t) -> VirtioBlkHostIOResult + + var punchHole: PunchHoleOperation + + static var production: Self { + Self(punchHole: { descriptor, offset, length in + var punch = fpunchhole_t( + fp_flags: 0, + reserved: 0, + fp_offset: offset, + fp_length: length + ) + let result = withUnsafeMutablePointer(to: &punch) { + fcntl(descriptor, F_PUNCHHOLE, $0) + } + return VirtioBlkHostIOResult( + count: Int(result), + code: result < 0 ? errno : 0 + ) + }) + } +} + +struct VirtioBlkFlushTelemetryConfiguration { + var slowThresholdNanoseconds: UInt64 + var synchronize: (Int32) -> Int32 + var monotonicNanoseconds: () -> UInt64 + + static var production: Self { + Self( + slowThresholdNanoseconds: 250_000_000, + synchronize: { descriptor in Darwin.fsync(descriptor) }, + monotonicNanoseconds: { DispatchTime.now().uptimeNanoseconds } + ) + } +} + +/// virtio-blk backed by a raw disk image. Requests use zero-copy preadv/pwritev straight into guest /// RAM; disk I/O is drained on dedicated ordered workers so the kicking vCPU is not parked inside -/// host file syscalls during metadata-heavy workloads. The device exposes a small multiqueue setup -/// by default; set `DORY_BLK_QUEUES=1` to force the legacy single-queue shape. +/// host file syscalls during metadata-heavy workloads. Production policy is explicit at the +/// initializer boundary; ambient process environment cannot silently change the guest ABI. public final class VirtioBlk: VirtioDeviceBackend { public let deviceID: UInt32 = 2 public let queueCount: Int + public let kickSynchronization: VirtioKickSynchronization = .backendManaged public var deviceFeatures: UInt64 { var features = Self.Feature.flush if readOnly { @@ -24,18 +309,163 @@ public final class VirtioBlk: VirtioDeviceBackend { private let fileDescriptor: Int32 private let capacitySectors: UInt64 + private let capacityBytes: UInt64 private let identity: String private let readOnly: Bool private let asyncIO: Bool private let discardEnabled: Bool private let discardBlockSize: Int + private let limits: VirtioBlkLimits + private let ioOperations: VirtioBlkIOOperations + private let rangeOperations: VirtioBlkRangeOperations private let ioQueues: [DispatchQueue] + private let ioQueueKey = DispatchSpecificKey() private let drainLock = NSLock() - private var activeDrainers: [Bool] - private var kickGenerations: [UInt64] + private var deviceIsReady = false + private var drainIsTerminal = false + private var queueDrainStates: [QueueDrainState] + private var queueDepthHighWatermark = 0 private let requestCondition = NSCondition() private var inFlightTransfers = 0 + private var maximumInFlightTransferCount = 0 private var flushActive = false + private let flushTelemetry: VirtioBlkFlushTelemetryConfiguration + private let statisticsLock = NSLock() + private var flushCount: UInt64 = 0 + private var maximumFlushLatencyNanoseconds: UInt64 = 0 + private var slowFlushCount: UInt64 = 0 + private var invalidRequestCount: UInt64 = 0 + private var queuePopFaultCount: UInt64 = 0 + private var completionFaultCount: UInt64 = 0 + private var boundedDrainStopCount: UInt64 = 0 + private var queueWorkTurnCount: UInt64 = 0 + private var requestCompletionCount: UInt64 = 0 + private var revokedRequestCount: UInt64 = 0 + private var requestServiceLatencyNanoseconds: UInt64 = 0 + private var maximumRequestServiceLatencyNanoseconds: UInt64 = 0 + private var readRequestCount: UInt64 = 0 + private var writeRequestCount: UInt64 = 0 + private var readByteCount: UInt64 = 0 + private var writeByteCount: UInt64 = 0 + private var readSystemCallCount: UInt64 = 0 + private var writeSystemCallCount: UInt64 = 0 + private var partialIOSystemCallCount: UInt64 = 0 + private var interruptedIOSystemCallCount: UInt64 = 0 + private var failedIOSystemCallCount: UInt64 = 0 + private var hostIOBudgetExhaustionCount: UInt64 = 0 + private var transferSegmentCount: UInt64 = 0 + private var discardRequestCount: UInt64 = 0 + private var discardRequestedByteCount: UInt64 = 0 + private var discardHostOperationCount: UInt64 = 0 + private var discardIgnoredRangeCount: UInt64 = 0 + private var writeZeroesRequestCount: UInt64 = 0 + private var writeZeroesRequestedByteCount: UInt64 = 0 + private var writeZeroesHostWrittenByteCount: UInt64 = 0 + private var writeZeroesHostOperationCount: UInt64 = 0 + private var rangePartialHostOperationCount: UInt64 = 0 + private var rangeInterruptedHostOperationCount: UInt64 = 0 + private var rangeFailedHostOperationCount: UInt64 = 0 + private var rangeHostOperationBudgetExhaustionCount: UInt64 = 0 + private var rangeSegmentCount: UInt64 = 0 + private var rangeTurnBudgetStopCount: UInt64 = 0 + + private struct QueueDrainState { + var generation: UInt64 = 1 + var transportIdentity: ObjectIdentifier? + var activeGeneration: UInt64? + var kickPending = false + var queueDepth = 0 + + mutating func advanceGeneration() { + generation &+= 1 + if generation == 0 { generation = 1 } + } + + mutating func revoke(replacementTransportIdentity: ObjectIdentifier?) { + advanceGeneration() + transportIdentity = replacementTransportIdentity + activeGeneration = nil + kickPending = false + queueDepth = 0 + } + } + + private struct DrainEpoch: Sendable { + let queue: Int + let generation: UInt64 + let transportIdentity: ObjectIdentifier + } + + private enum DrainBatchOutcome { + case drained + case pending + case fault + case stale + } + + private enum QueuePopOutcome { + case chain(VirtqueueChain) + case empty + case fault + case stale + } + + private enum QueueDepthOutcome { + case depth(Int) + case fault + case stale + } + + private enum QueuePushOutcome { + case published(wantsInterrupt: Bool) + case fault + case stale + } + + private struct RequestExecution { + let written: Int + let workBytes: Int + let rangeHostOperations: Int + + init(written: Int, workBytes: Int, rangeHostOperations: Int = 0) { + self.written = written + self.workBytes = workBytes + self.rangeHostOperations = rangeHostOperations + } + } + + private struct HostTransferReceipt { + var status: RequestStatus + var actualBytes = 0 + var systemCalls = 0 + var partialSystemCalls = 0 + var interruptedSystemCalls = 0 + var failedSystemCalls = 0 + var budgetExhaustions = 0 + } + + private struct RangeCommandExecution { + var status: RequestStatus + var requestedBytes: UInt64 = 0 + var hostWrittenBytes = 0 + var hostOperations = 0 + var partialHostOperations = 0 + var interruptedHostOperations = 0 + var failedHostOperations = 0 + var hostOperationBudgetExhaustions = 0 + var segmentCount = 0 + var ignoredDiscardRanges = 0 + var fairnessBytes = 0 + + mutating func mergeHostTransfer(_ receipt: HostTransferReceipt) { + hostWrittenBytes &+= receipt.actualBytes + hostOperations &+= receipt.systemCalls + partialHostOperations &+= receipt.partialSystemCalls + interruptedHostOperations &+= receipt.interruptedSystemCalls + failedHostOperations &+= receipt.failedSystemCalls + hostOperationBudgetExhaustions &+= receipt.budgetExhaustions + } + } private enum Feature { static let readOnly: UInt64 = 1 << 5 // VIRTIO_BLK_F_RO @@ -45,11 +475,11 @@ public final class VirtioBlk: VirtioDeviceBackend { static let writeZeroes: UInt64 = 1 << 14 // VIRTIO_BLK_F_WRITE_ZEROES } - // Per-segment discard/write-zeroes tunables surfaced in config space. Generous single-segment caps - // (2 GiB) keep fstrim from fragmenting into many round trips; punch-hole handles any length. + // DISCARD remains range-efficient because one aligned range is one hole-punch operation. Plain + // WRITE_ZEROES is intentionally advertised as at most 16 MiB across four segments: the host + // must write those bytes to preserve allocation, and the guest splits larger work fairly. private enum Discard { static let maxSectors: UInt32 = 1 << 22 // 2 GiB / 512 - static let maxSegments: UInt32 = 256 static let sectorAlignment: UInt32 = 1 static let entryByteCount = 16 // struct virtio_blk_discard_write_zeroes static let unmapFlag: UInt32 = 1 << 0 // VIRTIO_BLK_WRITE_ZEROES_FLAG_UNMAP @@ -70,44 +500,289 @@ public final class VirtioBlk: VirtioDeviceBackend { case unsupported = 2 } - public init( + struct ByteRange: Equatable { + let offset: off_t + let length: off_t + } + + private struct BackingFile { + let descriptor: Int32 + let capacitySectors: UInt64 + let capacityBytes: UInt64 + let discardBlockSize: Int + } + + private struct DiscardOperation { + let range: ByteRange + let deallocate: Bool + } + + public convenience init( + path: String, + identity: String, + readOnly: Bool = false, + queueCount requestedQueueCount: Int? = nil, + discard: Bool? = nil + ) throws { + try self.init( + path: path, + identity: identity, + readOnly: readOnly, + asyncIO: true, + queueCount: requestedQueueCount, + discard: discard, + flushTelemetry: .production + ) + } + + /// Synchronous execution exists only as a deterministic unit-test seam. Production callers + /// cannot opt a vCPU back into host file I/O through the public initializer. + convenience init( path: String, identity: String, readOnly: Bool = false, - asyncIO: Bool? = nil, + asyncIO: Bool, queueCount requestedQueueCount: Int? = nil, discard: Bool? = nil ) throws { - let descriptor = open(path, readOnly ? O_RDONLY : O_RDWR) + try self.init( + path: path, + identity: identity, + readOnly: readOnly, + asyncIO: asyncIO, + queueCount: requestedQueueCount, + discard: discard, + flushTelemetry: .production + ) + } + + convenience init( + path: String, + identity: String, + readOnly: Bool = false, + asyncIO: Bool = true, + queueCount requestedQueueCount: Int? = nil, + discard: Bool? = nil, + flushTelemetry: VirtioBlkFlushTelemetryConfiguration, + limits: VirtioBlkLimits = .production, + ioOperations: VirtioBlkIOOperations = .production, + rangeOperations: VirtioBlkRangeOperations = .production + ) throws { + let descriptor = open( + path, + (readOnly ? O_RDONLY : O_RDWR) | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK + ) guard descriptor >= 0 else { throw VMError.invalidConfiguration("cannot open disk image \(path): errno \(errno)") } - var info = stat() - guard fstat(descriptor, &info) == 0 else { + do { + let backing = try Self.inspectOwnedDescriptor( + descriptor, + readOnly: readOnly, + description: "disk image \(path)" + ) + try self.init( + backing: backing, + identity: identity, + readOnly: readOnly, + asyncIO: asyncIO, + queueCount: requestedQueueCount, + discard: discard, + flushTelemetry: flushTelemetry, + limits: limits, + ioOperations: ioOperations, + rangeOperations: rangeOperations + ) + } catch { + close(descriptor) + throw error + } + } + + /// Creates a block backend from an already-open regular-file descriptor. The descriptor is + /// duplicated with close-on-exec, so the caller retains ownership of the original descriptor. + /// This is the seam a future broker can use to hand an authorized image descriptor to the VMM + /// without making the VMM resolve a path itself. + public convenience init( + fileDescriptor: Int32, + identity: String, + readOnly: Bool = false, + queueCount requestedQueueCount: Int? = nil, + discard: Bool? = nil + ) throws { + try self.init( + fileDescriptor: fileDescriptor, + identity: identity, + readOnly: readOnly, + asyncIO: true, + queueCount: requestedQueueCount, + discard: discard, + flushTelemetry: .production + ) + } + + convenience init( + fileDescriptor: Int32, + identity: String, + readOnly: Bool = false, + asyncIO: Bool, + queueCount requestedQueueCount: Int? = nil, + discard: Bool? = nil + ) throws { + try self.init( + fileDescriptor: fileDescriptor, + identity: identity, + readOnly: readOnly, + asyncIO: asyncIO, + queueCount: requestedQueueCount, + discard: discard, + flushTelemetry: .production + ) + } + + convenience init( + fileDescriptor: Int32, + identity: String, + readOnly: Bool = false, + asyncIO: Bool = true, + queueCount requestedQueueCount: Int? = nil, + discard: Bool? = nil, + flushTelemetry: VirtioBlkFlushTelemetryConfiguration, + limits: VirtioBlkLimits = .production, + ioOperations: VirtioBlkIOOperations = .production, + rangeOperations: VirtioBlkRangeOperations = .production + ) throws { + let descriptor = fcntl(fileDescriptor, F_DUPFD_CLOEXEC, 0) + guard descriptor >= 0 else { + throw VMError.invalidConfiguration("cannot duplicate disk image descriptor: errno \(errno)") + } + do { + let backing = try Self.inspectOwnedDescriptor( + descriptor, + readOnly: readOnly, + description: "disk image descriptor" + ) + try self.init( + backing: backing, + identity: identity, + readOnly: readOnly, + asyncIO: asyncIO, + queueCount: requestedQueueCount, + discard: discard, + flushTelemetry: flushTelemetry, + limits: limits, + ioOperations: ioOperations, + rangeOperations: rangeOperations + ) + } catch { close(descriptor) - throw VMError.invalidConfiguration("cannot stat disk image \(path)") + throw error + } + } + + private init( + backing: BackingFile, + identity: String, + readOnly: Bool, + asyncIO: Bool, + queueCount requestedQueueCount: Int?, + discard: Bool?, + flushTelemetry: VirtioBlkFlushTelemetryConfiguration, + limits: VirtioBlkLimits, + ioOperations: VirtioBlkIOOperations, + rangeOperations: VirtioBlkRangeOperations + ) throws { + let resolvedQueueCount = requestedQueueCount ?? 1 + guard (1...16).contains(resolvedQueueCount) else { + throw VMError.invalidConfiguration( + "virtio-blk queue count must be resolved within 1...16" + ) } - self.fileDescriptor = descriptor - self.capacitySectors = UInt64(info.st_size) / 512 + self.fileDescriptor = backing.descriptor + self.capacitySectors = backing.capacitySectors + self.capacityBytes = backing.capacityBytes self.identity = identity self.readOnly = readOnly - self.asyncIO = asyncIO ?? Self.asyncIOEnabledFromEnvironment() + self.asyncIO = asyncIO + self.flushTelemetry = flushTelemetry // Discard/write-zeroes only make sense on a writable image; keep them off for read-only shares. - self.discardEnabled = !readOnly && (discard ?? Self.discardEnabledFromEnvironment()) + self.discardEnabled = !readOnly && (discard ?? true) + self.discardBlockSize = backing.discardBlockSize + self.limits = limits + self.ioOperations = ioOperations + self.rangeOperations = rangeOperations + self.queueCount = resolvedQueueCount + self.ioQueues = (0.. BackingFile { + var info = stat() + guard fstat(descriptor, &info) == 0 else { + throw VMError.invalidConfiguration("cannot stat \(description): errno \(errno)") + } + guard (info.st_mode & S_IFMT) == S_IFREG else { + throw VMError.invalidConfiguration("\(description) is not a regular file") + } + guard info.st_size >= 0 else { + throw VMError.invalidConfiguration("\(description) has an invalid size") + } + + let descriptorFlags = fcntl(descriptor, F_GETFL) + guard descriptorFlags >= 0 else { + throw VMError.invalidConfiguration("cannot inspect \(description) access mode: errno \(errno)") + } + let accessMode = descriptorFlags & O_ACCMODE + guard accessMode != O_WRONLY, readOnly || accessMode == O_RDWR else { + throw VMError.invalidConfiguration("\(description) does not have the required access mode") + } + + // Virtio-blk advertises capacity in complete 512-byte sectors. Freeze the addressable byte + // capacity at construction so later requests cannot grow the image or reach a trailing + // partial sector even if the path is replaced or the backing file is subsequently enlarged. + let fileSize = UInt64(info.st_size) + let capacitySectors = fileSize / 512 + let capacityBytes = capacitySectors * 512 + // F_PUNCHHOLE requires fs-block alignment; capture the backing filesystem's block size so // sub-block discard slivers can be zero-written instead of failing the whole request. var fsInfo = statfs() let blockSize = fstatfs(descriptor, &fsInfo) == 0 ? Int(fsInfo.f_bsize) : 4096 - self.discardBlockSize = blockSize > 0 ? blockSize : 4096 - self.queueCount = Self.clampedQueueCount(requestedQueueCount ?? Self.queueCountFromEnvironment()) - self.ioQueues = (0.. 0 ? blockSize : 4096 + ) } deinit { + drainLock.withLock { + drainIsTerminal = true + deviceIsReady = false + for index in queueDrainStates.indices { + queueDrainStates[index].revoke(replacementTransportIdentity: nil) + } + } + // Enqueued work captures the backend weakly. Join every other queue so no task can acquire + // the backing descriptor after this point; an executing task retains self until its one + // bounded turn has returned, so deinit can only run on that queue after its host I/O ends. + let currentQueue = DispatchQueue.getSpecific(key: ioQueueKey) + for (index, queue) in ioQueues.enumerated() where currentQueue != index { + queue.sync {} + } close(fileDescriptor) } @@ -117,79 +792,374 @@ public final class VirtioBlk: VirtioDeviceBackend { config.append(contentsOf: Array(repeating: 0, count: 26)) // @8..33 withUnsafeBytes(of: UInt16(queueCount).littleEndian) { config.append(contentsOf: $0) } // num_queues @34 guard discardEnabled else { return config } + let maximumDiscardSegments = UInt32(limits.maximumDiscardSegmentsPerRequest) + let maximumWriteZeroesSectors = UInt32( + limits.maximumWriteZeroesBytesPerRequest + / limits.maximumWriteZeroesSegmentsPerRequest / 512 + ) + let maximumWriteZeroesSegments = UInt32( + limits.maximumWriteZeroesSegmentsPerRequest + ) withUnsafeBytes(of: Discard.maxSectors.littleEndian) { config.append(contentsOf: $0) } // max_discard_sectors @36 - withUnsafeBytes(of: Discard.maxSegments.littleEndian) { config.append(contentsOf: $0) } // max_discard_seg @40 + withUnsafeBytes(of: maximumDiscardSegments.littleEndian) { config.append(contentsOf: $0) } // max_discard_seg @40 withUnsafeBytes(of: Discard.sectorAlignment.littleEndian) { config.append(contentsOf: $0) } // discard_sector_alignment @44 - withUnsafeBytes(of: Discard.maxSectors.littleEndian) { config.append(contentsOf: $0) } // max_write_zeroes_sectors @48 - withUnsafeBytes(of: Discard.maxSegments.littleEndian) { config.append(contentsOf: $0) } // max_write_zeroes_seg @52 + withUnsafeBytes(of: maximumWriteZeroesSectors.littleEndian) { config.append(contentsOf: $0) } // max_write_zeroes_sectors @48 + withUnsafeBytes(of: maximumWriteZeroesSegments.littleEndian) { config.append(contentsOf: $0) } // max_write_zeroes_seg @52 config.append(1) // write_zeroes_may_unmap @56 config.append(contentsOf: [0, 0, 0]) // unused @57..59 return config } public func handleKick(queue: Int, transport: VirtioMMIOTransport) { - guard queue >= 0, queue < queueCount else { return } - guard asyncIO else { - drainInline(queue: queue, transport: transport) - return - } - let shouldStart: Bool = drainLock.withLock { - kickGenerations[queue] &+= 1 - guard !activeDrainers[queue] else { return false } - activeDrainers[queue] = true - return true + guard (0.. DrainEpoch? { + let identity = ObjectIdentifier(transport) + return drainLock.withLock { + guard !drainIsTerminal, + deviceIsReady, + queueDrainStates[queue].transportIdentity == identity else { return nil } + queueDrainStates[queue].kickPending = true + guard queueDrainStates[queue].activeGeneration == nil else { return nil } + queueDrainStates[queue].kickPending = false + let generation = queueDrainStates[queue].generation + queueDrainStates[queue].activeGeneration = generation + return DrainEpoch( + queue: queue, + generation: generation, + transportIdentity: identity + ) + } + } + + private func enqueueDrain(_ epoch: DrainEpoch, transport: VirtioMMIOTransport) { + ioQueues[epoch.queue].async { [weak self, weak transport] in + guard let self, let transport else { return } + let outcome = self.drainBatch(epoch: epoch, transport: transport) + self.finishDrain( + epoch, + transport: transport, + outcome: outcome, + mayContinue: true + ) + } + } + + private func finishDrain( + _ epoch: DrainEpoch, + transport: VirtioMMIOTransport, + outcome: DrainBatchOutcome, + mayContinue: Bool + ) { + let shouldContinue = drainLock.withLock { () -> Bool in + guard isCurrentLocked(epoch) else { return false } + let hasNewKick = queueDrainStates[epoch.queue].kickPending + switch outcome { + case .pending where mayContinue: + queueDrainStates[epoch.queue].kickPending = false + return true + case .fault where mayContinue && hasNewKick: + queueDrainStates[epoch.queue].kickPending = false + return true + case .drained where mayContinue && hasNewKick: + queueDrainStates[epoch.queue].kickPending = false + return true + case .drained, .pending, .fault: + queueDrainStates[epoch.queue].activeGeneration = nil + return false + case .stale: + return false + } + } + if shouldContinue { enqueueDrain(epoch, transport: transport) } + } + + private func drainBatch( + epoch: DrainEpoch, + transport: VirtioMMIOTransport + ) -> DrainBatchOutcome { + guard isCurrent(epoch) else { return .stale } + recordQueueWorkTurn() var interrupt = false - while let chain = (transport.withQueueLock { (try? virtqueue.pop()) ?? nil }) { - let written = process(chain: chain) - let wants = transport.withQueueLock { (try? virtqueue.push(chain, written: written)) ?? false } - interrupt = interrupt || wants + var handled = 0 + var processedBytes = 0 + var processedRangeHostOperations = 0 + + switch pendingDepth(epoch: epoch, transport: transport) { + case let .depth(depth): + observeQueueDepth(depth, epoch: epoch) + if depth == 0 { return .drained } + case .fault: + recordQueuePopFault() + return .fault + case .stale: + return .stale } - if interrupt { - transport.notifyUsed() + + while handled < limits.maximumChainsPerDrain, + processedBytes < limits.maximumTransferBytesPerDrain, + processedRangeHostOperations < limits.maximumRangeHostOperationsPerDrain { + let chain: VirtqueueChain + switch popNext(epoch: epoch, transport: transport) { + case let .chain(next): + chain = next + case .empty: + observeQueueDepth(0, epoch: epoch) + notifyIfCurrent(interrupt, epoch: epoch, transport: transport) + return .drained + case .fault: + recordQueuePopFault() + notifyIfCurrent(interrupt, epoch: epoch, transport: transport) + return .fault + case .stale: + return .stale + } + handled += 1 + + let startedAt = ioOperations.monotonicNanoseconds() + guard let execution = process(chain: chain, epoch: epoch) else { + recordRevokedRequest() + return .stale + } + let (nextProcessedBytes, byteCountOverflow) = processedBytes.addingReportingOverflow( + execution.workBytes + ) + processedBytes = byteCountOverflow ? Int.max : nextProcessedBytes + let (nextRangeOperations, rangeOperationOverflow) = + processedRangeHostOperations.addingReportingOverflow( + execution.rangeHostOperations + ) + processedRangeHostOperations = rangeOperationOverflow + ? Int.max : nextRangeOperations + switch pushCompletion( + chain, + written: execution.written, + epoch: epoch, + transport: transport + ) { + case let .published(wantsInterrupt): + interrupt = wantsInterrupt || interrupt + recordRequestCompletion(startedAt: startedAt) + case .stale: + recordRevokedRequest() + return .stale + case .fault: + // Host I/O may already have committed. Surface the failed publication as an exact + // outcome-unknown boundary instead of converting it into an empty queue. + recordCompletionFault() + notifyIfCurrent(interrupt, epoch: epoch, transport: transport) + return .fault + } + } + + switch pendingDepth(epoch: epoch, transport: transport) { + case let .depth(depth) where depth > 0: + observeQueueDepth(depth, epoch: epoch) + recordBoundedDrainStop() + if processedRangeHostOperations >= limits.maximumRangeHostOperationsPerDrain { + recordRangeTurnBudgetStop() + } + notifyIfCurrent(interrupt, epoch: epoch, transport: transport) + return .pending + case .depth: + observeQueueDepth(0, epoch: epoch) + notifyIfCurrent(interrupt, epoch: epoch, transport: transport) + return .drained + case .fault: + recordQueuePopFault() + notifyIfCurrent(interrupt, epoch: epoch, transport: transport) + return .fault + case .stale: + return .stale } } - private func drain(queue: Int, transport: VirtioMMIOTransport) { - let virtqueue = transport.queues[queue] - while true { - let generation = drainLock.withLock { kickGenerations[queue] } - var interrupt = false - while let chain = (transport.withQueueLock { (try? virtqueue.pop()) ?? nil }) { - let written = process(chain: chain) - let wants = transport.withQueueLock { (try? virtqueue.push(chain, written: written)) ?? false } - interrupt = interrupt || wants + private func popNext( + epoch: DrainEpoch, + transport: VirtioMMIOTransport + ) -> QueuePopOutcome { + transport.withQueueLock { + guard isCurrent(epoch) else { return .stale } + do { + return try transport.queues[epoch.queue].pop().map(QueuePopOutcome.chain) ?? .empty + } catch { + return .fault } - if interrupt { - transport.notifyUsed() + } + } + + private func pendingDepth( + epoch: DrainEpoch, + transport: VirtioMMIOTransport + ) -> QueueDepthOutcome { + transport.withQueueLock { + guard isCurrent(epoch) else { return .stale } + do { + return .depth(Int(try transport.queues[epoch.queue].pendingCount())) + } catch { + return .fault } - let exit = drainLock.withLock { - guard kickGenerations[queue] == generation else { return false } - activeDrainers[queue] = false - return true + } + } + + private func pushCompletion( + _ chain: VirtqueueChain, + written: Int, + epoch: DrainEpoch, + transport: VirtioMMIOTransport + ) -> QueuePushOutcome { + transport.withQueueLock { + guard isCurrent(epoch) else { return .stale } + do { + switch try transport.queues[epoch.queue].pushOutcome(chain, written: written) { + case let .published(wantsInterrupt): + return .published(wantsInterrupt: wantsInterrupt) + case .revoked: + return isCurrent(epoch) ? .fault : .stale + } + } catch { + return isCurrent(epoch) ? .fault : .stale } - if exit { break } } } - private func process(chain: VirtqueueChain) -> Int { - let segments = chain.segments + private func process( + chain: VirtqueueChain, + epoch: DrainEpoch + ) -> RequestExecution? { + // Disk I/O may outlive the queue kick. Hold the chain lease for every direct guest-pointer + // read/write so reset or QueueReady reconfiguration cannot let the guest repurpose the + // buffer until the host operation and status byte are complete. Admission orders the + // lifecycle lock before the lease: a reset either revokes this work first or waits for the + // one already-admitted bounded request, never allowing post-reset I/O to begin. + drainLock.lock() + guard isCurrentLocked(epoch) else { + drainLock.unlock() + return nil + } + var enteredLease = false + let execution = chain.withLeaseHeld { access in + enteredLease = true + drainLock.unlock() + return process( + segments: access.segments, + containsZeroLengthDescriptor: chain.containsZeroLengthDescriptor + ) + } + if !enteredLease { drainLock.unlock() } + return execution + } + + private func isCurrent(_ epoch: DrainEpoch) -> Bool { + drainLock.withLock { isCurrentLocked(epoch) } + } + + private func isCurrentLocked(_ epoch: DrainEpoch) -> Bool { + !drainIsTerminal + && deviceIsReady + && queueDrainStates.indices.contains(epoch.queue) + && queueDrainStates[epoch.queue].generation == epoch.generation + && queueDrainStates[epoch.queue].activeGeneration == epoch.generation + && queueDrainStates[epoch.queue].transportIdentity == epoch.transportIdentity + } + + private func observeQueueDepth(_ depth: Int, epoch: DrainEpoch) { + drainLock.withLock { + guard isCurrentLocked(epoch) else { return } + queueDrainStates[epoch.queue].queueDepth = max(0, depth) + let aggregate = queueDrainStates.reduce(0) { partial, state in + let (next, overflow) = partial.addingReportingOverflow(state.queueDepth) + return overflow ? Int.max : next + } + queueDepthHighWatermark = max(queueDepthHighWatermark, aggregate) + } + } + + private func notifyIfCurrent( + _ wantsInterrupt: Bool, + epoch: DrainEpoch, + transport: VirtioMMIOTransport + ) { + if wantsInterrupt, isCurrent(epoch) { transport.notifyUsed() } + } + + private func process( + segments: [VirtqueueSegment], + containsZeroLengthDescriptor: Bool + ) -> RequestExecution { guard segments.count >= 2, - !segments[0].isDeviceWritable, segments[0].length >= 16, + !segments[0].isDeviceWritable, segments[0].length == 16, let statusSegment = segments.last, statusSegment.isDeviceWritable, statusSegment.length >= 1 else { - return 0 + recordInvalidRequest() + return RequestExecution(written: 0, workBytes: 0) + } + + guard !containsZeroLengthDescriptor else { + statusSegment.pointer.storeBytes(of: RequestStatus.ioError.rawValue, as: UInt8.self) + recordInvalidRequest() + return RequestExecution(written: 1, workBytes: 0) } let header = segments[0].pointer let rawType = header.loadUnaligned(fromByteOffset: 0, as: UInt32.self) - let sector = header.loadUnaligned(fromByteOffset: 8, as: UInt64.self) + let sector = UInt64(littleEndian: header.loadUnaligned(fromByteOffset: 8, as: UInt64.self)) let dataSegments = segments[1..<(segments.count - 1)] + var workBytes = dataSegments.reduce(into: 0) { total, segment in + let (next, overflow) = total.addingReportingOverflow(segment.length) + total = overflow ? Int.max : next + } + var rangeHostOperations = 0 var written = 0 let status: RequestStatus @@ -203,50 +1173,101 @@ public final class VirtioBlk: VirtioDeviceBackend { transfer(dataSegments, from: sector, into: &written, reading: false) } case .discard: - status = readOnly ? .ioError : withTransferPermit { - applyDiscardOrWriteZeroes(dataSegments, writeZeroes: false) + if readOnly { + status = .ioError + } else { + let execution = withTransferPermit { + executeDiscardOrWriteZeroes(dataSegments, writeZeroes: false) + } + status = execution.status + workBytes = max(workBytes, execution.fairnessBytes) + rangeHostOperations = execution.hostOperations } case .writeZeroes: - status = readOnly ? .ioError : withTransferPermit { - applyDiscardOrWriteZeroes(dataSegments, writeZeroes: true) + if readOnly { + status = .ioError + } else { + let execution = withTransferPermit { + executeDiscardOrWriteZeroes(dataSegments, writeZeroes: true) + } + status = execution.status + workBytes = max(workBytes, execution.fairnessBytes) + rangeHostOperations = execution.hostOperations } case .flush: - status = flush() + // VIRTIO_BLK_T_FLUSH has no data payload. Reject malformed chains instead of silently + // treating guest-provided buffers as part of a valid durability operation. + if dataSegments.isEmpty { + status = flush() + } else { + recordInvalidRequest() + status = .ioError + } case .getID: - let id = [UInt8](identity.utf8.prefix(20)) - for segment in dataSegments where segment.isDeviceWritable { - let count = min(segment.length, id.count) - id.withUnsafeBytes { segment.pointer.copyMemory(from: $0.baseAddress!, byteCount: count) } - written += count - break - } - status = .ok + written = writeIdentity(into: dataSegments) + if written == 20 { + status = .ok + } else { + recordInvalidRequest() + status = .ioError + } case nil: status = .unsupported } statusSegment.pointer.storeBytes(of: status.rawValue, as: UInt8.self) - return written + 1 + return RequestExecution( + written: written + 1, + workBytes: workBytes, + rangeHostOperations: rangeHostOperations + ) } - private func withTransferPermit(_ body: () -> RequestStatus) -> RequestStatus { + private func writeIdentity(into segments: ArraySlice) -> Int { + let identityByteCount = 20 + guard !segments.isEmpty, + segments.allSatisfy({ $0.isDeviceWritable && $0.length > 0 }) else { return 0 } + var capacity = 0 + for segment in segments { + let (next, overflow) = capacity.addingReportingOverflow(segment.length) + guard !overflow else { return 0 } + capacity = next + } + guard capacity >= identityByteCount else { return 0 } + + var encoded = [UInt8](repeating: 0, count: identityByteCount) + let source = Array(identity.utf8.prefix(identityByteCount)) + encoded.replaceSubrange(0..(_ body: () -> Result) -> Result { requestCondition.lock() while flushActive { requestCondition.wait() } inFlightTransfers += 1 + maximumInFlightTransferCount = max(maximumInFlightTransferCount, inFlightTransfers) requestCondition.unlock() - let status = body() + let result = body() requestCondition.lock() inFlightTransfers -= 1 requestCondition.broadcast() requestCondition.unlock() - return status + return result } - private func flush() -> RequestStatus { + func flush() -> RequestStatus { requestCondition.lock() while flushActive { requestCondition.wait() @@ -257,7 +1278,19 @@ public final class VirtioBlk: VirtioDeviceBackend { } requestCondition.unlock() - let status: RequestStatus = fsync(fileDescriptor) == 0 ? .ok : .ioError + let startedAt = flushTelemetry.monotonicNanoseconds() + let status: RequestStatus = flushTelemetry.synchronize(fileDescriptor) == 0 ? .ok : .ioError + let finishedAt = flushTelemetry.monotonicNanoseconds() + let duration = finishedAt >= startedAt ? finishedAt - startedAt : 0 + statisticsLock.withLock { + if flushCount < UInt64.max { + flushCount += 1 + } + maximumFlushLatencyNanoseconds = max(maximumFlushLatencyNanoseconds, duration) + if duration >= flushTelemetry.slowThresholdNanoseconds, slowFlushCount < UInt64.max { + slowFlushCount += 1 + } + } requestCondition.lock() flushActive = false @@ -266,167 +1299,599 @@ public final class VirtioBlk: VirtioDeviceBackend { return status } - private func transfer( + public var statistics: VirtioBlkStatistics { + let queueGauges = drainLock.withLock { + ( + depth: queueDrainStates.reduce(0) { $0 + $1.queueDepth }, + highWatermark: queueDepthHighWatermark + ) + } + let transferGauges: (current: Int, maximum: Int) = { + requestCondition.lock() + defer { requestCondition.unlock() } + return (inFlightTransfers, maximumInFlightTransferCount) + }() + return statisticsLock.withLock { + VirtioBlkStatistics( + flushes: flushCount, + maximumFlushLatencyNanoseconds: maximumFlushLatencyNanoseconds, + slowFlushes: slowFlushCount, + invalidRequests: invalidRequestCount, + queuePopFaults: queuePopFaultCount, + completionFaults: completionFaultCount, + boundedDrainStops: boundedDrainStopCount, + queueWorkTurns: queueWorkTurnCount, + queueDepth: UInt64(queueGauges.depth), + queueHighWatermark: UInt64(queueGauges.highWatermark), + requestCompletions: requestCompletionCount, + revokedRequests: revokedRequestCount, + requestServiceLatencyNanoseconds: requestServiceLatencyNanoseconds, + maximumRequestServiceLatencyNanoseconds: + maximumRequestServiceLatencyNanoseconds, + readRequests: readRequestCount, + writeRequests: writeRequestCount, + readBytes: readByteCount, + writeBytes: writeByteCount, + readSystemCalls: readSystemCallCount, + writeSystemCalls: writeSystemCallCount, + partialIOSystemCalls: partialIOSystemCallCount, + interruptedIOSystemCalls: interruptedIOSystemCallCount, + failedIOSystemCalls: failedIOSystemCallCount, + hostIOBudgetExhaustions: hostIOBudgetExhaustionCount, + transferSegments: transferSegmentCount, + inFlightTransfers: UInt64(transferGauges.current), + maximumInFlightTransfers: UInt64(transferGauges.maximum), + discardRequests: discardRequestCount, + discardRequestedBytes: discardRequestedByteCount, + discardHostOperations: discardHostOperationCount, + discardIgnoredRanges: discardIgnoredRangeCount, + writeZeroesRequests: writeZeroesRequestCount, + writeZeroesRequestedBytes: writeZeroesRequestedByteCount, + writeZeroesHostWrittenBytes: writeZeroesHostWrittenByteCount, + writeZeroesHostOperations: writeZeroesHostOperationCount, + rangePartialHostOperations: rangePartialHostOperationCount, + rangeInterruptedHostOperations: rangeInterruptedHostOperationCount, + rangeFailedHostOperations: rangeFailedHostOperationCount, + rangeHostOperationBudgetExhaustions: + rangeHostOperationBudgetExhaustionCount, + rangeSegments: rangeSegmentCount, + rangeTurnBudgetStops: rangeTurnBudgetStopCount + ) + } + } + + private func recordQueuePopFault() { + statisticsLock.withLock { queuePopFaultCount &+= 1 } + } + + private func recordInvalidRequest() { + statisticsLock.withLock { invalidRequestCount &+= 1 } + } + + private func recordCompletionFault() { + statisticsLock.withLock { completionFaultCount &+= 1 } + } + + private func recordBoundedDrainStop() { + statisticsLock.withLock { boundedDrainStopCount &+= 1 } + } + + private func recordRangeTurnBudgetStop() { + statisticsLock.withLock { rangeTurnBudgetStopCount &+= 1 } + } + + private func recordQueueWorkTurn() { + statisticsLock.withLock { queueWorkTurnCount &+= 1 } + } + + private func recordRevokedRequest() { + statisticsLock.withLock { revokedRequestCount &+= 1 } + } + + private func recordRequestCompletion(startedAt: UInt64) { + let finishedAt = ioOperations.monotonicNanoseconds() + let duration = finishedAt >= startedAt ? finishedAt - startedAt : 0 + statisticsLock.withLock { + requestCompletionCount &+= 1 + requestServiceLatencyNanoseconds &+= duration + maximumRequestServiceLatencyNanoseconds = max( + maximumRequestServiceLatencyNanoseconds, + duration + ) + } + } + + private func recordHostTransfer( + reading: Bool, + segments: Int, + receipt: HostTransferReceipt + ) { + statisticsLock.withLock { + if reading { + readRequestCount &+= 1 + readByteCount &+= UInt64(receipt.actualBytes) + readSystemCallCount &+= UInt64(receipt.systemCalls) + } else { + writeRequestCount &+= 1 + writeByteCount &+= UInt64(receipt.actualBytes) + writeSystemCallCount &+= UInt64(receipt.systemCalls) + } + partialIOSystemCallCount &+= UInt64(receipt.partialSystemCalls) + interruptedIOSystemCallCount &+= UInt64(receipt.interruptedSystemCalls) + failedIOSystemCallCount &+= UInt64(receipt.failedSystemCalls) + hostIOBudgetExhaustionCount &+= UInt64(receipt.budgetExhaustions) + transferSegmentCount &+= UInt64(segments) + } + } + + private func recordRangeCommand( + writeZeroes: Bool, + execution: RangeCommandExecution + ) { + statisticsLock.withLock { + if writeZeroes { + writeZeroesRequestCount &+= 1 + writeZeroesRequestedByteCount &+= execution.requestedBytes + writeZeroesHostWrittenByteCount &+= UInt64(execution.hostWrittenBytes) + writeZeroesHostOperationCount &+= UInt64(execution.hostOperations) + } else { + discardRequestCount &+= 1 + discardRequestedByteCount &+= execution.requestedBytes + discardHostOperationCount &+= UInt64(execution.hostOperations) + discardIgnoredRangeCount &+= UInt64(execution.ignoredDiscardRanges) + } + rangePartialHostOperationCount &+= UInt64(execution.partialHostOperations) + rangeInterruptedHostOperationCount &+= UInt64(execution.interruptedHostOperations) + rangeFailedHostOperationCount &+= UInt64(execution.failedHostOperations) + rangeHostOperationBudgetExhaustionCount &+= + UInt64(execution.hostOperationBudgetExhaustions) + rangeSegmentCount &+= UInt64(execution.segmentCount) + } + } + + static func checkedByteRange( + sector: UInt64, + byteCount: UInt64, + capacityBytes: UInt64 + ) -> ByteRange? { + let (byteOffset, offsetOverflow) = sector.multipliedReportingOverflow(by: 512) + guard !offsetOverflow, + byteOffset <= capacityBytes, + byteCount <= capacityBytes - byteOffset, + byteOffset <= UInt64(off_t.max), + byteCount <= UInt64(off_t.max) - byteOffset else { + return nil + } + return ByteRange(offset: off_t(byteOffset), length: off_t(byteCount)) + } + + static func checkedSectorRange( + sector: UInt64, + sectorCount: UInt64, + capacityBytes: UInt64 + ) -> ByteRange? { + let (byteCount, lengthOverflow) = sectorCount.multipliedReportingOverflow(by: 512) + guard !lengthOverflow else { return nil } + return checkedByteRange( + sector: sector, + byteCount: byteCount, + capacityBytes: capacityBytes + ) + } + + func transfer( _ segments: ArraySlice, from sector: UInt64, into written: inout Int, reading: Bool ) -> RequestStatus { - var offset = off_t(UInt64(littleEndian: sector) * 512) + // Validate direction and the complete aggregate range before the first syscall. This avoids + // partially mutating a disk when a later descriptor is malformed or outside capacity. + guard !segments.isEmpty else { return .ioError } + var totalByteCount: UInt64 = 0 for segment in segments { - if reading { - guard segment.isDeviceWritable else { return .ioError } - var done = 0 - while done < segment.length { - let bytes = pread(fileDescriptor, segment.pointer + done, segment.length - done, offset + off_t(done)) - guard bytes > 0 else { return .ioError } - done += bytes - } - written += segment.length - } else { - guard !segment.isDeviceWritable else { return .ioError } - var done = 0 - while done < segment.length { - let bytes = pwrite(fileDescriptor, segment.pointer + done, segment.length - done, offset + off_t(done)) - guard bytes > 0 else { return .ioError } - done += bytes + guard segment.length > 0, + segment.isDeviceWritable == reading else { return .ioError } + let (nextByteCount, overflow) = totalByteCount.addingReportingOverflow(UInt64(segment.length)) + guard !overflow else { return .ioError } + totalByteCount = nextByteCount + } + // Virtio block read/write payloads are expressed in complete 512-byte sectors. + guard totalByteCount > 0, + totalByteCount <= UInt64(limits.maximumTransferBytes), + totalByteCount % 512 == 0, + let range = Self.checkedByteRange( + sector: sector, + byteCount: totalByteCount, + capacityBytes: capacityBytes + ) else { return .ioError } + + // Copy only bounded iovec metadata. Guest payload bytes remain zero-copy and protected by + // the surrounding queue lease for the exact duration of every vectored host operation. + let transferSegments = Array(segments) + let receipt = performVectoredTransfer( + transferSegments, + at: range.offset, + reading: reading + ) + if reading { written += receipt.actualBytes } + recordHostTransfer( + reading: reading, + segments: transferSegments.count, + receipt: receipt + ) + return receipt.status + } + + private func performVectoredTransfer( + _ segments: [VirtqueueSegment], + at initialOffset: off_t, + reading: Bool + ) -> HostTransferReceipt { + var receipt = HostTransferReceipt(status: .ioError) + var segmentIndex = 0 + var segmentOffset = 0 + var fileOffset = initialOffset + let operation = reading ? ioOperations.read : ioOperations.write + + while segmentIndex < segments.count { + guard receipt.systemCalls < limits.maximumHostIOOperationsPerRequest else { + receipt.budgetExhaustions &+= 1 + return receipt + } + + var vectors = [iovec]() + vectors.reserveCapacity(limits.maximumIOVectorsPerSystemCall) + var offeredBytes = 0 + var vectorIndex = segmentIndex + var vectorOffset = segmentOffset + while vectorIndex < segments.count, + vectors.count < limits.maximumIOVectorsPerSystemCall { + let segment = segments[vectorIndex] + let length = segment.length - vectorOffset + vectors.append(iovec( + iov_base: segment.pointer + vectorOffset, + iov_len: length + )) + offeredBytes += length + vectorIndex += 1 + vectorOffset = 0 + } + + let result = vectors.withUnsafeBufferPointer { buffer in + operation( + fileDescriptor, + buffer.baseAddress!, + Int32(buffer.count), + fileOffset + ) + } + receipt.systemCalls &+= 1 + + if result.count < 0, result.code == EINTR { + receipt.interruptedSystemCalls &+= 1 + continue + } + guard result.count > 0, result.count <= offeredBytes else { + receipt.failedSystemCalls &+= 1 + return receipt + } + if result.count < offeredBytes { receipt.partialSystemCalls &+= 1 } + + receipt.actualBytes += result.count + fileOffset += off_t(result.count) + var consumed = result.count + while consumed > 0, segmentIndex < segments.count { + let available = segments[segmentIndex].length - segmentOffset + if consumed < available { + segmentOffset += consumed + consumed = 0 + } else { + consumed -= available + segmentIndex += 1 + segmentOffset = 0 } } - offset += off_t(segment.length) } - return .ok + + receipt.status = .ok + return receipt } // Applies a guest DISCARD or WRITE_ZEROES request. The data segments carry a packed array of - // `struct virtio_blk_discard_write_zeroes { le64 sector; le32 num_sectors; le32 flags; }`. Discard - // and unmap-flagged write-zeroes punch a hole (returning blocks to the host and reading back zeros); - // plain write-zeroes overwrites with zeros while keeping the allocation. Ranges are bounds-checked - // in sector space so a malformed guest request cannot touch bytes outside the image. - func applyDiscardOrWriteZeroes(_ segments: ArraySlice, writeZeroes: Bool) -> RequestStatus { - guard discardEnabled else { return .unsupported } + // `struct virtio_blk_discard_write_zeroes { le64 sector; le32 num_sectors; le32 flags; }`. + // Every entry and aggregate bound is validated before the first host operation. DISCARD may be + // ignored when the backing store cannot punch a hole, as permitted by virtio; WRITE_ZEROES must + // still produce zeros and therefore falls back to bounded allocation-preserving pwritev calls. + func applyDiscardOrWriteZeroes( + _ segments: ArraySlice, + writeZeroes: Bool + ) -> RequestStatus { + executeDiscardOrWriteZeroes(segments, writeZeroes: writeZeroes).status + } + + private func executeDiscardOrWriteZeroes( + _ segments: ArraySlice, + writeZeroes: Bool + ) -> RangeCommandExecution { + var execution = RangeCommandExecution(status: .ioError) + defer { recordRangeCommand(writeZeroes: writeZeroes, execution: execution) } + guard discardEnabled else { + execution.status = .unsupported + return execution + } + + let maximumSegments = writeZeroes + ? limits.maximumWriteZeroesSegmentsPerRequest + : limits.maximumDiscardSegmentsPerRequest + let maximumSectorsPerSegment = writeZeroes + ? UInt64( + limits.maximumWriteZeroesBytesPerRequest + / limits.maximumWriteZeroesSegmentsPerRequest / 512 + ) + : UInt64(Discard.maxSectors) var entryCount = 0 + var requestedBytes: UInt64 = 0 + var operations = [DiscardOperation]() + operations.reserveCapacity(maximumSegments) + for segment in segments { guard !segment.isDeviceWritable, segment.length >= Discard.entryByteCount, - segment.length % Discard.entryByteCount == 0 else { return .ioError } + segment.length % Discard.entryByteCount == 0 else { + return execution + } let segmentEntries = segment.length / Discard.entryByteCount - guard segmentEntries <= Int(Discard.maxSegments) - entryCount else { return .ioError } + guard segmentEntries <= maximumSegments - entryCount else { return execution } entryCount += segmentEntries var validationOffset = 0 while validationOffset + Discard.entryByteCount <= segment.length { let base = segment.pointer + validationOffset - let sector = UInt64(littleEndian: base.loadUnaligned(fromByteOffset: 0, as: UInt64.self)) - let numSectors = UInt64(UInt32(littleEndian: base.loadUnaligned(fromByteOffset: 8, as: UInt32.self))) - let flags = UInt32(littleEndian: base.loadUnaligned(fromByteOffset: 12, as: UInt32.self)) + let sector = UInt64(littleEndian: base.loadUnaligned( + fromByteOffset: 0, + as: UInt64.self + )) + let numSectors = UInt64(UInt32(littleEndian: base.loadUnaligned( + fromByteOffset: 8, + as: UInt32.self + ))) + let flags = UInt32(littleEndian: base.loadUnaligned( + fromByteOffset: 12, + as: UInt32.self + )) validationOffset += Discard.entryByteCount - guard numSectors <= UInt64(Discard.maxSectors) else { return .ioError } + guard numSectors <= maximumSectorsPerSegment else { return execution } if writeZeroes { - guard flags & ~Discard.unmapFlag == 0 else { return .unsupported } + guard flags & ~Discard.unmapFlag == 0 else { + execution.status = .unsupported + return execution + } } else { - guard flags == 0 else { return .unsupported } + guard flags == 0 else { + execution.status = .unsupported + return execution + } } - guard sector <= capacitySectors, - numSectors <= capacitySectors - sector else { return .ioError } + guard let range = Self.checkedSectorRange( + sector: sector, + sectorCount: numSectors, + capacityBytes: capacityBytes + ) else { return execution } + let rangeBytes = UInt64(range.length) + let (nextRequestedBytes, overflow) = requestedBytes.addingReportingOverflow( + rangeBytes + ) + guard !overflow, + !writeZeroes + || nextRequestedBytes <= UInt64( + limits.maximumWriteZeroesBytesPerRequest + ) else { return execution } + requestedBytes = nextRequestedBytes + operations.append(DiscardOperation( + range: range, + deallocate: !writeZeroes || (flags & Discard.unmapFlag) != 0 + )) } } - guard entryCount > 0 else { return .ioError } - for segment in segments { - var offset = 0 - while offset + Discard.entryByteCount <= segment.length { - let base = segment.pointer + offset - let sector = UInt64(littleEndian: base.loadUnaligned(fromByteOffset: 0, as: UInt64.self)) - let numSectors = UInt64(UInt32(littleEndian: base.loadUnaligned(fromByteOffset: 8, as: UInt32.self))) - let flags = UInt32(littleEndian: base.loadUnaligned(fromByteOffset: 12, as: UInt32.self)) - offset += Discard.entryByteCount - - guard numSectors > 0 else { continue } - - let byteOffset = off_t(sector * 512) - let byteLength = off_t(numSectors * 512) - let deallocate = !writeZeroes || (flags & Discard.unmapFlag) != 0 - let ok = deallocate - ? deallocateRange( - offset: byteOffset, - length: byteLength, - zeroFallback: writeZeroes - ) - : writeZerosPreservingAllocation(offset: byteOffset, length: byteLength) - guard ok else { return .ioError } + + guard entryCount > 0 else { return execution } + execution.requestedBytes = requestedBytes + execution.segmentCount = entryCount + if writeZeroes { + execution.fairnessBytes = Int(requestedBytes) + } + + for operation in operations where operation.range.length > 0 { + guard execution.hostOperations < limits.maximumRangeHostOperationsPerRequest else { + execution.hostOperationBudgetExhaustions &+= 1 + return execution + } + let succeeded: Bool + if operation.deallocate { + succeeded = deallocateRange( + offset: operation.range.offset, + length: operation.range.length, + zeroFallback: writeZeroes, + execution: &execution + ) + } else { + succeeded = writeZerosPreservingAllocation( + offset: operation.range.offset, + length: operation.range.length, + execution: &execution + ) } + guard succeeded else { return execution } } - return .ok + execution.status = .ok + return execution } - // Deallocates a byte range so it reads back as zeros and returns blocks to the host. F_PUNCHHOLE - // only accepts fs-block-aligned ranges, so this punches the aligned interior. WRITE_ZEROES must - // still read back as zero and therefore zero-writes unsupported/unaligned pieces; plain DISCARD - // may be ignored by the device and must never inflate a sparse image when hole punching is not - // available (for example, an external exFAT/SMB-backed home directory). - private func deallocateRange(offset: off_t, length: off_t, zeroFallback: Bool) -> Bool { + // Deallocates the aligned interior. Plain DISCARD is allowed to become an observable no-op when + // hole punching is unavailable. WRITE_ZEROES with UNMAP instead zero-writes the complete range + // on punch failure, and always zero-writes unaligned edges after a successful punch. + private func deallocateRange( + offset: off_t, + length: off_t, + zeroFallback: Bool, + execution: inout RangeCommandExecution + ) -> Bool { let block = off_t(discardBlockSize) let end = offset + length - let alignedStart = ((offset + block - 1) / block) * block - let alignedEnd = (end / block) * block + let startRemainder = offset % block + let startAdjustment = startRemainder == 0 ? 0 : block - startRemainder + guard startAdjustment < length else { + if zeroFallback { + return writeZerosPreservingAllocation( + offset: offset, + length: length, + execution: &execution + ) + } + execution.ignoredDiscardRanges &+= 1 + return true + } + let alignedStart = offset + startAdjustment + let alignedEnd = end - (end % block) guard alignedEnd > alignedStart else { - return zeroFallback ? writeZerosPreservingAllocation(offset: offset, length: length) : true + if zeroFallback { + return writeZerosPreservingAllocation( + offset: offset, + length: length, + execution: &execution + ) + } + execution.ignoredDiscardRanges &+= 1 + return true + } + guard execution.hostOperations < limits.maximumRangeHostOperationsPerRequest else { + execution.hostOperationBudgetExhaustions &+= 1 + return false } - var punch = fpunchhole_t(fp_flags: 0, reserved: 0, fp_offset: alignedStart, fp_length: alignedEnd - alignedStart) - let punched = withUnsafeMutablePointer(to: &punch) { fcntl(fileDescriptor, F_PUNCHHOLE, $0) } - guard punched == 0 else { - return zeroFallback ? writeZerosPreservingAllocation(offset: offset, length: length) : true + let punch = rangeOperations.punchHole( + fileDescriptor, + alignedStart, + alignedEnd - alignedStart + ) + execution.hostOperations &+= 1 + guard punch.count == 0 else { + execution.failedHostOperations &+= 1 + if zeroFallback { + return writeZerosPreservingAllocation( + offset: offset, + length: length, + execution: &execution + ) + } + execution.ignoredDiscardRanges &+= 1 + return true } if zeroFallback, alignedStart > offset, - !writeZerosPreservingAllocation(offset: offset, length: alignedStart - offset) { + !writeZerosPreservingAllocation( + offset: offset, + length: alignedStart - offset, + execution: &execution + ) { return false } if zeroFallback, end > alignedEnd, - !writeZerosPreservingAllocation(offset: alignedEnd, length: end - alignedEnd) { + !writeZerosPreservingAllocation( + offset: alignedEnd, + length: end - alignedEnd, + execution: &execution + ) { return false } return true } - private func writeZerosPreservingAllocation(offset: off_t, length: off_t) -> Bool { - let chunkSize = 64 * 1024 - let zeros = [UInt8](repeating: 0, count: chunkSize) - var remaining = Int(length) - var position = offset - return zeros.withUnsafeBytes { raw in - while remaining > 0 { - let take = min(chunkSize, remaining) - let written = pwrite(fileDescriptor, raw.baseAddress, take, position) - guard written > 0 else { return false } - remaining -= written - position += off_t(written) - } - return true - } + // Writes zeros through repeated references to one bounded 64 KiB buffer. A normal 16 MiB + // request is one 256-iovec pwritev; short results rebuild the exact remaining file range. + private func writeZerosPreservingAllocation( + offset: off_t, + length: off_t, + execution: inout RangeCommandExecution + ) -> Bool { + let remainingBudget = max( + 0, + limits.maximumRangeHostOperationsPerRequest - execution.hostOperations + ) + let receipt = performZeroWrite( + offset: offset, + length: length, + maximumHostOperations: remainingBudget + ) + execution.mergeHostTransfer(receipt) + return receipt.status == .ok } - private static func discardEnabledFromEnvironment() -> Bool { - guard let value = ProcessInfo.processInfo.environment["DORY_BLK_DISCARD"]?.lowercased() else { - return true + private func performZeroWrite( + offset: off_t, + length: off_t, + maximumHostOperations: Int + ) -> HostTransferReceipt { + var receipt = HostTransferReceipt(status: .ioError) + guard length > 0 else { + receipt.status = .ok + return receipt } - return !["0", "false", "no", "off"].contains(value) - } + let chunkSize = 64 * 1_024 + let zeros = [UInt8](repeating: 0, count: chunkSize) + var remaining = length + var position = offset + return zeros.withUnsafeBytes { zeroBuffer in + while remaining > 0 { + guard receipt.systemCalls < maximumHostOperations else { + receipt.budgetExhaustions &+= 1 + return receipt + } - private static func asyncIOEnabledFromEnvironment() -> Bool { - guard let value = ProcessInfo.processInfo.environment["DORY_BLK_ASYNC"]?.lowercased() else { - return true - } - return !["0", "false", "no", "off"].contains(value) - } + var vectors = [iovec]() + vectors.reserveCapacity(limits.maximumIOVectorsPerSystemCall) + var offeredBytes = 0 + var vectorRemaining = remaining + while vectorRemaining > 0, + vectors.count < limits.maximumIOVectorsPerSystemCall { + let count = Int(min(off_t(chunkSize), vectorRemaining)) + vectors.append(iovec( + iov_base: UnsafeMutableRawPointer( + mutating: zeroBuffer.baseAddress! + ), + iov_len: count + )) + offeredBytes += count + vectorRemaining -= off_t(count) + } - private static func queueCountFromEnvironment() -> Int { - guard let value = ProcessInfo.processInfo.environment["DORY_BLK_QUEUES"].flatMap(Int.init) else { - return min(4, max(1, ProcessInfo.processInfo.activeProcessorCount)) + let result = vectors.withUnsafeBufferPointer { buffer in + ioOperations.write( + fileDescriptor, + buffer.baseAddress!, + Int32(buffer.count), + position + ) + } + receipt.systemCalls &+= 1 + if result.count < 0, result.code == EINTR { + receipt.interruptedSystemCalls &+= 1 + continue + } + guard result.count > 0, result.count <= offeredBytes else { + receipt.failedSystemCalls &+= 1 + return receipt + } + if result.count < offeredBytes { + receipt.partialSystemCalls &+= 1 + } + receipt.actualBytes &+= result.count + remaining -= off_t(result.count) + position += off_t(result.count) + } + receipt.status = .ok + return receipt } - return value } - private static func clampedQueueCount(_ count: Int) -> Int { - min(16, max(1, count)) - } } extension VirtioBlk: @unchecked Sendable {} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift index 032a47cc..6bfa374d 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift @@ -1,17 +1,24 @@ +import DoryFSWorkerContracts import Foundation public enum VirtioFSError: Error, Equatable { case invalidTag(String) - case invalidDaxWindow } -public struct VirtioFSDaxConfiguration: Equatable, Sendable { - public var guestBase: UInt64 - public var length: UInt64 +/// Typed lifecycle boundary reported by one virtio-fs backend. A committed FUSE_DESTROY followed +/// by device reset is normal guest teardown; every other worker loss/reset remains a VM-fatal +/// authority failure. Callers must never infer this distinction from diagnostic strings. +public enum VirtioFSWorkerLifecycleEvent: Equatable, Sendable { + case connectionTeardown + case failure(String) - public init(guestBase: UInt64, length: UInt64 = DaxWindow.defaultSize) { - self.guestBase = guestBase - self.length = length + public var diagnostic: String { + switch self { + case .connectionTeardown: + "filesystem worker connection teardown committed" + case .failure(let reason): + reason + } } } @@ -35,17 +42,78 @@ public enum VirtioFSCacheActivationResult: Equatable, Sendable { case ineligible(VirtioFSCacheActivationEligibility) } -public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvider { +public struct VirtioFSStatistics: Equatable, Sendable { + public var invalidations: UInt64 + public var invalidationFailures: UInt64 + public var invalidationFailureLatched: Bool + + public init( + invalidations: UInt64, + invalidationFailures: UInt64, + invalidationFailureLatched: Bool + ) { + self.invalidations = invalidations + self.invalidationFailures = invalidationFailures + self.invalidationFailureLatched = invalidationFailureLatched + } +} + +public struct VirtioFSFrontendStatistics: Equatable, Sendable { + public let rejectedRequests: UInt64 + public let executedRequests: UInt64 + public let terminalQueueFaults: UInt64 + + public init(rejectedRequests: UInt64, executedRequests: UInt64, terminalQueueFaults: UInt64) { + self.rejectedRequests = rejectedRequests + self.executedRequests = executedRequests + self.terminalQueueFaults = terminalQueueFaults + } +} + +/// Payload, concurrency, and end-to-end timing counters for admitted request work. Byte counters +/// describe protocol payloads observed at each ownership boundary; they intentionally do not claim +/// that Foundation or XPC performed one physical copy per byte. +public struct VirtioFSPerformanceStatistics: Equatable, Sendable { + public let requestPayloadBytes: UInt64 + public let workerResponsePayloadBytes: UInt64 + public let guestPublishedResponseBytes: UInt64 + public let completedRequests: UInt64 + public let failedRequests: UInt64 + public let inFlightRequests: UInt64 + public let peakInFlightRequests: UInt64 + public let totalRequestLatencyNanoseconds: UInt64 + public let maximumRequestLatencyNanoseconds: UInt64 + + public init( + requestPayloadBytes: UInt64, + workerResponsePayloadBytes: UInt64, + guestPublishedResponseBytes: UInt64, + completedRequests: UInt64, + failedRequests: UInt64, + inFlightRequests: UInt64, + peakInFlightRequests: UInt64, + totalRequestLatencyNanoseconds: UInt64, + maximumRequestLatencyNanoseconds: UInt64 + ) { + self.requestPayloadBytes = requestPayloadBytes + self.workerResponsePayloadBytes = workerResponsePayloadBytes + self.guestPublishedResponseBytes = guestPublishedResponseBytes + self.completedRequests = completedRequests + self.failedRequests = failedRequests + self.inFlightRequests = inFlightRequests + self.peakInFlightRequests = peakInFlightRequests + self.totalRequestLatencyNanoseconds = totalRequestLatencyNanoseconds + self.maximumRequestLatencyNanoseconds = maximumRequestLatencyNanoseconds + } +} + +public final class VirtioFS: VirtioDeviceBackend { public static let tagByteCount = 36 public static let notificationFeature: UInt64 = 1 << 0 - static let traceInvalidations: Bool = { - let value = ProcessInfo.processInfo.environment["DORY_FUSE_TRACE_INVAL"] ?? "" - return ["1", "true", "yes", "on"].contains(value.lowercased()) - }() public static let notificationBufferSize: UInt32 = 4096 /// Upper bound for positive entry and attribute validity in coherent mode. Open-file and /// directory cache flags remain disabled because they cannot be revoked after degradation. - public static let maximumCoherentCacheValiditySeconds: UInt64 = FuseServer.maximumCoherentCacheValiditySeconds + public static let maximumCoherentCacheValiditySeconds: UInt64 = 0 /// The matching guest driver posts 16 page-sized notification buffers. Caching is forbidden /// until this process has seen every stable backing address in the current transport epoch. public static let requiredStableNotificationBufferCountForCaching = 16 @@ -65,18 +133,15 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid public let requestQueueCount: Int public let notificationBacklogLimit: Int public let tag: String - public let hostFS: HostFS - public let daxConfiguration: VirtioFSDaxConfiguration? - private let server: FuseServer - private let stats: VirtioFSStats? - private let inlineRequests: Bool + private let broker: DoryFSWorkerBroker + private let onWorkerLifecycle: @Sendable (VirtioFSWorkerLifecycleEvent) -> Void public var deviceFeatures: UInt64 { Self.notificationFeature } - // Small metadata-heavy workloads are latency-bound: dispatching every FUSE request to another - // thread costs more than the host syscall. Inline processing is therefore the default, with an - // environment opt-out for workloads that need the older worker-only behavior. The worker pool is - // still used when inline mode is disabled and remains available for experimentation. - private let workers = DispatchQueue(label: "dory-hv.virtiofs.worker", qos: .userInteractive, attributes: .concurrent) + private let workers = DispatchQueue( + label: "dory-hv.virtiofs.worker", + qos: RawHVSchedulingPolicy.fileSystemWorkerDispatchQoS, + attributes: .concurrent + ) private let drainLock = NSLock() /// The lifecycle epoch currently owned by a drainer, or nil when that queue has no drainer. /// Reset/reconfiguration clears the slot while the old epoch finishes outside the queue lock, @@ -116,7 +181,14 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid // backend's lifetime; success keeps the write fence until every admitted barrier resolves. private let requestGateLock = NSLock() private var requestGateClosed = false + /// The broker's shared workspace authority owns capacity. This frontend tracks only requests + /// that crossed its publication boundary plus per-queue grants already reserved by that shared + /// authority; it never mirrors the workspace counters with a second semaphore. private var activeRequestCount = 0 + private var deferredAdmissionQueues = Set() + private var grantedAdmissions = [Int: DoryFSWorkerAdmissionLease]() + private var admissionWaiterIDs = [DoryFSWorkerAdmissionWaiterID]() + private var frontendAdmissionTerminationReported = false private var requestGateWaiters: [ UUID: CheckedContinuation, Never> ] = [:] @@ -136,70 +208,71 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid private var connectionResetPending = false private var connectionResetInProgress = false private weak var connectionResetTransport: VirtioMMIOTransport? + private var connectionResetEvent: VirtioFSWorkerLifecycleEvent? private let responseFenceTestHookLock = NSLock() private var _responseFenceTestHook: (@Sendable (FuseInHeader, FuseOpcode) -> Void)? private let requestGateDrainTestHookLock = NSLock() private var _requestGateDrainTestHook: (@Sendable (RequestGateDrainTestEvent) -> Void)? - - public convenience init( + private let requestExecutionTestHookLock = NSLock() + private var _requestExecutionTestHook: (@Sendable (FuseInHeader, FuseOpcode) -> Void)? + private let hostResponseSnapshotTestHookLock = NSLock() + private var _hostResponseSnapshotTestHook: (@Sendable (FuseInHeader, FuseOpcode, [UInt8]) -> Void)? + private let telemetryLock = NSLock() + private var invalidationCount: UInt64 = 0 + private var invalidationFailureCount: UInt64 = 0 + private var hasLatchedInvalidationFailure = false + private var rejectedRequestCount: UInt64 = 0 + private var executedRequestCount: UInt64 = 0 + private var terminalQueueFaultCount: UInt64 = 0 + private var requestPayloadByteCount: UInt64 = 0 + private var workerResponsePayloadByteCount: UInt64 = 0 + private var guestPublishedResponseByteCount: UInt64 = 0 + private var completedRequestCount: UInt64 = 0 + private var failedRequestCount: UInt64 = 0 + private var inFlightRequestCount: UInt64 = 0 + private var peakInFlightRequestCount: UInt64 = 0 + private var totalRequestLatencyNanoseconds: UInt64 = 0 + private var maximumRequestLatencyNanoseconds: UInt64 = 0 + private var requestFrontendTerminallyFaulted = false + private var fuseInitCompleted = false + private var fuseDestroyCommitted = false + + public init( tag: String, - hostFS: HostFS, - daxConfiguration: VirtioFSDaxConfiguration? = nil, - requestQueueCount requestedQueueCount: Int? = nil, - notificationBacklogLimit requestedNotificationBacklogLimit: Int = 256 - ) throws { - try self.init( - tag: tag, - hostFS: hostFS, - daxConfiguration: daxConfiguration, - requestQueueCount: requestedQueueCount, - notificationBacklogLimit: requestedNotificationBacklogLimit, - inlineRequests: nil - ) - } - - init( - tag: String, - hostFS: HostFS, - daxConfiguration: VirtioFSDaxConfiguration? = nil, + broker: DoryFSWorkerBroker, requestQueueCount requestedQueueCount: Int? = nil, notificationBacklogLimit requestedNotificationBacklogLimit: Int = 256, - inlineRequests requestedInlineRequests: Bool? + onWorkerLifecycle: @escaping @Sendable (VirtioFSWorkerLifecycleEvent) -> Void = { event in + FileHandle.standardError.write(Data("dory-hv: \(event.diagnostic)\n".utf8)) + } ) throws { let bytes = Array(tag.utf8) guard !bytes.isEmpty, bytes.count < Self.tagByteCount else { throw VirtioFSError.invalidTag(tag) } - if let daxConfiguration { - guard daxConfiguration.guestBase.isMultiple(of: DaxWindow.pageSize), - daxConfiguration.length > 0, - daxConfiguration.length.isMultiple(of: DaxWindow.pageSize) else { - throw VirtioFSError.invalidDaxWindow - } - } self.tag = tag - self.hostFS = hostFS - self.daxConfiguration = daxConfiguration + self.broker = broker + self.onWorkerLifecycle = onWorkerLifecycle self.requestQueueCount = Self.clampedRequestQueueCount( - requestedQueueCount ?? Self.requestQueueCountFromEnvironment() + requestedQueueCount ?? Self.defaultRequestQueueCount() ) self.notificationBacklogLimit = min(4096, max(1, requestedNotificationBacklogLimit)) self.queueCount = self.requestQueueCount + 2 + self.admissionWaiterIDs = (0.. Int { + min(8, max(1, activeProcessorCount)) } public var configSpace: [UInt8] { @@ -221,7 +294,7 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid } public var coherentCachingActive: Bool { - server.coherentCachingActive + false } /// Test-only interlock used to stop a request after encoding but before used-ring publication. @@ -238,6 +311,18 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid set { requestGateDrainTestHookLock.withLock { _requestGateDrainTestHook = newValue } } } + /// Test-only proof that admitted work crosses the single generic FuseServer execution seam. + var requestExecutionTestHook: (@Sendable (FuseInHeader, FuseOpcode) -> Void)? { + get { requestExecutionTestHookLock.withLock { _requestExecutionTestHook } } + set { requestExecutionTestHookLock.withLock { _requestExecutionTestHook = newValue } } + } + + /// Test-only host-owned response observation. It runs before any guest-memory publication. + var hostResponseSnapshotTestHook: (@Sendable (FuseInHeader, FuseOpcode, [UInt8]) -> Void)? { + get { hostResponseSnapshotTestHookLock.withLock { _hostResponseSnapshotTestHook } } + set { hostResponseSnapshotTestHookLock.withLock { _hostResponseSnapshotTestHook = newValue } } + } + var requestPublicationGateClosed: Bool { requestGateLock.withLock { requestGateClosed } } @@ -246,6 +331,10 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid requestGateLock.withLock { deferredRequestQueues } } + var capacityDeferredRequestQueueSnapshot: Set { + requestGateLock.withLock { deferredAdmissionQueues } + } + /// Enables one-second positive entry/attribute validity only after notification negotiation, a /// ready queue, all 16 stable guest buffers, and FUSE INIT have been observed. Negative dentries, /// KEEP_CACHE, and CACHE_DIR remain disabled in every state. @@ -253,20 +342,14 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid public func activateCoherentCaching() -> VirtioFSCacheActivationResult { notificationLock.lock() let eligibility = cacheActivationEligibilityLocked() - guard eligibility.isEligible, server.activateCoherentCaching() else { - notificationLock.unlock() - return .ineligible(eligibility) - } - responseCacheEpoch &+= 1 notificationLock.unlock() - return .activated + return .ineligible(eligibility) } /// Synchronously makes every subsequently encoded FUSE response use zero metadata validity. /// KEEP_CACHE and CACHE_DIR are never emitted, including while coherent caching is active. public func deactivateCoherentCaching() { notificationLock.withLock { - server.deactivateCoherentCaching() responseCacheEpoch &+= 1 } } @@ -320,7 +403,13 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid public func deviceReset(transport: VirtioMMIOTransport) { advanceAllQueueLifecycles() let staleBarriers: [VirtioFSNotificationBarrier] + let hadStartedDriverLifecycle: Bool + let resetEvent: VirtioFSWorkerLifecycleEvent notificationLock.lock() + hadStartedDriverLifecycle = notificationTransport === transport + resetEvent = fuseDestroyCommitted + ? .connectionTeardown + : .failure("filesystem worker generation invalidated by virtio-fs device reset") if notificationTransport === transport { staleBarriers = removeAllNotificationStateLocked() } else { @@ -328,7 +417,15 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid } notificationLock.unlock() - beginConnectionReset(transport: transport) + // Linux writes device status 0 while probing an already-reset virtio device, before the + // DRIVER_OK edge that establishes a guest FUSE connection. Retiring the one-shot worker + // there makes every normal boot fail. Once this backend has observed DRIVER_OK, however, + // any reset can strand guest FUSE handles or dirty cache state and remains fail-stop. The + // sole normal exception is a reset after the exact FUSE_DESTROY response was committed; + // that retires only this share so sibling shares can finish on the shared worker channel. + if hadStartedDriverLifecycle { + beginConnectionReset(transport: transport, event: resetEvent) + } fail(staleBarriers, with: .transportReset, transport: transport) } @@ -359,7 +456,20 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid public func submitInvalidations( _ invalidations: [VirtioFSInvalidation] ) async throws -> VirtioFSNotificationBarrier { - try await submitInvalidations(invalidations, retainRequestGateForCaller: false) + guard !invalidations.isEmpty else { + return VirtioFSNotificationBarrier(notificationCount: 0) + } + do { + return try await submitInvalidations( + invalidations, + retainRequestGateForCaller: false + ) + } catch { + recordInvalidationFailure(latched: requestGateLock.withLock { + requestGateFailureLatched + }) + throw error + } } private func submitInvalidations( @@ -370,11 +480,6 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid guard !invalidations.isEmpty else { return VirtioFSNotificationBarrier(notificationCount: 0) } - if Self.traceInvalidations { - for invalidation in invalidations { - FileHandle.standardError.write(Data("dory-hv: inval \(invalidation)\n".utf8)) - } - } let frames = try invalidations.map { try $0.encoded() } guard frames.allSatisfy({ $0.count <= Int(Self.notificationBufferSize) }) else { // The public invalidation encoders currently make this unreachable, but preserve the @@ -453,7 +558,9 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid ) } apply(effects, transport: transport) - return try submission.get() + let barrier = try submission.get() + recordInvalidations(frames.count) + return barrier } public func submitInvalidation( @@ -521,12 +628,151 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid releaseCallerRetainedRequestGate(barrier, succeeded: false) } latchRequestGateFailure(barrier: nil) + recordInvalidationFailure(latched: true) throw error } } + public var statistics: VirtioFSStatistics { + telemetryLock.withLock { + VirtioFSStatistics( + invalidations: invalidationCount, + invalidationFailures: invalidationFailureCount, + invalidationFailureLatched: hasLatchedInvalidationFailure + ) + } + } + + public var frontendStatistics: VirtioFSFrontendStatistics { + telemetryLock.withLock { + VirtioFSFrontendStatistics( + rejectedRequests: rejectedRequestCount, + executedRequests: executedRequestCount, + terminalQueueFaults: terminalQueueFaultCount + ) + } + } + + public var performanceStatistics: VirtioFSPerformanceStatistics { + telemetryLock.withLock { + VirtioFSPerformanceStatistics( + requestPayloadBytes: requestPayloadByteCount, + workerResponsePayloadBytes: workerResponsePayloadByteCount, + guestPublishedResponseBytes: guestPublishedResponseByteCount, + completedRequests: completedRequestCount, + failedRequests: failedRequestCount, + inFlightRequests: inFlightRequestCount, + peakInFlightRequests: peakInFlightRequestCount, + totalRequestLatencyNanoseconds: totalRequestLatencyNanoseconds, + maximumRequestLatencyNanoseconds: maximumRequestLatencyNanoseconds + ) + } + } + + private func recordInvalidations(_ count: Int) { + guard count > 0 else { return } + let increment = UInt64(count) + telemetryLock.withLock { + invalidationCount = Self.saturatingAdd(invalidationCount, increment) + } + } + + private func recordInvalidationFailure(latched: Bool) { + telemetryLock.withLock { + invalidationFailureCount = Self.saturatingAdd(invalidationFailureCount, 1) + hasLatchedInvalidationFailure = hasLatchedInvalidationFailure || latched + } + } + + private func recordRequestRejection() { + telemetryLock.withLock { + rejectedRequestCount = Self.saturatingAdd(rejectedRequestCount, 1) + } + } + + private func recordRequestExecution(requestPayloadBytes: Int) { + telemetryLock.withLock { + executedRequestCount = Self.saturatingAdd(executedRequestCount, 1) + requestPayloadByteCount = Self.saturatingAdd( + requestPayloadByteCount, + UInt64(requestPayloadBytes) + ) + inFlightRequestCount = Self.saturatingAdd(inFlightRequestCount, 1) + peakInFlightRequestCount = max( + peakInFlightRequestCount, + inFlightRequestCount + ) + } + } + + private func recordWorkerResponsePayload(bytes: Int) { + telemetryLock.withLock { + workerResponsePayloadByteCount = Self.saturatingAdd( + workerResponsePayloadByteCount, + UInt64(bytes) + ) + } + } + + private func recordGuestPublishedResponse(bytes: Int) { + telemetryLock.withLock { + guestPublishedResponseByteCount = Self.saturatingAdd( + guestPublishedResponseByteCount, + UInt64(bytes) + ) + } + } + + private func recordRequestCompletion( + admittedAtUptimeNanoseconds: UInt64, + published: Bool + ) { + let completedAt = DispatchTime.now().uptimeNanoseconds + let latency = completedAt >= admittedAtUptimeNanoseconds + ? completedAt - admittedAtUptimeNanoseconds + : 0 + telemetryLock.withLock { + precondition(inFlightRequestCount > 0) + inFlightRequestCount -= 1 + if published { + completedRequestCount = Self.saturatingAdd(completedRequestCount, 1) + } else { + failedRequestCount = Self.saturatingAdd(failedRequestCount, 1) + } + totalRequestLatencyNanoseconds = Self.saturatingAdd( + totalRequestLatencyNanoseconds, + latency + ) + maximumRequestLatencyNanoseconds = max( + maximumRequestLatencyNanoseconds, + latency + ) + } + } + + private func recordTerminalQueueFault(_ error: any Error, queue: Int) { + telemetryLock.withLock { + terminalQueueFaultCount = Self.saturatingAdd(terminalQueueFaultCount, 1) + requestFrontendTerminallyFaulted = true + } + FileHandle.standardError.write(Data( + "dory-hv: virtiofs terminal queue fault queue=\(queue): \(error)\n".utf8 + )) + latchRequestGateFailure(barrier: nil) + } + + private var requestFrontendIsTerminallyFaulted: Bool { + telemetryLock.withLock { requestFrontendTerminallyFaulted } + } + + private static func saturatingAdd(_ value: UInt64, _ increment: UInt64) -> UInt64 { + let (sum, overflow) = value.addingReportingOverflow(increment) + return overflow ? UInt64.max : sum + } + public func handleKick(queue: Int, transport: VirtioMMIOTransport) { guard queue >= 0, queue < queueCount else { return } + guard !requestFrontendIsTerminallyFaulted else { return } // Queue notification MMIO is intentionally delivered without the transport lock for this // backend. Snapshot only routing/readiness under that lock; every pop/push below takes it // again and verifies the queue lifecycle epoch before touching the ring. @@ -549,13 +795,7 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid // Exactly one drainer owns a queue lifecycle epoch. Kicks on different queues may overlap, // while a same-queue kick only advances the generation so the active drainer sweeps again. guard let lifecycleEpoch = beginQueueDrain(queue: queue) else { return } - if inlineRequests { - drain(queue: queue, lifecycleEpoch: lifecycleEpoch, transport: transport) - } else { - workers.async { [self] in - drain(queue: queue, lifecycleEpoch: lifecycleEpoch, transport: transport) - } - } + drain(queue: queue, lifecycleEpoch: lifecycleEpoch, transport: transport) } private func drain(queue: Int, lifecycleEpoch: UInt64, transport: VirtioMMIOTransport) { @@ -570,35 +810,50 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid return } var shouldNotify = false - while true { - guard beginRequestProcessing( + requestLoop: while true { + let requestAdmission = beginRequestProcessing( queue: queue, lifecycleEpoch: lifecycleEpoch, virtqueue: virtqueue, transport: transport - ) else { + ) + guard case .process(let admissionLease, let previewOpcode) = requestAdmission else { requestGateDrainTestHook?(.deferred(queue: queue)) break } - guard let chain = popChain( + let popResult = popChain( queue: queue, lifecycleEpoch: lifecycleEpoch, virtqueue: virtqueue, transport: transport - ) else { + ) + switch popResult { + case .chain(let chain): + switch process( + chain: chain, + queue: queue, + lifecycleEpoch: lifecycleEpoch, + virtqueue: virtqueue, + transport: transport, + admissionLease: admissionLease, + previewOpcode: previewOpcode + ) { + case .completed(let interruptWanted): + shouldNotify = shouldNotify || interruptWanted + endRequestProcessing() + case .submitted: + break + } + case .empty: + admissionLease?.release() endRequestProcessing() - break - } - if process( - chain: chain, - queue: queue, - lifecycleEpoch: lifecycleEpoch, - virtqueue: virtqueue, - transport: transport - ) { - shouldNotify = true + break requestLoop + case .terminalFault(let error): + admissionLease?.release() + endRequestProcessing() + recordTerminalQueueFault(error, queue: queue) + break requestLoop } - endRequestProcessing() } if shouldNotify { transport.notifyUsed() @@ -651,204 +906,312 @@ public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvid } } + private enum ChainPopResult { + case chain(VirtqueueChain) + case empty + case terminalFault(any Error) + } + + private enum RequestProcessResult { + case completed(interruptWanted: Bool) + case submitted + } + + private enum RequestBeginResult { + case process(DoryFSWorkerAdmissionLease?, previewOpcode: FuseOpcode?) + case deferred + } + private func popChain( queue: Int, lifecycleEpoch: UInt64, virtqueue: Virtqueue, transport: VirtioMMIOTransport - ) -> VirtqueueChain? { + ) -> ChainPopResult { transport.withQueueLock { guard drainLock.withLock({ queueLifecycleEpochs[queue] == lifecycleEpoch }), - virtqueue.ready else { return nil } - return (try? virtqueue.pop()) ?? nil + virtqueue.ready else { return .empty } + do { + guard let chain = try virtqueue.pop() else { return .empty } + return .chain(chain) + } catch { + return .terminalFault(error) + } } } - @discardableResult private func process( chain: VirtqueueChain, queue: Int, lifecycleEpoch: UInt64, virtqueue: Virtqueue, - transport: VirtioMMIOTransport - ) -> Bool { - let requestEpochs: (notification: UInt64?, cache: UInt64) = notificationLock.withLock { - ( - notificationTransport === transport ? notificationEpoch : nil, - responseCacheEpoch + transport: VirtioMMIOTransport, + admissionLease: DoryFSWorkerAdmissionLease?, + previewOpcode: FuseOpcode? + ) -> RequestProcessResult { + let requestNotificationEpoch: UInt64? = notificationLock.withLock { + notificationTransport === transport ? notificationEpoch : nil + } + guard let admission = chain.withLeaseHeld({ access in + VirtioFSRequestAdmission.inspect( + chain: chain, + access: access, + queue: queue, + maximumRequestBytes: broker.effectiveAdmissionLimits.maximumRequestBytes, + maximumResponseBytes: broker.effectiveAdmissionLimits.maximumResponseBytes ) - } - let request = chain.readBytes() - var written = 0 - var decoded: (header: FuseInHeader, opcode: FuseOpcode)? - var statsStartNanoseconds: UInt64? - var completesFuseInit = false - var lifetimeGrantRolledBack = false - if let header = try? FuseProtocol.decodeInHeader(request), - header.length >= UInt32(FuseInHeader.byteCount), Int(header.length) <= request.count, - let opcode = FuseOpcode(rawValue: header.opcode) { - decoded = (header, opcode) - if stats != nil { - statsStartNanoseconds = DispatchTime.now().uptimeNanoseconds - } - } - if chain.hasWritableSegments, let decoded { - let header = decoded.header - let opcode = decoded.opcode - if opcode == .lookup { - let payload = request[FuseInHeader.byteCount.. NormalizedWorkerResponse { + if !request.expectsReply { + return NormalizedWorkerResponse( + bytes: [], + representsWorkerResponse: serverResponse.isEmpty + ) } - // `Virtqueue.push` publishes unconditionally once the queue is live; its Bool only reports - // whether the guest wants a used-ring interrupt (VRING_AVAIL_F_NO_INTERRUPT). Track - // publication separately: treating interrupt suppression as a failed publish rolled back - // handle/lookup grants the guest had legitimately received, poisoning rm/npm storms. + if request.opcode == .interrupt, serverResponse.isEmpty { + return NormalizedWorkerResponse( + bytes: Self.errorResponse(unique: request.header.unique, errno: 0), + representsWorkerResponse: false + ) + } + guard serverResponse.count >= FuseOutHeader.byteCount, + serverResponse.count <= request.maximumResponseBytes, + let header = try? FuseProtocol.decodeOutHeader(serverResponse), + Int(header.length) == serverResponse.count, + header.unique == request.header.unique else { + return NormalizedWorkerResponse( + bytes: Self.errorResponse(unique: request.header.unique, errno: EIO), + representsWorkerResponse: false + ) + } + return NormalizedWorkerResponse( + bytes: serverResponse, + representsWorkerResponse: true + ) + } + + private struct ResponsePublication { + let pushed: Bool + let interruptWanted: Bool + let failureReason: String? + } + + private func publishResponse( + _ response: [UInt8], + chain: VirtqueueChain, + queue: Int, + lifecycleEpoch: UInt64, + virtqueue: Virtqueue, + transport: VirtioMMIOTransport + ) -> ResponsePublication { var interruptWanted = false - var publishFailureReason: String? + var failureReason: String? let pushed = transport.withQueueLock { guard drainLock.withLock({ queueLifecycleEpochs[queue] == lifecycleEpoch }) else { - publishFailureReason = "lifecycle epoch changed" + failureReason = "lifecycle epoch changed" return false } guard virtqueue.ready else { - publishFailureReason = "queue not ready" + failureReason = "queue not ready" return false } - return notificationLock.withLock { - if responseCacheEpoch != requestEpochs.cache, let decoded { - // Queue-health degradation can overtake a worker outside the register lock. - // Such a response may keep its payload, but it cannot carry a pre-degradation - // entry/attribute validity grant. Normal host edits use the full request gate, - // preserving LOOKUP identities and READ payload ordering as well. - _ = server.neutralizeCacheGrants( - opcode: decoded.opcode, - writable: chain.writableSegments, - written: written - ) + return requestGateLock.withLock { + guard !requestGateFailureLatched else { + failureReason = "request gate latched" + return false } - // A high-level invalidation deadline is a one-way publication boundary. HostFS - // work admitted before that boundary may already have performed its host syscall - // and cannot be rolled back here, but its response must never become guest-visible - // afterward. An already-admitted syscall cannot be canceled and keeps normal host - // last-writer semantics; this fence covers only response publication and new work. - return requestGateLock.withLock { - guard !requestGateFailureLatched else { - publishFailureReason = "request gate latched" - return false - } - do { - interruptWanted = try virtqueue.push(chain, written: written) - return true - } catch { - publishFailureReason = "virtqueue push threw: \(error)" - return false - } + guard virtqueue.isLeaseValid(chain) else { + failureReason = "queue lease changed" + return false + } + guard chain.withLeaseHeld({ access in + access.writeBytes(response) == response.count + }) == true else { + failureReason = "bounded response copy failed" + return false + } + do { + interruptWanted = try virtqueue.push(chain, written: response.count) + return true + } catch { + failureReason = "virtqueue push threw: \(error)" + return false } } } - if pushed, completesFuseInit, let requestNotificationEpoch = requestEpochs.notification { - notificationLock.withLock { - guard notificationTransport === transport, - notificationEpoch == requestNotificationEpoch else { return } - server.markFuseInitCompleted() - } - } - if !pushed, !lifetimeGrantRolledBack, let decoded { - FileHandle.standardError.write(Data( - "dory-hv: virtiofs response unpublished (\(publishFailureReason ?? "unknown")) op=\(decoded.opcode) unique=\(decoded.header.unique) queue=\(queue)\n".utf8 - )) - server.rollbackUnpublishedResponse( - opcode: decoded.opcode, - writable: chain.writableSegments, - written: written - ) - } - if let decoded, let statsStartNanoseconds { - stats?.recordCompletion( - decoded.opcode, - durationNanoseconds: DispatchTime.now().uptimeNanoseconds &- statsStartNanoseconds - ) - } - return pushed && interruptWanted + return ResponsePublication( + pushed: pushed, + interruptWanted: interruptWanted, + failureReason: failureReason + ) } + + private func failWorker(_ reason: String) { + latchRequestGateFailure(barrier: nil) + onWorkerLifecycle(.failure(reason)) + } + + private static func errorResponse(unique: UInt64, errno: Int32) -> [UInt8] { + FuseProtocol.encodeOutHeader(FuseOutHeader( + length: UInt32(FuseOutHeader.byteCount), + error: errno == 0 ? 0 : -FuseProtocol.linuxErrno(errno), + unique: unique + )) + } + } private struct PendingNotification { @@ -937,16 +1300,18 @@ private extension VirtioFS { } func makeNotificationBuffer(_ chain: VirtqueueChain) -> NotificationBuffer? { - guard chain.readableSegments.isEmpty, - chain.writableSegments.count == 1, - let segment = chain.writableSegments.first, - segment.length >= Int(Self.notificationBufferSize) else { - return nil - } - // The patched guest reuses its kzalloc'd page but virtio may choose a new descriptor head - // when it reposts it. GuestMemory has a stable mapping, so its host pointer is a stable - // identity for the underlying guest buffer across those descriptor changes. - return NotificationBuffer(key: UInt(bitPattern: segment.pointer), chain: chain) + chain.withLeaseHeld { access -> NotificationBuffer? in + guard access.readableSegments.isEmpty, + access.writableSegments.count == 1, + let segment = access.writableSegments.first, + segment.length >= Int(Self.notificationBufferSize) else { + return nil + } + // The patched guest reuses its kzalloc'd page but virtio may choose a new descriptor + // head when it reposts it. Capture only the integer identity while the lease is held; + // the raw segment itself never escapes this callback. + return NotificationBuffer(key: UInt(bitPattern: segment.pointer), chain: chain) + } ?? nil } func pumpNotificationsLocked(queue: Virtqueue, effects: inout NotificationEffects) { @@ -1010,7 +1375,7 @@ private extension VirtioFS { // A QueueReady disable/reconfigure invalidates every retained descriptor immediately. Keep // feature negotiation and FUSE INIT only when the device itself remains live, but require a // fresh complete set of stable buffers before metadata caching can be reactivated. - server.deactivateCoherentCaching(resetFuseInit: resetFuseInit) + if resetFuseInit { fuseInitCompleted = false } responseCacheEpoch &+= 1 notificationEpoch &+= 1 let barriers = notificationBarrierTargets.map(\.barrier) @@ -1034,7 +1399,7 @@ private extension VirtioFS { notificationQueueReady: featureNegotiated && notificationQueueReady, stableNotificationBufferCount: observedNotificationBufferKeys.count, requiredStableNotificationBufferCount: Self.requiredStableNotificationBufferCountForCaching, - fuseInitCompleted: server.fuseInitCompleted + fuseInitCompleted: fuseInitCompleted ) } @@ -1106,38 +1471,113 @@ private extension VirtioFS { try result.get() } - func beginRequestProcessing( + private func beginRequestProcessing( queue: Int, lifecycleEpoch: UInt64, virtqueue: Virtqueue, transport: VirtioMMIOTransport - ) -> Bool { - // Resolve, but do not consume, the next opcode before taking requestGateLock. Queue access - // always precedes gate state elsewhere too, avoiding a transport -> gate lock inversion. - let opcode: FuseOpcode? = transport.withQueueLock { + ) -> RequestBeginResult { + // Resolve, but do not consume, the next exact admission shape before taking + // requestGateLock. Queue access always precedes gate state elsewhere too, avoiding a + // transport -> gate lock inversion. Rejected guest requests need no workspace lease. + var queueFault: (any Error)? + let preview: (opcode: FuseOpcode?, shape: DoryFSWorkerAdmissionShape?) = + transport.withQueueLock { guard drainLock.withLock({ queueLifecycleEpochs[queue] == lifecycleEpoch }), - virtqueue.ready, - let chain = try? virtqueue.peek() else { return nil } - let request = chain.readBytes(maximum: FuseInHeader.byteCount) - guard let header = try? FuseProtocol.decodeInHeader(request) else { return nil } - return FuseOpcode(rawValue: header.opcode) + virtqueue.ready else { return (nil, nil) } + let chain: VirtqueueChain + do { + guard let pending = try virtqueue.peek() else { return (nil, nil) } + chain = pending + } catch { + queueFault = error + return (nil, nil) + } + guard let preview = chain.withLeaseHeld({ access in + VirtioFSRequestAdmission.preview( + chain: chain, + access: access, + queue: queue, + maximumRequestBytes: broker.effectiveAdmissionLimits.maximumRequestBytes, + maximumResponseBytes: broker.effectiveAdmissionLimits.maximumResponseBytes + ) + }) else { return (nil, nil) } + guard let requestBytes = preview.requestBytes, + let responseBytes = preview.responseBytes else { + return (preview.opcode, nil) + } + return ( + preview.opcode, + DoryFSWorkerAdmissionShape( + requestBytes: requestBytes, + responseBytes: responseBytes + ) + ) + } + if let queueFault { + recordTerminalQueueFault(queueFault, queue: queue) + return .deferred } return requestGateLock.withLock { guard !connectionResetPending, !requestGateFailureLatched else { + grantedAdmissions.removeValue(forKey: queue)?.release() deferredRequestQueues.insert(queue) - return false + return .deferred } // A delayed write may be writeback copied before the host edit. Keep it in the guest's // available ring until reverse invalidation succeeds or the VM is discarded. All other // object operations must be able to finish so the guest can release VFS locks needed by // its notification worker. Unknown/malformed requests remain fail-closed. if requestGateClosed, - opcode?.mayDrainDuringReverseInvalidation != true { + preview.opcode?.mayDrainDuringReverseInvalidation != true { + grantedAdmissions.removeValue(forKey: queue)?.release() deferredRequestQueues.insert(queue) - return false + return .deferred + } + guard let shape = preview.shape else { + activeRequestCount += 1 + return .process(nil, previewOpcode: preview.opcode) + } + if let granted = grantedAdmissions.removeValue(forKey: queue) { + guard granted.shape == shape else { + granted.release() + activeRequestCount += 1 + return .process(nil, previewOpcode: preview.opcode) + } + activeRequestCount += 1 + return .process(granted, previewOpcode: preview.opcode) + } + guard !deferredAdmissionQueues.contains(queue) else { return .deferred } + let waiterID = admissionWaiterIDs[queue] + let result = broker.requestFrontendAdmission( + shape: shape, + waiterID: waiterID + ) { [weak self, weak transport] resolution in + switch resolution { + case .granted(let lease): + guard let self, let transport else { + lease.release() + return + } + self.receiveGrantedAdmission(lease, queue: queue, transport: transport) + case .terminated(let error): + self?.receiveTerminatedAdmission(error, queue: queue) + } + } + switch result { + case .admitted(let lease): + activeRequestCount += 1 + return .process(lease, previewOpcode: preview.opcode) + case .deferred: + deferredAdmissionQueues.insert(queue) + return .deferred + case .rejected: + // Exact per-request ceilings were already applied while peeking. If the typed + // authority still rejects, consume this guest request only to publish retryable + // EAGAIN; never reinterpret ordinary capacity as worker loss. + activeRequestCount += 1 + return .process(nil, previewOpcode: preview.opcode) } - activeRequestCount += 1 - return true } } @@ -1148,7 +1588,9 @@ private extension VirtioFS { ) = requestGateLock.withLock { precondition(activeRequestCount > 0) activeRequestCount -= 1 - guard activeRequestCount == 0 else { return ([], false) } + guard activeRequestCount == 0 else { + return ([], false) + } let shouldReset = connectionResetPending && !connectionResetInProgress if shouldReset { connectionResetInProgress = true @@ -1158,26 +1600,101 @@ private extension VirtioFS { requestGateWaiters.removeAll(keepingCapacity: true) return (waiters, shouldReset) } - if result.shouldReset { - server.resetConnection() - finishConnectionReset() - } + if result.shouldReset { invalidateWorkerForConnectionReset() } for waiter in result.waiters { waiter.resume(returning: .success(())) } } - func beginConnectionReset(transport: VirtioMMIOTransport) { - let shouldReset = requestGateLock.withLock { + func receiveGrantedAdmission( + _ lease: DoryFSWorkerAdmissionLease, + queue: Int, + transport: VirtioMMIOTransport + ) { + let shouldSchedule = requestGateLock.withLock { + deferredAdmissionQueues.remove(queue) + guard !connectionResetPending, + !requestGateFailureLatched, + grantedAdmissions[queue] == nil else { return false } + grantedAdmissions[queue] = lease + return true + } + guard shouldSchedule else { + lease.release() + return + } + workers.async { [weak self, weak transport] in + guard let self, let transport else { + lease.release() + return + } + self.handleKick(queue: queue, transport: transport) + } + } + + func receiveTerminatedAdmission( + _ error: DoryFSWorkerBrokerError, + queue: Int + ) { + let shouldReport = requestGateLock.withLock { + deferredAdmissionQueues.remove(queue) + guard !frontendAdmissionTerminationReported else { return false } + frontendAdmissionTerminationReported = true + return true + } + guard shouldReport else { return } + failWorker("filesystem worker admission terminated: \(error)") + } + + func beginConnectionReset( + transport: VirtioMMIOTransport, + event: VirtioFSWorkerLifecycleEvent + ) { + let result = requestGateLock.withLock { () -> (Bool, FrontendAdmissionCleanup) in connectionResetPending = true connectionResetTransport = transport - guard activeRequestCount == 0, !connectionResetInProgress else { return false } + requestGateClosed = true + requestGateFailureLatched = true + let cleanup = detachFrontendAdmissionsLocked() + if connectionResetEvent == nil { + connectionResetEvent = event + } + guard activeRequestCount == 0, + !connectionResetInProgress else { return (false, cleanup) } connectionResetInProgress = true - return true + return (true, cleanup) + } + releaseFrontendAdmissions(result.1) + guard result.0 else { return } + invalidateWorkerForConnectionReset() + } + + func invalidateWorkerForConnectionReset() { + Task { [weak self] in + guard let self else { return } + let event = requestGateLock.withLock { + connectionResetEvent + ?? .failure("filesystem worker generation invalidated by virtio-fs device reset") + } + let reportedEvent: VirtioFSWorkerLifecycleEvent + switch event { + case .connectionTeardown: + do { + try await broker.completeConnectionTeardown() + reportedEvent = .connectionTeardown + } catch { + await broker.invalidate() + reportedEvent = .failure( + "filesystem worker connection teardown was not quiescent: \(error)" + ) + } + case .failure: + await broker.invalidate() + reportedEvent = event + } + onWorkerLifecycle(reportedEvent) + finishConnectionReset() } - guard shouldReset else { return } - server.resetConnection() - finishConnectionReset() } func finishConnectionReset() { @@ -1185,6 +1702,7 @@ private extension VirtioFS { guard connectionResetInProgress else { return nil } connectionResetInProgress = false connectionResetPending = false + connectionResetEvent = nil if requestGateClosed { if requestGateTransport == nil { requestGateTransport = connectionResetTransport @@ -1300,7 +1818,7 @@ private extension VirtioFS { /// different executor, so the publication boundary must be established synchronously here. /// Constructing the replacement VM/backend is the only operation that clears this latch. func latchRequestGateFailure(barrier: VirtioFSNotificationBarrier?) { - requestGateLock.withLock { + let cleanup = requestGateLock.withLock { requestGateClosed = true requestGateFailureLatched = true if let barrier { @@ -1308,7 +1826,9 @@ private extension VirtioFS { requestGateBarriers.removeValue(forKey: identifier) requestGateCallerRetainedBarriers.remove(identifier) } + return detachFrontendAdmissionsLocked() } + releaseFrontendAdmissions(cleanup) } func openRequestGateIfResolvedLocked() -> RequestGateRelease? { @@ -1340,23 +1860,30 @@ private extension VirtioFS { } } - static func requestQueueCountFromEnvironment() -> Int { - guard let value = ProcessInfo.processInfo.environment["DORY_FUSE_QUEUES"].flatMap(Int.init) else { - return min(8, max(1, ProcessInfo.processInfo.activeProcessorCount)) + func detachFrontendAdmissionsLocked() -> FrontendAdmissionCleanup { + let waiters = deferredAdmissionQueues.map { admissionWaiterIDs[$0] } + let leases = Array(grantedAdmissions.values) + deferredAdmissionQueues.removeAll(keepingCapacity: false) + grantedAdmissions.removeAll(keepingCapacity: false) + return FrontendAdmissionCleanup(waiters: waiters, leases: leases) + } + + func releaseFrontendAdmissions(_ cleanup: FrontendAdmissionCleanup) { + for waiter in cleanup.waiters { + broker.cancelFrontendAdmission(waiterID: waiter) } - return value + for lease in cleanup.leases { lease.release() } } static func clampedRequestQueueCount(_ count: Int) -> Int { min(16, max(1, count)) } - static func inlineRequestsFromEnvironment() -> Bool { - guard let value = ProcessInfo.processInfo.environment["DORY_FUSE_INLINE"]?.lowercased() else { - return true - } - return !["0", "false", "no", "off"].contains(value) - } +} + +private struct FrontendAdmissionCleanup { + let waiters: [DoryFSWorkerAdmissionWaiterID] + let leases: [DoryFSWorkerAdmissionLease] } private extension FuseOpcode { @@ -1366,7 +1893,7 @@ private extension FuseOpcode { /// locks and remain behind the boundary too, keeping the exceptional path minimal. var mayDrainDuringReverseInvalidation: Bool { switch self { - case .write, .statfs, .initOp, .destroy, .interrupt, .notifyReply, + case .write, .statfs, .syncfs, .initOp, .destroy, .interrupt, .notifyReply, .forget, .batchForget: false default: @@ -1375,46 +1902,4 @@ private extension FuseOpcode { } } -private final class VirtioFSStats: @unchecked Sendable { - private let tag: String - private let lock = NSLock() - private var counts: [FuseOpcode: Int] = [:] - private var durationNanoseconds: [FuseOpcode: UInt64] = [:] - private var total = 0 - - init(tag: String) { - self.tag = tag - } - - static func fromEnvironment(tag: String) -> VirtioFSStats? { - let value = ProcessInfo.processInfo.environment["DORY_FUSE_STATS"] ?? "" - guard ["1", "true", "yes", "on"].contains(value.lowercased()) else { return nil } - FileHandle.standardError.write(Data("dory-hv: virtiofs stats enabled tag=\(tag)\n".utf8)) - return VirtioFSStats(tag: tag) - } - - func recordCompletion(_ opcode: FuseOpcode, durationNanoseconds elapsed: UInt64) { - let snapshot: (Int, [FuseOpcode: Int], [FuseOpcode: UInt64])? = lock.withLock { - total += 1 - counts[opcode, default: 0] += 1 - durationNanoseconds[opcode, default: 0] &+= elapsed - guard total <= 20 || total.isMultiple(of: 100) else { return nil } - return (total, counts, durationNanoseconds) - } - guard let snapshot else { return } - let line = snapshot.1 - .sorted { lhs, rhs in lhs.key.rawValue < rhs.key.rawValue } - .map { opcode, count in - let nanoseconds = snapshot.2[opcode] ?? 0 - let totalMilliseconds = Double(nanoseconds) / 1_000_000 - let averageMicroseconds = count == 0 ? 0 : Double(nanoseconds) / Double(count) / 1_000 - let totalText = String(format: "%.3f", totalMilliseconds) - let averageText = String(format: "%.1f", averageMicroseconds) - return "\(opcode)=\(count)/\(totalText)ms/\(averageText)us" - } - .joined(separator: " ") - FileHandle.standardError.write(Data("dory-hv: virtiofs stats tag=\(tag) total=\(snapshot.0) \(line)\n".utf8)) - } -} - extension VirtioFS: @unchecked Sendable {} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSNotification.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSNotification.swift index 605028a3..33a35af4 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSNotification.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSNotification.swift @@ -1,3 +1,4 @@ +import DoryFSWorkerContracts import Foundation /// Cache invalidations delivered through the negotiated virtio-fs notification queue. diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSRequestAdmission.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSRequestAdmission.swift new file mode 100644 index 00000000..da7c1301 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSRequestAdmission.swift @@ -0,0 +1,379 @@ +import DoryFSWorkerContracts +import Foundation + +/// Host-owned request envelope produced while the exact virtqueue lease is held. Execution code +/// never needs guest pointers, which is the seam a future out-of-process FUSE worker can consume. +struct VirtioFSAdmittedRequest: Sendable { + let bytes: [UInt8] + let header: FuseInHeader + let opcode: FuseOpcode? + let writableCapacity: Int + let maximumResponseBytes: Int + let expectsReply: Bool +} + +enum VirtioFSRequestRejection: Error, Equatable, Sendable, CustomStringConvertible { + case emptyChain + case zeroLengthDescriptor + case readableAfterWritable + case missingReadablePrefix + case missingWritableSuffix + case shortHeader + case requestTooLarge(limit: Int, actual: Int) + case lengthMismatch(declared: UInt32, actual: Int) + case headerChangedDuringSnapshot + case wrongQueue(queue: Int, opcode: UInt32) + case responseTooLarge(limit: Int, requested: Int) + case insufficientResponseCapacity(required: Int, actual: Int) + + var description: String { + switch self { + case .emptyChain: return "empty descriptor chain" + case .zeroLengthDescriptor: return "zero-length descriptor" + case .readableAfterWritable: return "readable descriptor follows writable suffix" + case .missingReadablePrefix: return "missing readable request prefix" + case .missingWritableSuffix: return "missing writable response suffix" + case .shortHeader: return "request is shorter than fuse_in_header" + case let .requestTooLarge(limit, actual): + return "request size \(actual) exceeds \(limit) bytes" + case let .lengthMismatch(declared, actual): + return "fuse_in_header.len \(declared) does not equal readable size \(actual)" + case .headerChangedDuringSnapshot: + return "fuse_in_header changed while taking the host-owned request snapshot" + case let .wrongQueue(queue, opcode): + return "opcode \(opcode) is not permitted on queue \(queue)" + case let .responseTooLarge(limit, requested): + return "response bound \(requested) exceeds \(limit) bytes" + case let .insufficientResponseCapacity(required, actual): + return "response capacity \(actual) is smaller than required \(required)" + } + } +} + +struct VirtioFSRejectedRequest: Sendable { + let reason: VirtioFSRequestRejection + /// A complete host-owned FUSE error frame when the validated writable suffix can hold one. + let response: [UInt8] +} + +enum VirtioFSRequestAdmissionDecision: Sendable { + case execute(VirtioFSAdmittedRequest) + case reject(VirtioFSRejectedRequest) +} + +struct VirtioFSRequestAdmissionPreview: Sendable { + let opcode: FuseOpcode? + let requestBytes: Int? + let responseBytes: Int? +} + +enum VirtioFSRequestAdmission { + /// Dory negotiates a one-MiB FUSE transfer payload. The common header is bounded separately so + /// a legal maximum-sized WRITE remains representable without accepting an unbounded request. + static let maximumPayloadBytes = 1 * 1_024 * 1_024 + static let maximumRequestBytes = FuseInHeader.byteCount + maximumPayloadBytes + static let maximumResponseBytes = FuseOutHeader.byteCount + maximumPayloadBytes + + /// Computes only the exact admission-memory shape while the chain remains guest-owned. This + /// bounded preview reads at most the FUSE header plus the fixed fields needed to size READ and + /// READDIRPLUS replies; the full payload is copied exactly once, after a workspace lease has + /// been reserved and the chain is popped. + static func preview( + chain: VirtqueueChain, + access: VirtqueueLeaseAccess, + queue: Int, + maximumRequestBytes requestLimit: Int = maximumRequestBytes, + maximumResponseBytes responseLimit: Int = maximumResponseBytes + ) -> VirtioFSRequestAdmissionPreview { + var readableBytes = 0 + var writableBytes = 0 + var sawReadable = false + var sawWritable = false + for segment in access.segments { + guard segment.length > 0 else { + return .init(opcode: nil, requestBytes: nil, responseBytes: nil) + } + if segment.isDeviceWritable { + sawWritable = true + guard let total = checkedAdd(writableBytes, segment.length) else { + return .init(opcode: nil, requestBytes: nil, responseBytes: nil) + } + writableBytes = total + } else { + guard !sawWritable, + let total = checkedAdd(readableBytes, segment.length) else { + return .init(opcode: nil, requestBytes: nil, responseBytes: nil) + } + sawReadable = true + readableBytes = total + } + } + guard !chain.containsZeroLengthDescriptor, + sawReadable, + readableBytes >= FuseInHeader.byteCount else { + return .init(opcode: nil, requestBytes: nil, responseBytes: nil) + } + let prefix = access.readBytes( + maximum: min(readableBytes, FuseInHeader.byteCount + 32) + ) + guard prefix.count >= FuseInHeader.byteCount, + let header = try? FuseProtocol.decodeInHeader(prefix) else { + return .init(opcode: nil, requestBytes: nil, responseBytes: nil) + } + let opcode = FuseOpcode(rawValue: header.opcode) + guard readableBytes <= requestLimit, + header.length == UInt32(readableBytes) else { + return .init(opcode: opcode, requestBytes: nil, responseBytes: nil) + } + let replylessForget = opcode == .forget || opcode == .batchForget + let isHighPriority = replylessForget || opcode == .interrupt + guard (queue == 0) == isHighPriority, + replylessForget || sawWritable else { + return .init(opcode: opcode, requestBytes: nil, responseBytes: nil) + } + guard let requiredCapacity = try? requiredResponseCapacity( + opcode: opcode, + request: prefix, + maximumResponseBytes: responseLimit + ), requiredCapacity <= responseLimit, + writableBytes >= requiredCapacity else { + return .init(opcode: opcode, requestBytes: nil, responseBytes: nil) + } + return VirtioFSRequestAdmissionPreview( + opcode: opcode, + requestBytes: readableBytes, + responseBytes: opcode == .readlink + ? min(writableBytes, responseLimit) + : requiredCapacity + ) + } + + static func inspect( + chain: VirtqueueChain, + access: VirtqueueLeaseAccess, + queue: Int, + maximumRequestBytes requestLimit: Int = maximumRequestBytes, + maximumResponseBytes responseLimit: Int = maximumResponseBytes + ) -> VirtioFSRequestAdmissionDecision { + guard !access.segments.isEmpty else { return reject(.emptyChain) } + guard !chain.containsZeroLengthDescriptor else { + return reject(.zeroLengthDescriptor) + } + + var sawReadable = false + var sawWritable = false + var readableBytes = 0 + var writableBytes = 0 + for segment in access.segments { + guard segment.length > 0 else { return reject(.zeroLengthDescriptor) } + if segment.isDeviceWritable { + sawWritable = true + guard let total = checkedAdd(writableBytes, segment.length) else { + return reject(.responseTooLarge(limit: maximumResponseBytes, requested: Int.max)) + } + writableBytes = total + } else { + guard !sawWritable else { return reject(.readableAfterWritable) } + sawReadable = true + guard let total = checkedAdd(readableBytes, segment.length) else { + return reject(.requestTooLarge(limit: maximumRequestBytes, actual: Int.max)) + } + readableBytes = total + } + } + guard sawReadable else { return reject(.missingReadablePrefix) } + guard readableBytes >= FuseInHeader.byteCount else { return reject(.shortHeader) } + + let headerBytes = access.readBytes(maximum: FuseInHeader.byteCount) + guard headerBytes.count == FuseInHeader.byteCount, + let observedHeader = try? FuseProtocol.decodeInHeader(headerBytes) else { + return reject(.shortHeader) + } + guard readableBytes <= requestLimit else { + return reject( + .requestTooLarge(limit: requestLimit, actual: readableBytes), + header: observedHeader, + writableCapacity: sawWritable ? writableBytes : 0, + errno: E2BIG + ) + } + guard observedHeader.length == UInt32(readableBytes) else { + return reject( + .lengthMismatch(declared: observedHeader.length, actual: readableBytes), + header: observedHeader, + writableCapacity: sawWritable ? writableBytes : 0, + errno: EINVAL + ) + } + + let request = access.readBytes(maximum: readableBytes) + guard request.count == readableBytes else { + return reject(.lengthMismatch(declared: observedHeader.length, actual: request.count)) + } + guard let header = try? FuseProtocol.decodeInHeader(request), + header.length == UInt32(readableBytes) else { + return reject( + .headerChangedDuringSnapshot, + header: observedHeader, + writableCapacity: sawWritable ? writableBytes : 0, + errno: EINVAL + ) + } + guard header == observedHeader else { + return reject( + .headerChangedDuringSnapshot, + header: header, + writableCapacity: sawWritable ? writableBytes : 0, + errno: EINVAL + ) + } + + // Every decision that can authorize host work is derived from the immutable host-owned + // snapshot above. The bounded preliminary header is used only to prove the copy size. + let opcode = FuseOpcode(rawValue: header.opcode) + let replylessForget = opcode == .forget || opcode == .batchForget + let isHighPriority = replylessForget || opcode == .interrupt + guard (queue == 0) == isHighPriority else { + return reject( + .wrongQueue(queue: queue, opcode: header.opcode), + header: header, + writableCapacity: sawWritable ? writableBytes : 0, + errno: EPROTO + ) + } + if !replylessForget, !sawWritable { + return reject(.missingWritableSuffix, header: header, writableCapacity: 0, errno: EIO) + } + let requiredCapacity: Int + do { + requiredCapacity = try requiredResponseCapacity( + opcode: opcode, + request: request, + maximumResponseBytes: responseLimit + ) + } catch let reason as VirtioFSRequestRejection { + return reject( + reason, + header: header, + writableCapacity: sawWritable ? writableBytes : 0, + errno: EINVAL + ) + } catch { + return reject( + .responseTooLarge(limit: maximumResponseBytes, requested: Int.max), + header: header, + writableCapacity: sawWritable ? writableBytes : 0, + errno: EINVAL + ) + } + guard writableBytes >= requiredCapacity else { + return reject( + .insufficientResponseCapacity(required: requiredCapacity, actual: writableBytes), + header: header, + writableCapacity: writableBytes, + errno: EIO + ) + } + guard requiredCapacity <= responseLimit else { + return reject( + .responseTooLarge(limit: responseLimit, requested: requiredCapacity), + header: header, + writableCapacity: writableBytes, + errno: E2BIG + ) + } + + return .execute(VirtioFSAdmittedRequest( + bytes: request, + header: header, + opcode: opcode, + writableCapacity: writableBytes, + maximumResponseBytes: opcode == .readlink + ? min(writableBytes, responseLimit) + : requiredCapacity, + expectsReply: !replylessForget + )) + } + + private static func requiredResponseCapacity( + opcode: FuseOpcode?, + request: [UInt8], + maximumResponseBytes: Int + ) throws -> Int { + guard let opcode else { return FuseOutHeader.byteCount } + switch opcode { + case .forget, .batchForget: + return 0 + case .initOp: + return FuseOutHeader.byteCount + FuseInitOut.byteCount + case .lookup, .symlink, .link, .mkdir: + return FuseOutHeader.byteCount + 128 + case .getattr, .setattr: + return FuseOutHeader.byteCount + 104 + case .open, .opendir: + return FuseOutHeader.byteCount + 16 + case .read, .readdirplus: + let payloadOffset = FuseInHeader.byteCount + guard request.count >= payloadOffset + 20 else { return FuseOutHeader.byteCount } + let payloadBytes = Int(request.leUInt32(at: payloadOffset + 16)) + let required = FuseOutHeader.byteCount + payloadBytes + guard required <= maximumResponseBytes else { + throw VirtioFSRequestRejection.responseTooLarge( + limit: maximumResponseBytes, + requested: required + ) + } + return required + case .write: + return FuseOutHeader.byteCount + 8 + case .statfs: + return FuseOutHeader.byteCount + 80 + case .getlk: + return FuseOutHeader.byteCount + 24 + case .listxattr: + return FuseOutHeader.byteCount + 4 + case .create: + return FuseOutHeader.byteCount + 144 + case .readlink: + // READLINK neither mutates host state nor grants a resource. Its payload is validated + // against the actual writable capacity after the single generic execution. + return FuseOutHeader.byteCount + case .unlink, .rmdir, .rename, .release, .fsync, .syncfs, .setxattr, .getxattr, .flush, + .setlk, .setlkw, .interrupt, .releasedir, .setupmapping, .removemapping: + // These implemented operations have either an empty success or a header-only error. + // INTERRUPT remains reply-capable at the transport boundary even when no matching + // pending server operation requires a payload. + return FuseOutHeader.byteCount + case .readdir, .fsyncdir, .bmap, .destroy, .ioctl, .poll, .notifyReply, .fallocate, + .rename2, .lseek, .copyFileRange: + // The generic FuseServer path explicitly rejects these with a header-only ENOSYS. + // Keeping the classification exhaustive prevents a future mutating implementation + // from silently inheriting an insufficient response preflight. + return FuseOutHeader.byteCount + } + } + + private static func checkedAdd(_ lhs: Int, _ rhs: Int) -> Int? { + let (sum, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? nil : sum + } + + private static func reject(_ reason: VirtioFSRequestRejection) -> VirtioFSRequestAdmissionDecision { + .reject(VirtioFSRejectedRequest(reason: reason, response: [])) + } + + private static func reject( + _ reason: VirtioFSRequestRejection, + header: FuseInHeader, + writableCapacity: Int, + errno: Int32 + ) -> VirtioFSRequestAdmissionDecision { + guard writableCapacity >= FuseOutHeader.byteCount else { return reject(reason) } + let response = FuseProtocol.encodeOutHeader(FuseOutHeader( + length: UInt32(FuseOutHeader.byteCount), + error: -FuseProtocol.linuxErrno(errno), + unique: header.unique + )) + return .reject(VirtioFSRejectedRequest(reason: reason, response: response)) + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift index 3fa9c245..450e27e7 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioGPU.swift @@ -1,6 +1,10 @@ import Darwin +import DoryFSWorkerContracts +import DoryRendererWorkerContracts +import Dispatch import Foundation import Hypervisor +import Metal public struct VirtioGPUCapset: Sendable, Equatable { public var id: UInt32 @@ -17,24 +21,81 @@ public struct VirtioGPUCapset: Sendable, Equatable { public struct VirtioGPUMemoryEntry { public var pointer: UnsafeMutableRawPointer public var length: Int + /// Guest-physical identity used to grant the same bytes to an isolated renderer worker. + /// Synthetic host-only entries leave this nil and are never eligible for worker submission. + public var guestAddress: UInt64? - public init(pointer: UnsafeMutableRawPointer, length: Int) { + public init( + pointer: UnsafeMutableRawPointer, + length: Int, + guestAddress: UInt64? = nil + ) { self.pointer = pointer self.length = length + self.guestAddress = guestAddress } } /// A host-visible blob mapping produced by the renderer: the host pointer virglrenderer owns (to be -/// hv_vm_map'd into the guest window), its size, and the guest-facing cache map info. +/// hv_vm_map'd into the guest window), its size, the guest-facing cache map info, and whether the +/// renderer API created map state which must later be explicitly released. public struct VirtioGPUBlobMapping { public var hostPointer: UnsafeMutableRawPointer public var size: UInt64 public var mapInfo: UInt32 + public var requiresRendererUnmap: Bool - public init(hostPointer: UnsafeMutableRawPointer, size: UInt64, mapInfo: UInt32) { + public init( + hostPointer: UnsafeMutableRawPointer, + size: UInt64, + mapInfo: UInt32, + requiresRendererUnmap: Bool = true + ) { self.hostPointer = hostPointer self.size = size self.mapInfo = mapInfo + self.requiresRendererUnmap = requiresRendererUnmap + } +} + +/// VMM-local mapping of a worker-exported SHM blob. The descriptor and mmap lifetime remain bound +/// to one authenticated resource generation; neither a worker pointer nor a path crosses XPC. +private final class DoryRendererWorkerBlobMappingAuthority: @unchecked Sendable { + let lease: DoryRendererBlobMappingLease + let hostPointer: UnsafeMutableRawPointer + let mappedByteCount: Int + private let descriptor: FileHandle + + init(_ mapping: DoryRendererWorkerBlobMapping) throws { + let roundedLength = mapping.lease.mappingByteCount + .roundedUpToMultiple(of: HostPage.size) + guard roundedLength > 0, + roundedLength <= mapping.lease.declaredFileSize, + roundedLength <= UInt64(Int.max) else { + try? mapping.sharedMemoryDescriptor.close() + throw VMError.invalidConfiguration("worker blob mapping exceeds SHM authority") + } + let pointer = mmap( + nil, + Int(roundedLength), + PROT_READ | PROT_WRITE, + MAP_SHARED, + mapping.sharedMemoryDescriptor.fileDescriptor, + 0 + ) + guard pointer != MAP_FAILED, let pointer else { + try? mapping.sharedMemoryDescriptor.close() + throw VMError.outOfMemory("cannot map worker blob SHM: errno \(errno)") + } + self.lease = mapping.lease + self.hostPointer = pointer + self.mappedByteCount = Int(roundedLength) + self.descriptor = mapping.sharedMemoryDescriptor + } + + deinit { + munmap(hostPointer, mappedByteCount) + try? descriptor.close() } } @@ -62,377 +123,7931 @@ public struct VirtioGPUTransfer3D { public var box: [UInt32] } -public protocol VirtioGPURenderer: AnyObject { - var capsets: [VirtioGPUCapset] { get } - func createContext(id: UInt32, flags: UInt32, name: String) throws - func destroyContext(id: UInt32) throws - func attachResource(contextID: UInt32, resourceID: UInt32) throws - func detachResource(contextID: UInt32, resourceID: UInt32) throws - func submit3D(contextID: UInt32, command: [UInt8]) throws - func createResource3D(_ resource: VirtioGPUResourceCreate3D, entries: [VirtioGPUMemoryEntry]) throws - func createBlob( +public struct VirtioGPURect: Sendable, Equatable { + public var x: UInt32 + public var y: UInt32 + public var width: UInt32 + public var height: UInt32 + + public init(x: UInt32, y: UInt32, width: UInt32, height: UInt32) { + self.x = x + self.y = y + self.width = width + self.height = height + } +} + +/// The preferred mode for one stable virtio-gpu scanout. Array order is the guest-visible +/// scanout identifier, so callers must preserve it for the lifetime of the device. +public struct VirtioGPUScanoutSize: Sendable, Equatable { + public var width: UInt32 + public var height: UInt32 + + public init(width: UInt32, height: UInt32) { + self.width = min(16_384, max(1, width)) + self.height = min(16_384, max(1, height)) + } +} + +/// One copied scanout update ready for a host display surface. `width` and `height` describe the +/// complete scanout, while `bytes` contains only `dirtyRect` rows at `stride` bytes per row. The +/// device never exposes guest pointers to the UI layer, and small browser repaints therefore avoid +/// copying the rest of a 4K framebuffer merely to update one damaged rectangle. +public struct VirtioGPUScanoutFrame: Sendable, Equatable { + public var scanoutID: UInt32 + public var resourceID: UInt32 + public var resourceGeneration: UInt64 + public var format: UInt32 + public var width: UInt32 + public var height: UInt32 + public var stride: UInt32 + public var dirtyRect: VirtioGPURect + public var bytes: Data + + public init( + scanoutID: UInt32, resourceID: UInt32, - contextID: UInt32, - blobMemory: UInt32, - blobFlags: UInt32, - blobID: UInt64, - size: UInt64, - entries: [VirtioGPUMemoryEntry] - ) throws - func attachBacking(resourceID: UInt32, entries: [VirtioGPUMemoryEntry]) throws - func detachBacking(resourceID: UInt32) throws - func unrefResource(resourceID: UInt32) throws - func mapBlob(resourceID: UInt32) throws -> VirtioGPUBlobMapping - func unmapBlob(resourceID: UInt32) throws - func transferToHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws - func transferFromHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws - /// Registers a fence that must call `onFenceSignaled` (possibly from another thread) once all - /// GPU work submitted before it has completed. Context fences order per (context, ring); plain - /// fences ride the global ctx0 timeline and signal as (0, 0, id). - func createFence(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64, contextFence: Bool) throws - var onFenceSignaled: ((_ contextID: UInt32, _ ringIndex: UInt32, _ fenceID: UInt64) -> Void)? { get set } + resourceGeneration: UInt64 = 0, + format: UInt32, + width: UInt32, + height: UInt32, + stride: UInt32, + dirtyRect: VirtioGPURect, + bytes: Data + ) { + self.scanoutID = scanoutID + self.resourceID = resourceID + self.resourceGeneration = resourceGeneration + self.format = format + self.width = width + self.height = height + self.stride = stride + self.dirtyRect = dirtyRect + self.bytes = bytes + } } -extension VirtioGPUMemoryEntry: @unchecked Sendable {} +/// A renderer-owned OpenGL texture that can be displayed without copying its pixels through host +/// memory. The texture name is valid only in an OpenGL context that shares objects with the +/// renderer. `yOriginTop` is the renderer's authoritative orientation bit and must be preserved by +/// the display backend rather than inferred from the guest format. +public struct VirtioGPUTextureResource: Sendable, Equatable { + public var textureID: UInt32 + public var format: UInt32 + public var width: UInt32 + public var height: UInt32 + public var yOriginTop: Bool -/// The guest-physical window into which host-visible Venus blobs are mapped. Unlike a normal RAM -/// region this is NOT pre-backed: virglrenderer owns each blob's host memory (a Metal-backed, -/// page-aligned allocation), so on `resource_map` we hv_vm_map that renderer-owned pointer into the -/// window at the guest-requested offset — the same zero-copy model libkrun/krunkit use on macOS. -/// Pre-mapping the whole window would make per-blob hv_vm_map fail (the GPA is already mapped). -public final class VirtioGPUHostVisibleMemory: @unchecked Sendable { - public let guestBase: UInt64 - public let length: UInt64 + public init( + textureID: UInt32, + format: UInt32, + width: UInt32, + height: UInt32, + yOriginTop: Bool + ) { + self.textureID = textureID + self.format = format + self.width = width + self.height = height + self.yOriginTop = yOriginTop + } +} + +/// A producer-completion boundary for a renderer-owned texture presentation. +/// +/// `prepareConsumerForPresentation()` is invoked with the display's shared OpenGL context current. +/// A conforming renderer must enqueue a server-side wait for producer completion on that context +/// (without a CPU-wide finish) and return only after subsequent consumer reattachment and reads are +/// ordered behind it. A texture name by itself is deliberately not presentation authority: +/// `glFlush` only submits producer work and does not establish the required cross-context +/// completion dependency. Each synchronization object is a single-use authority: an +/// implementation must atomically choose either a successful consumer preparation or discard, +/// make repeated calls harmless, and destroy its completion primitive on the context that owns it. +public protocol VirtioGPUTexturePresentationSynchronization: AnyObject, Sendable { + func prepareConsumerForPresentation() throws + /// Retires an authority that was coalesced or rejected before the consumer wait was enqueued. + /// This may be called from a producer/mailbox thread; implementations must schedule any + /// context-bound fence destruction on an appropriate renderer context and make it idempotent. + func discardWithoutPresentation() +} + +/// Renderer-issued authority to present one shared texture after an explicit producer-completion +/// handoff. The synchronization object is intentionally opaque to the virtio device and AppKit +/// mailbox. This is the legacy in-process representation; the signed worker contract instead +/// exports either an owned SHM descriptor with immutable linear layout or a secure-coding +/// `MTLSharedTextureHandle`, plus a single-use release token and its qualified producer-completion +/// contract. A process-local GL/Metal texture name by itself is never cross-process authority. +public struct VirtioGPUTexturePresentation: Sendable { + public let resourceID: UInt32 + public let resourceGeneration: UInt64 + public let texture: VirtioGPUTextureResource + private let synchronization: any VirtioGPUTexturePresentationSynchronization + + public init( + resourceID: UInt32, + resourceGeneration: UInt64, + texture: VirtioGPUTextureResource, + synchronization: any VirtioGPUTexturePresentationSynchronization + ) { + self.resourceID = resourceID + self.resourceGeneration = resourceGeneration + self.texture = texture + self.synchronization = synchronization + } + + public func prepareConsumerForPresentation() throws { + try synchronization.prepareConsumerForPresentation() + } + + public func discardWithoutPresentation() { + synchronization.discardWithoutPresentation() + } +} + +public enum VirtioGPUMetalScanoutPresentationError: Error, Equatable, Sendable { + case retired +} + +public enum VirtioGPUMetalScanoutTransport: Equatable, Sendable { + case sharedMemory + case sharedTexture +} + +/// One consumer's authority to import a worker-issued scanout into Metal without copying pixels. +/// A Venus lease borrows one SHM descriptor; a VirGL2 lease borrows one secure-coding shared-texture +/// handle. The managed guest's producer-complete flush has already run before publication. +public final class VirtioGPUMetalScanoutPresentation: @unchecked Sendable { + public let workerGeneration: DoryRendererWorkerGeneration + public let resourceID: UInt32 + public let resourceGeneration: UInt64 + public let leaseID: DoryRendererScanoutLeaseID + public let releaseToken: DoryRendererScanoutReleaseToken + public let pixelFormat: DoryRendererScanoutPixelFormat + public let yOriginTop: Bool + public let width: UInt32 + public let height: UInt32 + public let transport: VirtioGPUMetalScanoutTransport + private let consumerID: UInt32 + private let core: DoryRendererWorkerSharedScanoutCore private let lock = NSLock() - private var mappings: [UInt32: (offset: UInt64, size: UInt64)] = [:] + private var retired = false - public init(guestBase: UInt64, length: UInt64 = 256 * 1024 * 1024) throws { - guard length > 0, - guestBase.isMultiple(of: HostPage.size), - length.isMultiple(of: HostPage.size), - length <= UInt64(Int.max) else { - throw VMError.invalidConfiguration("invalid virtio-gpu host-visible memory window") + fileprivate init( + scanout: DoryRendererWorkerScanoutAuthority, + consumerID: UInt32, + core: DoryRendererWorkerSharedScanoutCore + ) { + switch scanout { + case .sharedMemory(let value): + transport = .sharedMemory + workerGeneration = value.lease.workerGeneration + resourceID = value.lease.resourceID + resourceGeneration = value.lease.resourceGeneration + leaseID = value.lease.leaseID + releaseToken = value.lease.releaseToken + pixelFormat = value.lease.pixelFormat + yOriginTop = value.lease.yOriginTop + width = value.lease.width + height = value.lease.height + case .sharedTexture(let value): + transport = .sharedTexture + workerGeneration = value.lease.workerGeneration + resourceID = value.lease.resourceID + resourceGeneration = value.lease.resourceGeneration + leaseID = value.lease.leaseID + releaseToken = value.lease.releaseToken + pixelFormat = value.lease.pixelFormat + yOriginTop = value.lease.yOriginTop + width = value.lease.width + height = value.lease.height } - self.guestBase = guestBase - self.length = length + self.consumerID = consumerID + self.core = core } - deinit { - lock.lock() - for (_, mapping) in mappings { - _ = hv_vm_unmap(guestBase + mapping.offset, Int(mapping.size)) - } - lock.unlock() + public func withSharedMemoryScanout( + _ body: (DoryRendererScanoutLease, Int32) throws -> T + ) throws -> T { + let isRetired = lock.withLock { retired } + guard !isRetired else { throw VirtioGPUMetalScanoutPresentationError.retired } + return try core.withSharedMemoryScanout( + consumerID: consumerID, + body + ) } - /// hv_vm_map the renderer-owned `hostPointer` into the window at `offset`. `hostPointer` stays - /// owned by virglrenderer and must never be munmap'd here — it is released via resource_unmap. - public func map(resourceID: UInt32, hostPointer: UnsafeMutableRawPointer, offset: UInt64, size: UInt64) throws { - let mapSize = size.roundedUpToMultiple(of: HostPage.size) - guard offset.isMultiple(of: HostPage.size), - mapSize > 0, offset <= length, mapSize <= length - offset else { - throw VMError.guestMemoryFault(address: guestBase + offset, count: size) - } - lock.lock() - defer { lock.unlock() } - if let previous = mappings.removeValue(forKey: resourceID) { - _ = hv_vm_unmap(guestBase + previous.offset, Int(previous.size)) - } - try hvCheck( - hv_vm_map(hostPointer, guestBase + offset, Int(mapSize), hv_memory_flags_t(HV_MEMORY_READ | HV_MEMORY_WRITE)), - "virtio-gpu host-visible blob hv_vm_map" + public func withSharedTextureHandle( + _ body: (MTLSharedTextureHandle) throws -> T + ) throws -> T { + let isRetired = lock.withLock { retired } + guard !isRetired else { throw VirtioGPUMetalScanoutPresentationError.retired } + return try core.withSharedTextureHandle( + consumerID: consumerID, + body ) - mappings[resourceID] = (offset, mapSize) } - public func unmap(resourceID: UInt32) { - lock.lock() - defer { lock.unlock() } - if let mapping = mappings.removeValue(forKey: resourceID) { - _ = hv_vm_unmap(guestBase + mapping.offset, Int(mapping.size)) - } + /// Retires this display consumer after all Metal and mmap references have been destroyed. + /// The worker release token is sent only after every consumer of the shared lease retires. + public func finishPresentation() { + retire() } -} -private extension UInt64 { - func roundedUpToMultiple(of alignment: UInt64) -> UInt64 { - guard alignment > 0 else { return self } - let remainder = self % alignment - return remainder == 0 ? self : self + (alignment - remainder) + /// Retires an update coalesced or rejected before presentation. This is intentionally the same + /// exact lifetime transition as a successfully displayed update. + public func discardWithoutPresentation() { + retire() + } + + deinit { + retire() + } + + private func retire() { + let shouldRetire = lock.withLock { () -> Bool in + guard !retired else { return false } + retired = true + return true + } + if shouldRetire { core.retireConsumer(consumerID) } } } -/// Experimental virtio-gpu device. -/// -/// Bootstrap mode keeps the Linux driver bring-up surface deliberately inert. Venus mode advertises -/// the Linux UAPI feature bits only when a host renderer is supplied, then forwards blob/context -/// commands to that renderer. -public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvider { - public let deviceID: UInt32 = 16 - public let queueCount = 2 - public let deviceFeatures: UInt64 - public let sharedMemoryRegions: [VirtioSharedMemoryRegion] +/// Exactly-once acknowledgement that a worker update reached a committed host Metal command +/// buffer. Merely enqueueing an update in the AppKit mailbox is not sufficient: a following guest +/// modeset could otherwise retire the lease before the main thread imports it. +private final class VirtioGPUMetalScanoutHostSubmission: @unchecked Sendable { + private let lock = NSLock() + private var completion: (@Sendable (Bool) -> Void)? - private let scanoutCount: UInt32 - private let renderer: VirtioGPURenderer? - private let capsets: [VirtioGPUCapset] - private let hostVisibleMemory: VirtioGPUHostVisibleMemory? - private var resourceEntries: [UInt32: [VirtioGPUMemoryEntry]] = [:] - private var blobResources: [UInt32: BlobResource] = [:] + init(completion: @escaping @Sendable (Bool) -> Void) { + self.completion = completion + } - // Real fence signalling: a fenced command's descriptor is held here and completed only when the - // renderer signals the fence (from its own thread), per the virtio-gpu contract — responding - // immediately would tell the guest its GPU work finished before it did. - private struct FenceKey: Hashable { - var contextID: UInt32 - var ringIndex: UInt32 + func resolve(accepted: Bool) { + let callback = lock.withLock { () -> (@Sendable (Bool) -> Void)? in + let callback = completion + completion = nil + return callback + } + callback?(accepted) } - private struct PendingFence { - var fenceID: UInt64 - var response: [UInt8] - var chain: VirtqueueChain + deinit { + resolve(accepted: false) } +} - private let fenceLock = NSLock() - private var pendingFences: [FenceKey: [PendingFence]] = [:] - private weak var lastTransport: VirtioMMIOTransport? +/// One zero-copy Metal scanout update. `sourceRect` selects the guest scanout from the immutable +/// worker resource; `dirtyRect` is scanout-local damage and never carries frame bytes. +public struct VirtioGPUMetalScanoutUpdate: Sendable, Equatable { + public let scanoutID: UInt32 + public let resourceID: UInt32 + /// VMM display-lifetime identity used to order SET_SCANOUT, release, and ID reuse. + public let resourceGeneration: UInt64 + /// Independent authenticated worker identity returned by CREATE_BLOB and bound into the lease. + public let rendererResourceGeneration: UInt64 + public let presentation: VirtioGPUMetalScanoutPresentation + public let sourceRect: VirtioGPURect + public let dirtyRect: VirtioGPURect + private let hostSubmission: VirtioGPUMetalScanoutHostSubmission - private enum HeaderFlag { - static let fence: UInt32 = 1 << 0 - static let infoRingIndex: UInt32 = 1 << 1 + fileprivate init( + scanoutID: UInt32, + resourceID: UInt32, + resourceGeneration: UInt64, + rendererResourceGeneration: UInt64, + presentation: VirtioGPUMetalScanoutPresentation, + sourceRect: VirtioGPURect, + dirtyRect: VirtioGPURect, + hostSubmission: VirtioGPUMetalScanoutHostSubmission + ) { + self.scanoutID = scanoutID + self.resourceID = resourceID + self.resourceGeneration = resourceGeneration + self.rendererResourceGeneration = rendererResourceGeneration + self.presentation = presentation + self.sourceRect = sourceRect + self.dirtyRect = dirtyRect + self.hostSubmission = hostSubmission } - private enum Command { - static let getDisplayInfo: UInt32 = 0x0100 - static let resourceCreate2D: UInt32 = 0x0101 - static let resourceUnref: UInt32 = 0x0102 - static let setScanout: UInt32 = 0x0103 - static let resourceFlush: UInt32 = 0x0104 - static let transferToHost2D: UInt32 = 0x0105 - static let resourceAttachBacking: UInt32 = 0x0106 - static let resourceDetachBacking: UInt32 = 0x0107 - static let getCapsetInfo: UInt32 = 0x0108 - static let getCapset: UInt32 = 0x0109 - static let resourceCreateBlob: UInt32 = 0x010C - static let setScanoutBlob: UInt32 = 0x010D - static let ctxCreate: UInt32 = 0x0200 - static let ctxDestroy: UInt32 = 0x0201 - static let ctxAttachResource: UInt32 = 0x0202 - static let ctxDetachResource: UInt32 = 0x0203 - static let resourceCreate3D: UInt32 = 0x0204 - static let transferToHost3D: UInt32 = 0x0205 - static let transferFromHost3D: UInt32 = 0x0206 - static let submit3D: UInt32 = 0x0207 - static let resourceMapBlob: UInt32 = 0x0208 - static let resourceUnmapBlob: UInt32 = 0x0209 - static let updateCursor: UInt32 = 0x0300 - static let moveCursor: UInt32 = 0x0301 + /// Completes the guest flush only after the display consumer has imported the lease and + /// committed a Metal command buffer that retains it. + public func acceptHostSubmission() { + hostSubmission.resolve(accepted: true) } - private enum Response { - static let okNoData: UInt32 = 0x1100 - static let okDisplayInfo: UInt32 = 0x1101 - static let okCapsetInfo: UInt32 = 0x1102 - static let okCapset: UInt32 = 0x1103 - static let okMapInfo: UInt32 = 0x1106 - static let errorUnspecified: UInt32 = 0x1200 - static let errorInvalidParameter: UInt32 = 0x1202 + /// Fails the guest flush when the display consumer cannot commit the worker frame. The caller + /// must also retire `presentation` after all local references have been destroyed. + public func rejectHostSubmission() { + hostSubmission.resolve(accepted: false) } - private enum Feature { - static let virgl: UInt64 = 1 << 0 - static let resourceUUID: UInt64 = 1 << 2 - static let resourceBlob: UInt64 = 1 << 3 - static let contextInit: UInt64 = 1 << 4 + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.scanoutID == rhs.scanoutID + && lhs.resourceID == rhs.resourceID + && lhs.resourceGeneration == rhs.resourceGeneration + && lhs.rendererResourceGeneration == rhs.rendererResourceGeneration + && lhs.presentation.leaseID == rhs.presentation.leaseID + && lhs.sourceRect == rhs.sourceRect + && lhs.dirtyRect == rhs.dirtyRect } +} - private enum Capset { - static let venus: UInt32 = 4 - } +/// Joins one host-submission acknowledgement per scanout for a single RESOURCE_FLUSH. Multi-head +/// guests receive success only after every target has accepted the same worker lease generation. +private final class DoryRendererWorkerHostSubmissionGroup: @unchecked Sendable { + private let lock = NSLock() + private var remaining: Int + private var accepted = true + private var completion: (@Sendable (Bool) -> Void)? - private struct BlobResource { - var size: UInt64 + init(count: Int, completion: @escaping @Sendable (Bool) -> Void) { + precondition(count > 0) + self.remaining = count + self.completion = completion } - /// - Parameters: - /// - hostMemoryBase: Guest physical base of the virtio-gpu host-visible memory window. - /// - hostMemorySize: Size of the host-visible memory window reported through virtio-mmio. - public init( - hostMemoryBase: UInt64, - hostMemorySize: UInt64 = 256 * 1024 * 1024, - scanoutCount: UInt32 = 0, - renderer: VirtioGPURenderer? = nil, - hostVisibleMemory: VirtioGPUHostVisibleMemory? = nil - ) { - self.renderer = renderer - self.capsets = renderer?.capsets ?? [] - self.deviceFeatures = renderer == nil - ? 0 - : Feature.virgl | Feature.resourceUUID | Feature.resourceBlob | Feature.contextInit - self.sharedMemoryRegions = [ - VirtioSharedMemoryRegion(id: 1, guestBase: hostMemoryBase, length: hostVisibleMemory?.length ?? hostMemorySize) - ] - self.scanoutCount = scanoutCount - self.hostVisibleMemory = hostVisibleMemory - renderer?.onFenceSignaled = { [weak self] contextID, ringIndex, fenceID in - self?.fenceSignaled(contextID: contextID, ringIndex: ringIndex, fenceID: fenceID) + func resolve(accepted: Bool) { + let result = lock.withLock { () -> ( + (@Sendable (Bool) -> Void), Bool + )? in + guard remaining > 0 else { return nil } + self.accepted = self.accepted && accepted + remaining -= 1 + guard remaining == 0, let completion else { return nil } + self.completion = nil + return (completion, self.accepted) } + if let result { result.0(result.1) } } +} - public var configSpace: [UInt8] { - var config = [UInt8]() - config.appendLE(UInt32(0)) // events_read - config.appendLE(UInt32(0)) // events_clear - config.appendLE(scanoutCount) // num_scanouts - config.appendLE(UInt32(capsets.count)) // num_capsets - return config - } +/// Shared core for one producer-complete worker lease. A resource may be bound to several guest +/// scanouts, so each update receives a distinct consumer handle while the underlying transport +/// authority and release token remain singular. +private final class DoryRendererWorkerSharedScanoutCore: @unchecked Sendable { + typealias Release = @Sendable (DoryRendererWorkerScanoutAuthority) -> Void + typealias Terminal = @Sendable (DoryRendererScanoutReleaseToken) -> Void - public func handleKick(queue: Int, transport: VirtioMMIOTransport) { - guard queue == 0 || queue == 1 else { return } - fenceLock.lock() - lastTransport = transport - fenceLock.unlock() - let virtqueue = transport.queues[queue] - var interrupt = false - while let chain = (try? virtqueue.pop()) ?? nil { - let request = chain.readBytes() - let response = process(request: request, cursorQueue: queue == 1, transport: transport) - if queue == 0, deferForFence(request: request, response: response, chain: chain) { - continue - } - let written = chain.writeBytes(response) - let wants = (try? virtqueue.push(chain, written: written)) ?? false - interrupt = interrupt || wants - } - if interrupt { - transport.notifyUsed() - } + private enum State { + case ready + case terminal } - /// Holds a successfully processed, fenced command's descriptor until the renderer signals the - /// fence. Returns false (respond immediately) for unfenced commands, errors — whose response - /// still carries the fence id, which the guest treats as the signal — and fence-registration - /// failures, so a broken fence path degrades to the old eager completion instead of hanging. - private func deferForFence(request: [UInt8], response: [UInt8], chain: VirtqueueChain) -> Bool { - guard let renderer, request.count >= 24, response.count >= 4 else { return false } + let releaseToken: DoryRendererScanoutReleaseToken + + private let lock = NSLock() + private var state: State = .ready + private var pendingConsumers: Set + private var scanout: DoryRendererWorkerScanoutAuthority? + private var release: Release? + private var terminal: Terminal? + + init( + scanout: DoryRendererWorkerScanoutAuthority, + consumerCount: Int, + release: @escaping Release, + terminal: @escaping Terminal + ) throws { + guard consumerCount > 0, consumerCount <= 16 else { + scanout.discardTransport() + throw VMError.invalidConfiguration("invalid worker scanout consumer count") + } + self.releaseToken = scanout.releaseToken + self.pendingConsumers = Set((0.. VirtioGPUMetalScanoutPresentation? { + lock.withLock { + guard case .ready = state, pendingConsumers.contains(consumerID) else { return nil } + return VirtioGPUMetalScanoutPresentation( + scanout: scanout!, + consumerID: consumerID, + core: self + ) + } + } + + func withSharedMemoryScanout( + consumerID: UInt32, + _ body: (DoryRendererScanoutLease, Int32) throws -> T + ) throws -> T { + lock.lock() + defer { lock.unlock() } + guard case .ready = state, + pendingConsumers.contains(consumerID), + case .sharedMemory(let value)? = scanout, + value.sharedMemoryDescriptor.fileDescriptor >= 0 else { + throw VirtioGPUMetalScanoutPresentationError.retired + } + return try body(value.lease, value.sharedMemoryDescriptor.fileDescriptor) + } + + func withSharedTextureHandle( + consumerID: UInt32, + _ body: (MTLSharedTextureHandle) throws -> T + ) throws -> T { + lock.lock() + defer { lock.unlock() } + guard case .ready = state, + pendingConsumers.contains(consumerID), + case .sharedTexture(let value)? = scanout else { + throw VirtioGPUMetalScanoutPresentationError.retired + } + return try body(value.sharedTextureHandle) + } + + func retireConsumer(_ consumerID: UInt32) { + let terminalAuthority = lock.withLock { () -> ( + DoryRendererWorkerScanoutAuthority?, Release?, Terminal? + )? in + guard case .ready = state, + pendingConsumers.remove(consumerID) != nil else { return nil } + guard pendingConsumers.isEmpty else { return nil } + state = .terminal + let scanout = self.scanout + self.scanout = nil + let release = self.release + self.release = nil + let terminal = self.terminal + self.terminal = nil + return (scanout, release, terminal) + } + guard let terminalAuthority else { return } + terminalAuthority.0?.discardTransport() + terminalAuthority.2?(releaseToken) + if let scanout = terminalAuthority.0 { terminalAuthority.1?(scanout) } + } + + /// Discards every not-yet-published consumer while the worker generation remains valid. + func retireWithoutPresentation() { + let terminalAuthority = terminate(requestWorkerRelease: true) + finish(terminalAuthority) + } + + /// Revokes local transport authority without attempting a token release into an already-revoked worker + /// generation. Used by reset, queue teardown, crash, and generation drift. + func revoke() { + let terminalAuthority = terminate(requestWorkerRelease: false) + finish(terminalAuthority) + } + + private func terminate( + requestWorkerRelease: Bool + ) -> (DoryRendererWorkerScanoutAuthority?, Release?, Terminal?)? { + lock.withLock { + guard case .terminal = state else { + state = .terminal + pendingConsumers.removeAll(keepingCapacity: false) + let scanout = self.scanout + self.scanout = nil + let release = requestWorkerRelease ? self.release : nil + self.release = nil + let terminal = self.terminal + self.terminal = nil + return (scanout, release, terminal) + } + return nil + } + } + + private func finish( + _ authority: (DoryRendererWorkerScanoutAuthority?, Release?, Terminal?)? + ) { + guard let authority else { return } + authority.0?.discardTransport() + authority.2?(releaseToken) + if let scanout = authority.0 { authority.1?(scanout) } + } +} + +private final class DoryRendererWorkerWeakScanoutCore: @unchecked Sendable { + weak var value: DoryRendererWorkerSharedScanoutCore? + + init(_ value: DoryRendererWorkerSharedScanoutCore) { + self.value = value + } +} + +/// One direct renderer-texture presentation. `sourceRect` selects the guest scanout within the +/// backing texture; `dirtyRect` is scanout-local damage. Damage schedules a redraw only—the texture +/// remains the single authoritative surface and no partial framebuffer bytes are copied or queued. +public struct VirtioGPUScanoutTextureUpdate: Sendable, Equatable { + public var scanoutID: UInt32 + public var presentation: VirtioGPUTexturePresentation + public var sourceRect: VirtioGPURect + public var dirtyRect: VirtioGPURect + + public var texture: VirtioGPUTextureResource { presentation.texture } + public var resourceID: UInt32 { presentation.resourceID } + public var resourceGeneration: UInt64 { presentation.resourceGeneration } + + public init( + scanoutID: UInt32, + presentation: VirtioGPUTexturePresentation, + sourceRect: VirtioGPURect, + dirtyRect: VirtioGPURect + ) { + self.scanoutID = scanoutID + self.presentation = presentation + self.sourceRect = sourceRect + self.dirtyRect = dirtyRect + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.scanoutID == rhs.scanoutID + && lhs.resourceID == rhs.resourceID + && lhs.resourceGeneration == rhs.resourceGeneration + && lhs.texture == rhs.texture + && lhs.sourceRect == rhs.sourceRect + && lhs.dirtyRect == rhs.dirtyRect + } +} + +/// Acknowledged retirement of one guest resource generation from every configured scanout. +/// Renderer destruction is deferred until each scanout has detached any shared framebuffer +/// attachment and submitted that detach. Acknowledgements are keyed by scanout ID, making retries +/// idempotent and preventing one consumer from accidentally retiring another consumer's lease. +public final class VirtioGPUScanoutResourceRelease: @unchecked Sendable { + public let resourceID: UInt32 + public let resourceGeneration: UInt64 + public let scanoutCount: UInt32 + + private let lock = NSLock() + private var pendingScanoutIDs: Set + private var completion: (@Sendable () -> Void)? + + init( + resourceID: UInt32, + resourceGeneration: UInt64, + scanoutCount: UInt32, + completion: @escaping @Sendable () -> Void + ) { + self.resourceID = resourceID + self.resourceGeneration = resourceGeneration + self.scanoutCount = scanoutCount + self.pendingScanoutIDs = Set(0.. Void)? = lock.withLock { + guard pendingScanoutIDs.remove(scanoutID) != nil, + pendingScanoutIDs.isEmpty else { + return nil + } + defer { completion = nil } + return completion + } + completed?() + } + + func acknowledgeAll() { + let completed: (@Sendable () -> Void)? = lock.withLock { + pendingScanoutIDs.removeAll() + defer { completion = nil } + return completion + } + completed?() + } +} + +public struct VirtioGPUStatistics: Equatable, Sendable { + public var fences: UInt64 + public var fenceRegistrationFailures: UInt64 + public var fenceTimeouts: UInt64 + public var hasTimedOutPendingFence: Bool + public var rendererDeviceLosses: UInt64 + public var hasLostRendererDevice: Bool + public var queuePendingReadFailures: UInt64 + public var queuePopFailures: UInt64 + public var invalidDescriptorChains: UInt64 + public var oversizedRequests: UInt64 + public var insufficientResponseCapacity: UInt64 + public var queuePushFailures: UInt64 + public var revokedCompletions: UInt64 + public var undeliveredFenceCompletions: UInt64 + public var responseWriteFailures: UInt64 + public var fenceAdmissionRejections: UInt64 + public var queueRevokedFences: UInt64 + public var resetRevokedFences: UInt64 + public var rendererCommandUncertainties: UInt64 + public var revokedUncertainRendererCommands: UInt64 + public var rendererWorkerSnapshotCount: UInt64 + public var rendererWorkerSnapshotBytes: UInt64 + public var rendererWorkerSnapshotNanoseconds: UInt64 + public var rendererWorkerMaximumSnapshotNanoseconds: UInt64 + public var rendererWorkerQueuedCommands: Int + public var rendererWorkerMaximumQueuedCommands: Int + public var rendererWorkerRejectedAdmissions: UInt64 + public var rendererWorkerCompletedControlCommands: UInt64 + public var rendererWorkerCompletedResourceCommands: UInt64 + public var rendererWorkerCompletedSubmissions: UInt64 + public var rendererWorkerArmedFences: Int + public var rendererWorkerCompletedFences: UInt64 + public var rendererWorkerScanoutCopyBytes: UInt64 + /// Guest-backing bytes copied into software scanout updates. This is separate from the worker + /// accelerated path, whose scanout copy count is required to remain exactly zero. + public var softwareScanoutCopiedBytes: UInt64 + + public init( + fences: UInt64, + fenceRegistrationFailures: UInt64, + fenceTimeouts: UInt64, + hasTimedOutPendingFence: Bool, + rendererDeviceLosses: UInt64 = 0, + hasLostRendererDevice: Bool = false, + queuePendingReadFailures: UInt64 = 0, + queuePopFailures: UInt64 = 0, + invalidDescriptorChains: UInt64 = 0, + oversizedRequests: UInt64 = 0, + insufficientResponseCapacity: UInt64 = 0, + queuePushFailures: UInt64 = 0, + revokedCompletions: UInt64 = 0, + undeliveredFenceCompletions: UInt64 = 0, + responseWriteFailures: UInt64 = 0, + fenceAdmissionRejections: UInt64 = 0, + queueRevokedFences: UInt64 = 0, + resetRevokedFences: UInt64 = 0, + rendererCommandUncertainties: UInt64 = 0, + revokedUncertainRendererCommands: UInt64 = 0, + rendererWorkerSnapshotCount: UInt64 = 0, + rendererWorkerSnapshotBytes: UInt64 = 0, + rendererWorkerSnapshotNanoseconds: UInt64 = 0, + rendererWorkerMaximumSnapshotNanoseconds: UInt64 = 0, + rendererWorkerQueuedCommands: Int = 0, + rendererWorkerMaximumQueuedCommands: Int = 0, + rendererWorkerRejectedAdmissions: UInt64 = 0, + rendererWorkerCompletedControlCommands: UInt64 = 0, + rendererWorkerCompletedResourceCommands: UInt64 = 0, + rendererWorkerCompletedSubmissions: UInt64 = 0, + rendererWorkerArmedFences: Int = 0, + rendererWorkerCompletedFences: UInt64 = 0, + rendererWorkerScanoutCopyBytes: UInt64 = 0, + softwareScanoutCopiedBytes: UInt64 = 0 + ) { + self.fences = fences + self.fenceRegistrationFailures = fenceRegistrationFailures + self.fenceTimeouts = fenceTimeouts + self.hasTimedOutPendingFence = hasTimedOutPendingFence + self.rendererDeviceLosses = rendererDeviceLosses + self.hasLostRendererDevice = hasLostRendererDevice + self.queuePendingReadFailures = queuePendingReadFailures + self.queuePopFailures = queuePopFailures + self.invalidDescriptorChains = invalidDescriptorChains + self.oversizedRequests = oversizedRequests + self.insufficientResponseCapacity = insufficientResponseCapacity + self.queuePushFailures = queuePushFailures + self.revokedCompletions = revokedCompletions + self.undeliveredFenceCompletions = undeliveredFenceCompletions + self.responseWriteFailures = responseWriteFailures + self.fenceAdmissionRejections = fenceAdmissionRejections + self.queueRevokedFences = queueRevokedFences + self.resetRevokedFences = resetRevokedFences + self.rendererCommandUncertainties = rendererCommandUncertainties + self.revokedUncertainRendererCommands = revokedUncertainRendererCommands + self.rendererWorkerSnapshotCount = rendererWorkerSnapshotCount + self.rendererWorkerSnapshotBytes = rendererWorkerSnapshotBytes + self.rendererWorkerSnapshotNanoseconds = rendererWorkerSnapshotNanoseconds + self.rendererWorkerMaximumSnapshotNanoseconds = + rendererWorkerMaximumSnapshotNanoseconds + self.rendererWorkerQueuedCommands = rendererWorkerQueuedCommands + self.rendererWorkerMaximumQueuedCommands = rendererWorkerMaximumQueuedCommands + self.rendererWorkerRejectedAdmissions = rendererWorkerRejectedAdmissions + self.rendererWorkerCompletedControlCommands = + rendererWorkerCompletedControlCommands + self.rendererWorkerCompletedResourceCommands = + rendererWorkerCompletedResourceCommands + self.rendererWorkerCompletedSubmissions = rendererWorkerCompletedSubmissions + self.rendererWorkerArmedFences = rendererWorkerArmedFences + self.rendererWorkerCompletedFences = rendererWorkerCompletedFences + self.rendererWorkerScanoutCopyBytes = rendererWorkerScanoutCopyBytes + self.softwareScanoutCopiedBytes = softwareScanoutCopiedBytes + } +} + +/// A renderer failure whose meaning is host-owned and therefore safe to publish as device-loss +/// telemetry. Ordinary renderer command errors are deliberately excluded: malformed guest 3D +/// commands must not be relabeled as a host GPU failure. +public enum VirtioGPURendererRuntimeFailure: Error, Equatable, Sendable { + case deviceLost(String) +} + +/// A renderer may report reset recovery only when every pre-reset context, resource, mapping, and +/// fence callback has been destroyed or made permanently unreachable before this call returns. +/// Returning `requiresRecreation` keeps the existing renderer object quarantined; the device will +/// reject further renderer-backed guest commands rather than pretend an in-place reset succeeded. +public enum VirtioGPURendererResetResult: Equatable, Sendable { + case ready + case requiresRecreation(String) +} + +public enum VirtioGPURendererHealthFault: Error, Equatable, Sendable { + case commandOutcomeUnknown(operation: String, detail: String) + case fenceRegistrationFailed(String) + case resetRequiresRecreation(String) + case resetFailed(String) + case resourceRetirementFailed(resourceID: UInt32, generation: UInt64, detail: String) + case quiescenceTimedOut(epoch: UInt64) +} + +public enum VirtioGPURendererLifecycleHealth: Equatable, Sendable { + case notConfigured + case ready(epoch: UInt64) + case quiescing(epoch: UInt64) + case failed(epoch: UInt64, fault: VirtioGPURendererHealthFault) +} + +public enum VirtioGPUQuiescenceReason: Equatable, Sendable { + case deviceReset + case shutdown +} + +public enum VirtioGPUQuiescenceOutcome: Equatable, Sendable { + case completed + case failed(VirtioGPURendererHealthFault) +} + +/// Receipt for an asynchronous display/renderer quiescence boundary. MMIO reset waits on this +/// receipt before returning to the guest; process shutdown can request the same boundary without +/// blocking its caller and wait from an appropriate lifecycle queue. +public final class VirtioGPUQuiescence: @unchecked Sendable { + public let epoch: UInt64 + public let reason: VirtioGPUQuiescenceReason + + private let condition = NSCondition() + private var storedOutcome: VirtioGPUQuiescenceOutcome? + + init(epoch: UInt64, reason: VirtioGPUQuiescenceReason) { + self.epoch = epoch + self.reason = reason + } + + public var outcome: VirtioGPUQuiescenceOutcome? { + condition.lock() + defer { condition.unlock() } + return storedOutcome + } + + public func wait(timeout: TimeInterval) -> VirtioGPUQuiescenceOutcome? { + let deadline = Date(timeIntervalSinceNow: max(0, timeout)) + condition.lock() + defer { condition.unlock() } + while storedOutcome == nil, condition.wait(until: deadline) {} + return storedOutcome + } + + func complete(_ outcome: VirtioGPUQuiescenceOutcome) { + condition.lock() + guard storedOutcome == nil else { + condition.unlock() + return + } + storedOutcome = outcome + condition.broadcast() + condition.unlock() + } +} + +/// A copied guest cursor plane ready for the host window. Cursor bytes are never exposed through +/// guest-owned pointers: the device snapshots the complete 32-bit BGRA resource at the +/// UPDATE_CURSOR command boundary and carries the guest hotspot with it. +public struct VirtioGPUCursorUpdate: Sendable, Equatable { + public var scanoutID: UInt32 + public var resourceID: UInt32 + public var x: UInt32 + public var y: UInt32 + public var width: UInt32 + public var height: UInt32 + public var hotX: UInt32 + public var hotY: UInt32 + public var bytes: Data + + public init( + scanoutID: UInt32, + resourceID: UInt32, + x: UInt32, + y: UInt32, + width: UInt32, + height: UInt32, + hotX: UInt32, + hotY: UInt32, + bytes: Data + ) { + self.scanoutID = scanoutID + self.resourceID = resourceID + self.x = x + self.y = y + self.width = width + self.height = height + self.hotX = hotX + self.hotY = hotY + self.bytes = bytes + } +} + +public protocol VirtioGPURenderer: AnyObject, Sendable { + var capsets: [VirtioGPUCapset] { get } + /// True only when `makeScanoutPresentation` supplies a real producer-completion handoff. + var supportsSynchronizedScanoutPresentation: Bool { get } + var onRuntimeFailure: ((VirtioGPURendererRuntimeFailure) -> Void)? { get set } + func createContext(id: UInt32, flags: UInt32, name: String) throws + func destroyContext(id: UInt32) throws + func attachResource(contextID: UInt32, resourceID: UInt32) throws + func detachResource(contextID: UInt32, resourceID: UInt32) throws + func submit3D(contextID: UInt32, command: [UInt8]) throws + func createResource3D(_ resource: VirtioGPUResourceCreate3D, entries: [VirtioGPUMemoryEntry]) throws + func createBlob( + resourceID: UInt32, + contextID: UInt32, + blobMemory: UInt32, + blobFlags: UInt32, + blobID: UInt64, + size: UInt64, + entries: [VirtioGPUMemoryEntry] + ) throws + func attachBacking(resourceID: UInt32, entries: [VirtioGPUMemoryEntry]) throws + func detachBacking(resourceID: UInt32) throws + func unrefResource(resourceID: UInt32) throws + func mapBlob(resourceID: UInt32) throws -> VirtioGPUBlobMapping + func unmapBlob(resourceID: UInt32) throws + func transferToHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws + func transferFromHost3D(_ transfer: VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) throws + /// Returns a shared texture together with synchronization authority covering all producer work + /// submitted before this call. Implementations must fail closed when they cannot identify the + /// producer context or cannot create a completion primitive usable by the display context. + func makeScanoutPresentation( + resourceID: UInt32, + resourceGeneration: UInt64 + ) throws -> VirtioGPUTexturePresentation + /// Called only after every display release has been acknowledged and every device-tracked + /// renderer resource has been unmapped/unreferenced. `.ready` is a strong epoch barrier: no + /// fence callback or renderer state from the old guest epoch may become observable afterward. + func resetAfterDeviceQuiesce() throws -> VirtioGPURendererResetResult + /// Registers a fence that must call `onFenceSignaled` (possibly from another thread) once all + /// GPU work submitted before it has completed. Context fences order per (context, ring); plain + /// fences ride the global ctx0 timeline and signal as (0, 0, id). + func createFence(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64, contextFence: Bool) throws + var onFenceSignaled: ((_ contextID: UInt32, _ ringIndex: UInt32, _ fenceID: UInt64) -> Void)? { get set } +} + +public extension VirtioGPURenderer { + var supportsSynchronizedScanoutPresentation: Bool { false } + + func makeScanoutPresentation( + resourceID: UInt32, + resourceGeneration: UInt64 + ) throws -> VirtioGPUTexturePresentation { + throw VirtioGPURendererCommandRejected( + "renderer does not provide synchronized shared-texture presentation" + ) + } + + func resetAfterDeviceQuiesce() throws -> VirtioGPURendererResetResult { + .requiresRecreation("renderer does not implement recreate-safe in-place reset") + } +} + +extension VirtioGPUMemoryEntry: @unchecked Sendable {} + +/// A renderer adapter may use this error only when it can prove that a command was rejected before +/// any renderer-visible mutation. Every other thrown error is conservatively classified as an +/// unknown outcome by `VirtioGPURendererCommandExecutor`. +public struct VirtioGPURendererCommandRejected: Error, Equatable, Sendable { + public let detail: String + + public init(_ detail: String) { + self.detail = detail + } +} + +enum VirtioGPURendererCommand: @unchecked Sendable { + case createContext(id: UInt32, flags: UInt32, name: String) + case destroyContext(id: UInt32) + case attachResource(contextID: UInt32, resourceID: UInt32) + case detachResource(contextID: UInt32, resourceID: UInt32) + case submit3D(contextID: UInt32, command: [UInt8]) + case createResource3D(VirtioGPUResourceCreate3D, entries: [VirtioGPUMemoryEntry]) + case createBlob( + resourceID: UInt32, + contextID: UInt32, + blobMemory: UInt32, + blobFlags: UInt32, + blobID: UInt64, + size: UInt64, + entries: [VirtioGPUMemoryEntry] + ) + case attachBacking(resourceID: UInt32, entries: [VirtioGPUMemoryEntry]) + case detachBacking(resourceID: UInt32) + case unrefResource(resourceID: UInt32) + case mapBlob(resourceID: UInt32) + case unmapBlob(resourceID: UInt32) + case transferToHost3D(VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) + case transferFromHost3D(VirtioGPUTransfer3D, entries: [VirtioGPUMemoryEntry]) + case makeScanoutPresentation(resourceID: UInt32, resourceGeneration: UInt64) + case createFence( + contextID: UInt32, + ringIndex: UInt32, + guestFenceID: UInt64, + contextFence: Bool + ) + case resetAfterDeviceQuiesce(successorGeneration: UInt64) + + var operation: String { + switch self { + case .createContext: "create-context" + case .destroyContext: "destroy-context" + case .attachResource: "attach-resource" + case .detachResource: "detach-resource" + case .submit3D: "submit-3d" + case .createResource3D: "create-resource-3d" + case .createBlob: "create-blob" + case .attachBacking: "attach-backing" + case .detachBacking: "detach-backing" + case .unrefResource: "unref-resource" + case .mapBlob: "map-blob" + case .unmapBlob: "unmap-blob" + case .transferToHost3D: "transfer-to-host-3d" + case .transferFromHost3D: "transfer-from-host-3d" + case .makeScanoutPresentation: "make-scanout-presentation" + case .createFence: "create-fence" + case .resetAfterDeviceQuiesce: "reset-after-device-quiesce" + } + } +} + +enum VirtioGPURendererCommandValue: @unchecked Sendable { + case none + case blobMapping(VirtioGPUBlobMapping) + case scanoutPresentation(VirtioGPUTexturePresentation) + case reset(VirtioGPURendererResetResult) +} + +enum VirtioGPURendererCommandRejection: Equatable, Sendable { + case invalidInput(operation: String, detail: String) + case staleGeneration(expected: UInt64, actual: UInt64) + case admissionClosed(generation: UInt64) + case renderer(operation: String, detail: String) +} + +struct VirtioGPURendererCommandUncertainty: Equatable, Sendable { + var operation: String + var generation: UInt64 + var detail: String + var runtimeFailure: VirtioGPURendererRuntimeFailure? +} + +enum VirtioGPURendererCommandOutcome: @unchecked Sendable { + case success(VirtioGPURendererCommandValue) + case rejected(VirtioGPURendererCommandRejection) + case outcomeUnknown(VirtioGPURendererCommandUncertainty) +} + +enum VirtioGPURendererCommandPurpose: Sendable { + case guest + case retirement +} + +enum VirtioGPURendererQuiescenceAdmission: Equatable, Sendable { + case admitted(sourceGeneration: UInt64) + case rejected(VirtioGPURendererCommandRejection) +} + +/// The legacy in-process renderer serialization seam. +/// +/// All device-to-renderer calls, including callback installation, pass through this executor. It +/// serializes one renderer generation, owns bounded copies of variable command metadata, assigns +/// non-reused host fence identities, and never guesses that a thrown adapter call was harmless. +/// It is retained only until the signed worker has equivalent operation coverage. Production +/// cutover must replace pointer-bearing commands with the typed renderer-worker envelopes, +/// descriptor-backed regions, SHM scanout leases, and producer-fence/release receipts; helper death +/// is an outcome-unknown generation. This executor and VirglRenderer must be deleted in the same +/// cutover that selects the qualified worker—never left as an accelerated fallback. +final class VirtioGPURendererCommandExecutor: @unchecked Sendable { + private enum State { + case active(UInt64) + case revoked(UInt64) + case uncertain(UInt64) + case quiescing(source: UInt64, successor: UInt64) + } + + private enum FenceRegistrationState { + case registering(signalObserved: Bool) + case registered + } + + private struct FenceCallbackKey: Hashable { + var contextID: UInt32 + var ringIndex: UInt32 + var hostFenceID: UInt64 + } + + private struct FenceRegistration { + var generation: UInt64 + var guestContextID: UInt32 + var guestRingIndex: UInt32 + var guestFenceID: UInt64 + var state: FenceRegistrationState + } + + let capsets: [VirtioGPUCapset] + let supportsSynchronizedScanoutPresentation: Bool + + private let renderer: VirtioGPURenderer + private let maximumCommandBytes: Int + private let maximumMemoryEntries: Int + private let maximumReferencedBytes: UInt64 + private let lock = NSRecursiveLock() + private var state: State + private var nextHostFenceID: UInt64 = 1 + private var fences = [FenceCallbackKey: FenceRegistration]() + private var fenceSink: ((UInt64, UInt32, UInt32, UInt64) -> Void)? + private var runtimeFailureSink: ((UInt64, VirtioGPURendererRuntimeFailure) -> Void)? + + init( + renderer: VirtioGPURenderer, + initialGeneration: UInt64 = 1, + maximumCommandBytes: Int, + maximumMemoryEntries: Int, + maximumReferencedBytes: UInt64 + ) { + self.renderer = renderer + self.capsets = renderer.capsets.map { + VirtioGPUCapset(id: $0.id, maxVersion: $0.maxVersion, data: Array($0.data)) + } + self.supportsSynchronizedScanoutPresentation = + renderer.supportsSynchronizedScanoutPresentation + self.maximumCommandBytes = max(1, maximumCommandBytes) + self.maximumMemoryEntries = max(1, maximumMemoryEntries) + self.maximumReferencedBytes = max(1, maximumReferencedBytes) + self.state = .active(initialGeneration == 0 ? 1 : initialGeneration) + renderer.onFenceSignaled = { [weak self] contextID, ringIndex, fenceID in + self?.receiveFence(contextID: contextID, ringIndex: ringIndex, hostFenceID: fenceID) + } + renderer.onRuntimeFailure = { [weak self] failure in + self?.receiveRuntimeFailure(failure) + } + } + + func installCallbacks( + fence: @escaping @Sendable (UInt64, UInt32, UInt32, UInt64) -> Void, + runtimeFailure: @escaping @Sendable (UInt64, VirtioGPURendererRuntimeFailure) -> Void + ) { + lock.withLock { + fenceSink = fence + runtimeFailureSink = runtimeFailure + } + } + + func revokeActiveGeneration() { + lock.withLock { + switch state { + case .active(let generation), .uncertain(let generation): + state = .revoked(generation) + fences.removeAll() + case .revoked, .quiescing: + break + } + } + } + + func beginQuiescence(successorGeneration: UInt64) -> VirtioGPURendererQuiescenceAdmission { + lock.withLock { + let source: UInt64 + switch state { + case .active(let generation), .revoked(let generation), .uncertain(let generation): + source = generation + case .quiescing(let generation, let existingSuccessor): + guard existingSuccessor == successorGeneration else { + return .rejected(.admissionClosed(generation: generation)) + } + return .admitted(sourceGeneration: generation) + } + guard successorGeneration != 0, successorGeneration != source else { + return .rejected(.invalidInput( + operation: "begin-quiescence", + detail: "successor generation must be nonzero and distinct" + )) + } + state = .quiescing(source: source, successor: successorGeneration) + fences.removeAll() + return .admitted(sourceGeneration: source) + } + } + + func execute( + _ requestedCommand: VirtioGPURendererCommand, + generation: UInt64, + purpose: VirtioGPURendererCommandPurpose = .guest + ) -> VirtioGPURendererCommandOutcome { + lock.lock() + defer { lock.unlock() } + + guard let command = boundedCopy(of: requestedCommand) else { + return .rejected(.invalidInput( + operation: requestedCommand.operation, + detail: "renderer command exceeds its configured input bound" + )) + } + if case .resetAfterDeviceQuiesce(let successor) = command { + return executeReset( + generation: generation, + successorGeneration: successor, + operation: command.operation + ) + } + guard admits(generation: generation, purpose: purpose) else { + return generationRejection(for: generation) + } + if case let .createFence(contextID, ringIndex, guestFenceID, contextFence) = command { + return executeFence( + generation: generation, + contextID: contextID, + ringIndex: ringIndex, + guestFenceID: guestFenceID, + contextFence: contextFence + ) + } + + do { + let value = try invoke(command) + return .success(value) + } catch let rejection as VirtioGPURendererCommandRejected { + return .rejected(.renderer( + operation: command.operation, + detail: rejection.detail + )) + } catch { + state = .uncertain(generation) + fences.removeAll() + return .outcomeUnknown(uncertainty( + operation: command.operation, + generation: generation, + error: error + )) + } + } + + private func admits( + generation: UInt64, + purpose: VirtioGPURendererCommandPurpose + ) -> Bool { + switch (state, purpose) { + case (.active(let active), .guest), + (.active(let active), .retirement), + (.revoked(let active), .retirement), + (.quiescing(let active, _), .retirement): + return active == generation + case (.revoked, .guest), (.uncertain, _), (.quiescing, .guest): + return false + } + } + + private func generationRejection(for generation: UInt64) -> VirtioGPURendererCommandOutcome { + let actual: UInt64 + switch state { + case .active(let value): + actual = value + case .revoked(let value), .uncertain(let value): + actual = value + case .quiescing(let value, _): + actual = value + } + return actual == generation + ? .rejected(.admissionClosed(generation: generation)) + : .rejected(.staleGeneration(expected: actual, actual: generation)) + } + + private func executeReset( + generation: UInt64, + successorGeneration: UInt64, + operation: String + ) -> VirtioGPURendererCommandOutcome { + guard case .quiescing(let source, let successor) = state, + source == generation, + successor == successorGeneration else { + return generationRejection(for: generation) + } + do { + let result = try renderer.resetAfterDeviceQuiesce() + switch result { + case .ready: + state = .active(successorGeneration) + case .requiresRecreation: + state = .revoked(generation) + } + fences.removeAll() + return .success(.reset(result)) + } catch let rejection as VirtioGPURendererCommandRejected { + state = .revoked(generation) + return .rejected(.renderer(operation: operation, detail: rejection.detail)) + } catch { + state = .uncertain(generation) + fences.removeAll() + return .outcomeUnknown(uncertainty( + operation: operation, + generation: generation, + error: error + )) + } + } + + private func executeFence( + generation: UInt64, + contextID: UInt32, + ringIndex: UInt32, + guestFenceID: UInt64, + contextFence: Bool + ) -> VirtioGPURendererCommandOutcome { + guard nextHostFenceID <= UInt64(UInt32.max) else { + return .rejected(.invalidInput( + operation: "create-fence", + detail: "host fence identity space exhausted" + )) + } + let hostFenceID = nextHostFenceID + nextHostFenceID += 1 + let callbackKey = FenceCallbackKey( + contextID: contextFence ? contextID : 0, + ringIndex: contextFence ? ringIndex : 0, + hostFenceID: hostFenceID + ) + fences[callbackKey] = FenceRegistration( + generation: generation, + guestContextID: contextFence ? contextID : 0, + guestRingIndex: contextFence ? ringIndex : 0, + guestFenceID: guestFenceID, + state: .registering(signalObserved: false) + ) + + do { + try renderer.createFence( + contextID: contextID, + ringIndex: ringIndex, + fenceID: hostFenceID, + contextFence: contextFence + ) + } catch let rejection as VirtioGPURendererCommandRejected { + fences.removeValue(forKey: callbackKey) + return .rejected(.renderer(operation: "create-fence", detail: rejection.detail)) + } catch { + fences.removeValue(forKey: callbackKey) + state = .uncertain(generation) + return .outcomeUnknown(uncertainty( + operation: "create-fence", + generation: generation, + error: error + )) + } + + if var registration = fences[callbackKey] { + switch registration.state { + case .registering(let signalObserved) where signalObserved: + fences.removeValue(forKey: callbackKey) + let sink = fenceSink + // NSRecursiveLock permits a synchronous renderer callback to record completion. + // Deliver only after registration itself has returned success. + sink?( + registration.generation, + registration.guestContextID, + registration.guestRingIndex, + registration.guestFenceID + ) + case .registering: + registration.state = .registered + fences[callbackKey] = registration + case .registered: + break + } + } + return .success(.none) + } + + private func receiveFence(contextID: UInt32, ringIndex: UInt32, hostFenceID: UInt64) { + let sinkAndRegistration: ( + ((UInt64, UInt32, UInt32, UInt64) -> Void), + FenceRegistration + )? = lock.withLock { + let key = FenceCallbackKey( + contextID: contextID, + ringIndex: ringIndex, + hostFenceID: hostFenceID + ) + guard var registration = fences[key] else { return nil } + switch registration.state { + case .registering: + registration.state = .registering(signalObserved: true) + fences[key] = registration + return nil + case .registered: + guard case .active(let activeGeneration) = state, + activeGeneration == registration.generation, + let fenceSink else { + fences.removeValue(forKey: key) + return nil + } + fences.removeValue(forKey: key) + return (fenceSink, registration) + } + } + guard let (sink, registration) = sinkAndRegistration else { return } + sink( + registration.generation, + registration.guestContextID, + registration.guestRingIndex, + registration.guestFenceID + ) + } + + private func receiveRuntimeFailure(_ failure: VirtioGPURendererRuntimeFailure) { + let sinkAndGeneration: (((UInt64, VirtioGPURendererRuntimeFailure) -> Void), UInt64)? = + lock.withLock { + guard case .active(let generation) = state, let runtimeFailureSink else { + return nil + } + return (runtimeFailureSink, generation) + } + guard let (sink, generation) = sinkAndGeneration else { return } + sink(generation, failure) + } + + private func invoke(_ command: VirtioGPURendererCommand) throws -> VirtioGPURendererCommandValue { + switch command { + case let .createContext(id, flags, name): + try renderer.createContext(id: id, flags: flags, name: name) + case .destroyContext(let id): + try renderer.destroyContext(id: id) + case let .attachResource(contextID, resourceID): + try renderer.attachResource(contextID: contextID, resourceID: resourceID) + case let .detachResource(contextID, resourceID): + try renderer.detachResource(contextID: contextID, resourceID: resourceID) + case let .submit3D(contextID, command): + try renderer.submit3D(contextID: contextID, command: command) + case let .createResource3D(resource, entries): + try renderer.createResource3D(resource, entries: entries) + case let .createBlob(resourceID, contextID, memory, flags, blobID, size, entries): + try renderer.createBlob( + resourceID: resourceID, + contextID: contextID, + blobMemory: memory, + blobFlags: flags, + blobID: blobID, + size: size, + entries: entries + ) + case let .attachBacking(resourceID, entries): + try renderer.attachBacking(resourceID: resourceID, entries: entries) + case .detachBacking(let resourceID): + try renderer.detachBacking(resourceID: resourceID) + case .unrefResource(let resourceID): + try renderer.unrefResource(resourceID: resourceID) + case .mapBlob(let resourceID): + return .blobMapping(try renderer.mapBlob(resourceID: resourceID)) + case .unmapBlob(let resourceID): + try renderer.unmapBlob(resourceID: resourceID) + case let .transferToHost3D(transfer, entries): + try renderer.transferToHost3D(transfer, entries: entries) + case let .transferFromHost3D(transfer, entries): + try renderer.transferFromHost3D(transfer, entries: entries) + case let .makeScanoutPresentation(resourceID, resourceGeneration): + return .scanoutPresentation(try renderer.makeScanoutPresentation( + resourceID: resourceID, + resourceGeneration: resourceGeneration + )) + case .createFence, .resetAfterDeviceQuiesce: + preconditionFailure("special renderer commands must use their lifecycle executor") + } + return .none + } + + private func boundedCopy( + of command: VirtioGPURendererCommand + ) -> VirtioGPURendererCommand? { + switch command { + case let .createContext(id, flags, name): + guard name.utf8.count <= 64 else { return nil } + return .createContext(id: id, flags: flags, name: String(name)) + case let .submit3D(contextID, bytes): + guard bytes.count <= maximumCommandBytes else { return nil } + return .submit3D(contextID: contextID, command: Array(bytes)) + case let .createResource3D(resource, entries): + guard let entries = boundedEntries(entries) else { return nil } + return .createResource3D(resource, entries: entries) + case let .createBlob(resourceID, contextID, memory, flags, blobID, size, entries): + guard size <= maximumReferencedBytes, + let entries = boundedEntries(entries) else { return nil } + return .createBlob( + resourceID: resourceID, + contextID: contextID, + blobMemory: memory, + blobFlags: flags, + blobID: blobID, + size: size, + entries: entries + ) + case let .attachBacking(resourceID, entries): + guard let entries = boundedEntries(entries) else { return nil } + return .attachBacking(resourceID: resourceID, entries: entries) + case let .transferToHost3D(transfer, entries): + guard transfer.box.count == 6, + let entries = boundedEntries(entries) else { return nil } + var copied = transfer + copied.box = Array(transfer.box) + return .transferToHost3D(copied, entries: entries) + case let .transferFromHost3D(transfer, entries): + guard transfer.box.count == 6, + let entries = boundedEntries(entries) else { return nil } + var copied = transfer + copied.box = Array(transfer.box) + return .transferFromHost3D(copied, entries: entries) + default: + return command + } + } + + private func boundedEntries( + _ entries: [VirtioGPUMemoryEntry] + ) -> [VirtioGPUMemoryEntry]? { + guard entries.count <= maximumMemoryEntries else { return nil } + var total: UInt64 = 0 + for entry in entries { + guard entry.length > 0 else { return nil } + let (next, overflow) = total.addingReportingOverflow(UInt64(entry.length)) + guard !overflow, next <= maximumReferencedBytes else { return nil } + total = next + } + return Array(entries) + } + + private func uncertainty( + operation: String, + generation: UInt64, + error: Error + ) -> VirtioGPURendererCommandUncertainty { + VirtioGPURendererCommandUncertainty( + operation: operation, + generation: generation, + detail: String(describing: error), + runtimeFailure: error as? VirtioGPURendererRuntimeFailure + ) + } +} + +/// The guest-physical window into which host-visible Venus blobs are mapped. Unlike a normal RAM +/// region this is NOT pre-backed: virglrenderer owns each blob's host memory (a Metal-backed, +/// page-aligned allocation), so on `resource_map` we hv_vm_map that renderer-owned pointer into the +/// window at the guest-requested offset — the same zero-copy model libkrun/krunkit use on macOS. +/// Pre-mapping the whole window would make per-blob hv_vm_map fail (the GPA is already mapped). +public final class VirtioGPUHostVisibleMemory: @unchecked Sendable { + public let guestBase: UInt64 + public let length: UInt64 + + private let lock = NSLock() + private var mappings: [UInt32: (offset: UInt64, size: UInt64)] = [:] + + public init(guestBase: UInt64, length: UInt64 = 256 * 1024 * 1024) throws { + guard length > 0, + guestBase.isMultiple(of: HostPage.size), + length.isMultiple(of: HostPage.size), + length <= UInt64(Int.max) else { + throw VMError.invalidConfiguration("invalid virtio-gpu host-visible memory window") + } + self.guestBase = guestBase + self.length = length + } + + deinit { + lock.lock() + for (_, mapping) in mappings { + _ = hv_vm_unmap(guestBase + mapping.offset, Int(mapping.size)) + } + lock.unlock() + } + + /// hv_vm_map the renderer-owned `hostPointer` into the window at `offset`. `hostPointer` stays + /// owned by virglrenderer and must never be munmap'd here — it is released via resource_unmap. + public func map(resourceID: UInt32, hostPointer: UnsafeMutableRawPointer, offset: UInt64, size: UInt64) throws { + let mapSize = size.roundedUpToMultiple(of: HostPage.size) + guard offset.isMultiple(of: HostPage.size), + mapSize > 0, offset <= length, mapSize <= length - offset else { + throw VMError.guestMemoryFault(address: guestBase + offset, count: size) + } + lock.lock() + defer { lock.unlock() } + if let previous = mappings.removeValue(forKey: resourceID) { + _ = hv_vm_unmap(guestBase + previous.offset, Int(previous.size)) + } + try hvCheck( + hv_vm_map(hostPointer, guestBase + offset, Int(mapSize), hv_memory_flags_t(HV_MEMORY_READ | HV_MEMORY_WRITE)), + "virtio-gpu host-visible blob hv_vm_map" + ) + mappings[resourceID] = (offset, mapSize) + } + + public func unmap(resourceID: UInt32) { + lock.lock() + defer { lock.unlock() } + if let mapping = mappings.removeValue(forKey: resourceID) { + _ = hv_vm_unmap(guestBase + mapping.offset, Int(mapping.size)) + } + } +} + +private extension UInt64 { + func roundedUpToMultiple(of alignment: UInt64) -> UInt64 { + guard alignment > 0 else { return self } + let remainder = self % alignment + return remainder == 0 ? self : self + (alignment - remainder) + } +} + +/// Virtio-gpu device with an explicitly gated renderer authority. +/// +/// A renderer authority is either the legacy in-process compatibility object or one already +/// authenticated signed-worker lane; two authorities fail closed. Worker capsets and device +/// features come only from its complete capability receipt, while all command and presentation +/// completion remains generation-bound to that lane. +public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvider, @unchecked Sendable { + public let deviceID: UInt32 = 16 + public let queueCount = 2 + public var deviceFeatures: UInt64 { + guard rendererAuthorityIsConfigured, + rendererCapabilitiesAreAdvertised else { return 0 } + return configuredRendererDeviceFeatures + } + public let sharedMemoryRegions: [VirtioSharedMemoryRegion] + + private let scanoutCount: UInt32 + private let displayLock = NSLock() + private var scanoutSizes: [VirtioGPUScanoutSize] + private var pendingDisplayEvents: UInt32 = 0 + private let onScanoutFrame: (@Sendable (VirtioGPUScanoutFrame) -> Void)? + private let onScanoutTexture: (@Sendable (VirtioGPUScanoutTextureUpdate) -> Void)? + private let onMetalScanout: (@Sendable (VirtioGPUMetalScanoutUpdate) -> Void)? + private let onScanoutResourceReleased: (@Sendable (VirtioGPUScanoutResourceRelease) -> Void)? + private let onScanoutDisabled: (@Sendable (UInt32) -> Void)? + private let onCursorUpdate: (@Sendable (VirtioGPUCursorUpdate?) -> Void)? + private let onRendererWorkerFailure: (@Sendable (String) -> Void)? + private let rendererExecutor: VirtioGPURendererCommandExecutor? + private let rendererWorkerCandidate: DoryRendererWorkerVirtioCommandLane? + private let configuredRendererDeviceFeatures: UInt64 + private let capsets: [VirtioGPUCapset] + private let hostVisibleMemory: VirtioGPUHostVisibleMemory? + private var resourceEntries: [UInt32: [VirtioGPUMemoryEntry]] = [:] + private var blobResources: [UInt32: BlobResource] = [:] + /// UUIDs back VIRTIO_GPU_F_RESOURCE_UUID, which Linux exposes as the cross-device DRM + /// capability required by Mesa Venus before it will create a Vulkan instance. Keep an assigned + /// UUID stable for the lifetime of each renderer resource; the value is an opaque identity to + /// the guest and does not imply a host dma-buf export on macOS. + private var resourceUUIDs: [UInt32: [UInt8]] = [:] + + private struct Resource2D { + var format: UInt32 + var width: UInt32 + var height: UInt32 + var backing: [VirtioGPUMemoryEntry] = [] + } + + private struct Resource3D { + var format: UInt32 + var width: UInt32 + var height: UInt32 + } + + private struct CursorResourceSnapshot { + var width: UInt32 + var height: UInt32 + var bytes: Data + } + + private struct ScanoutBinding { + enum Source { + case resource2D + case resource3D + case blob(format: UInt32, width: UInt32, height: UInt32, stride: UInt32, offset: UInt32) + } + + var resourceID: UInt32 + var rect: VirtioGPURect + var source: Source + } + + private var resources2D: [UInt32: Resource2D] = [:] + private var resources3D: [UInt32: Resource3D] = [:] + private var scanouts: [UInt32: ScanoutBinding] = [:] + private var cursorResourceID: UInt32? + private var commandFailureCounts: [UInt32: Int] = [:] + private let traceResourceLifecycle: Bool + private var resourceTraceSequence: UInt64 = 0 + /// The cursor virtqueue reads resources created and retired on the control virtqueue. VCPU + /// kicks may arrive concurrently, so serialize command interpretation while leaving descriptor + /// dequeue/completion and renderer fence delivery on their existing independent locks. + private let commandLock = NSLock() + private struct RendererWorkerSnapshotMetrics { + var count: UInt64 = 0 + var bytes: UInt64 = 0 + var nanoseconds: UInt64 = 0 + var maximumNanoseconds: UInt64 = 0 + } + private let rendererWorkerMetricsLock = NSLock() + private var rendererWorkerSnapshotMetrics = RendererWorkerSnapshotMetrics() + private let rendererWorkerScanoutDiagnosticLock = NSLock() + private var rendererWorkerScanoutDiagnosticStages = Set() + private let softwareScanoutMetricsLock = NSLock() + private var softwareScanoutCopiedBytes: UInt64 = 0 + private let rendererWorkerPresentationLock = NSLock() + private var rendererWorkerPendingScanouts = [ + DoryRendererScanoutReleaseToken: DoryRendererWorkerSharedScanoutCore + ]() + private var rendererWorkerLiveScanouts = [ + DoryRendererScanoutReleaseToken: DoryRendererWorkerWeakScanoutCore + ]() + private let rendererWorkerPresentationQueue = DispatchQueue( + label: "dev.dory.gpu.renderer-worker-presentation", + qos: .userInteractive + ) + private let rendererWorkerResumeQueue = DispatchQueue( + label: "dev.dory.gpu.renderer-worker-queue-resume", + qos: .userInteractive + ) + + // Real fence signalling: a fenced command's descriptor is held here and completed only when the + // renderer signals the fence (from its own thread), per the virtio-gpu contract — responding + // immediately would tell the guest its GPU work finished before it did. + private struct FenceKey: Hashable { + var contextID: UInt32 + var ringIndex: UInt32 + } + + private struct PendingFence { + var token: UInt64 + var fenceID: UInt64 + var epoch: UInt64 + var response: [UInt8] + var chain: VirtqueueChain + var createdAtMonotonicNanoseconds: UInt64 + var timeoutReported: Bool + } + + private struct FenceRequest { + var key: FenceKey + var contextID: UInt32 + var ringIndex: UInt32 + var fenceID: UInt64 + var contextFence: Bool + } + + private enum FenceAdmission { + case notRequested + case admitted(FenceRequest) + case rejected + } + + private enum FenceDeferralOutcome { + case immediate + case deferred + /// The renderer accepted the command but could not establish its completion boundary. + /// Keep the descriptor owned until reset rather than publishing a false completion. + case outcomeUnknown + } + + private struct RendererCommandOutcomeUnknownSignal: Error { + var uncertainty: VirtioGPURendererCommandUncertainty + } + + private enum CommandProcessingOutcome { + case response([UInt8]) + /// The renderer may have committed the command. The descriptor remains device-owned until + /// queue revocation/reset rather than receiving a fabricated success or rejection. + case outcomeUnknown(VirtioGPURendererCommandUncertainty) + } + + private enum QueueAdmissionRejection { + case invalidDescriptorLayout + case oversizedRequest + case insufficientResponseCapacity + } + + private enum QueueAdmissionOutcome { + case admitted(request: [UInt8], writesResponse: Bool) + case workerControl(WorkerControlAdmission) + case workerCreateResource3D(WorkerCreateResource3DAdmission) + case workerCreateBlob(WorkerCreateBlobAdmission) + case workerAttachBacking(WorkerAttachBackingAdmission) + case workerDetachBacking(WorkerDetachBackingAdmission) + case workerTransfer(WorkerTransferAdmission) + case workerUnref(WorkerUnrefAdmission) + case workerMapBlob(WorkerMapBlobAdmission) + case workerUnmapBlob(WorkerUnmapBlobAdmission) + case workerFlushScanout(WorkerFlushScanoutAdmission) + case workerSubmit(WorkerSubmitAdmission) + case workerRejected(requestHeader: [UInt8]) + case rejected(QueueAdmissionRejection) + case revoked + } + + private struct WorkerSubmitAdmission: @unchecked Sendable { + let requestHeader: [UInt8] + let regions: DoryRendererWorkerSharedRegionSet + let fence: FenceRequest? + } + + private struct WorkerAttachBackingAdmission: @unchecked Sendable { + let request: [UInt8] + let resourceID: UInt32 + let resourceGeneration: UInt64 + let entries: [VirtioGPUMemoryEntry] + let regions: DoryRendererWorkerSharedRegionSet + let fence: FenceRequest? + } + + private struct WorkerDetachBackingAdmission: Sendable { + let request: [UInt8] + let resourceID: UInt32 + let resourceGeneration: UInt64 + let fence: FenceRequest? + } + + private enum WorkerTransferDirection: Sendable { + case toHost + case fromHost + } + + private struct WorkerTransferAdmission: Sendable { + let request: [UInt8] + let resourceID: UInt32 + let resourceGeneration: UInt64 + let contextID: UInt32 + let payload: DoryRendererTransfer3DPayload + let direction: WorkerTransferDirection + let fence: FenceRequest? + } + + private struct WorkerUnrefAdmission: Sendable { + let request: [UInt8] + let resourceID: UInt32 + let workerResourceGeneration: UInt64 + let displayResourceGeneration: UInt64 + let fence: FenceRequest? + } + + private struct WorkerCreateBlobAdmission: @unchecked Sendable { + let request: [UInt8] + let resourceID: UInt32 + let contextID: UInt32 + let payload: DoryRendererBlobCreatePayload + let entries: [VirtioGPUMemoryEntry] + let regions: DoryRendererWorkerSharedRegionSet + } + + private enum WorkerCreatedResourceKind: Equatable, Sendable { + case resource2D + case resource3D + } + + private struct WorkerCreateResource3DAdmission: Sendable { + let request: [UInt8] + let resourceID: UInt32 + let payload: DoryRendererResource3DCreatePayload + let kind: WorkerCreatedResourceKind + let fence: FenceRequest? + } + + private struct WorkerMapBlobAdmission: Sendable { + let request: [UInt8] + let resourceID: UInt32 + let resourceGeneration: UInt64 + let hostVisibleOffset: UInt64 + } + + private struct WorkerUnmapBlobAdmission: Sendable { + let request: [UInt8] + let resourceID: UInt32 + let resourceGeneration: UInt64 + } + + private struct WorkerFlushTarget: Sendable { + let scanoutID: UInt32 + let sourceRect: VirtioGPURect + let dirtyRect: VirtioGPURect + } + + private struct WorkerScanoutSurface: Equatable, Sendable { + let width: UInt32 + let height: UInt32 + let format: UInt32 + let stride: UInt32 + let offset: UInt32 + } + + private struct WorkerFlushScanoutAdmission: Sendable { + let request: [UInt8] + let resourceID: UInt32 + let workerResourceGeneration: UInt64 + let displayResourceGeneration: UInt64 + let surface: WorkerScanoutSurface? + let targets: [WorkerFlushTarget] + let fence: FenceRequest? + } + + private enum WorkerControlOperation: Sendable { + case createContext(name: String, capsetID: UInt32) + case destroyContext + case attachResource(resourceID: UInt32) + case detachResource(resourceID: UInt32) + } + + private struct WorkerControlAdmission: Sendable { + let request: [UInt8] + let contextID: UInt32 + let operation: WorkerControlOperation + } + + private enum QueueCompletionOutcome { + case published(wantsInterrupt: Bool) + case revoked + case failed + } + + private enum QueueDrainOutcome { + case drained(wantsInterrupt: Bool) + case pendingReadFailed + case popFailed(wantsInterrupt: Bool) + case completionFailed(wantsInterrupt: Bool) + } + + private enum TelemetryEvent { + case queuePendingReadFailure + case queuePopFailure + case invalidDescriptorChain + case oversizedRequest + case insufficientResponseCapacity + case queuePushFailure + case revokedCompletion + case undeliveredFenceCompletion + case responseWriteFailure + case fenceAdmissionRejection + case queueRevokedFence + case resetRevokedFence + case rendererCommandUncertainty + case revokedUncertainRendererCommand + } + + private let fenceLock = NSLock() + private var pendingFences: [FenceKey: [PendingFence]] = [:] + /// Fence creation failed after the renderer command crossed its mutation boundary. These + /// chains remain owned until queue revocation/reset, but are kept out of callback lookup so a + /// late signal from an older fence can never fabricate their completion. + private var uncertainFences = [PendingFence]() + private var uncertainRendererCommandChains = [VirtqueueChain]() + private weak var lastTransport: VirtioMMIOTransport? + /// Never reset to zero. Fence callbacks may arrive after MMIO reset; the epoch is rechecked + /// while holding the transport queue lock so a callback already in flight cannot publish into + /// the replacement queue. + private var lifecycleEpoch: UInt64 = 1 + private var nextFenceToken: UInt64 = 1 + private var pendingFenceCount = 0 + private var pendingFenceResponseBytes = 0 + /// QueueReady can revoke a descriptor while its renderer callback remains in flight. Because + /// virglrenderer callbacks do not carry a queue generation, do not admit another renderer + /// fence after such a revocation until the renderer's full reset barrier has completed. + private var fenceAdmissionBlockedUntilDeviceReset = false + private let fenceTimeoutNanoseconds: UInt64 + private var fenceCount: UInt64 = 0 + private var fenceRegistrationFailureCount: UInt64 = 0 + private var fenceTimeoutCount: UInt64 = 0 + private var rendererDeviceLossCount: UInt64 = 0 + private var rendererDeviceLossLatched = false + private var queuePendingReadFailureCount: UInt64 = 0 + private var queuePopFailureCount: UInt64 = 0 + private var invalidDescriptorChainCount: UInt64 = 0 + private var oversizedRequestCount: UInt64 = 0 + private var insufficientResponseCapacityCount: UInt64 = 0 + private var queuePushFailureCount: UInt64 = 0 + private var revokedCompletionCount: UInt64 = 0 + private var undeliveredFenceCompletionCount: UInt64 = 0 + private var responseWriteFailureCount: UInt64 = 0 + private var fenceAdmissionRejectionCount: UInt64 = 0 + private var queueRevokedFenceCount: UInt64 = 0 + private var resetRevokedFenceCount: UInt64 = 0 + private var rendererCommandUncertaintyCount: UInt64 = 0 + private var revokedUncertainRendererCommandCount: UInt64 = 0 + private var resourceGenerations: [UInt32: UInt64] = [:] + /// Exact worker generations returned by authenticated create replies. These are never derived + /// from the independent display/resource lifetime generation above. + private var rendererWorkerResourceGenerations: [UInt32: UInt64] = [:] + /// Context/resource attachment ownership is committed only by authenticated worker replies. + /// RESOURCE_FLUSH derives its context-timeline fence from this single authority and fails closed + /// when a resource is attached to zero or multiple contexts. + private var rendererWorkerResourceContextIDs: [UInt32: Set] = [:] + /// Reserves IDs while create mutations are asynchronous so another vCPU kick cannot race a + /// local 2D/blob allocation into the same guest resource identity. + private var rendererWorkerPendingResourceIDs = Set() + private var rendererWorkerPendingBackingResourceIDs = Set() + private var rendererWorkerPendingMappingResourceIDs = Set() + /// Unique ownership of the one worker command that currently holds controlq ordering. Device + /// generation alone is not an identity: an older pipelined submit can complete while a newer + /// state mutation is pending in the same generation. Only the exact claim may release the + /// barrier after its used entry has been published. + private struct RendererWorkerControlCommandClaim: Equatable, Sendable { + let generation: UInt64 + let token: UInt64 + } + private var rendererWorkerControlCommandClaim: RendererWorkerControlCommandClaim? + private var nextRendererWorkerControlCommandToken: UInt64 = 1 + private var nextResourceGeneration: UInt64 = 1 + private let maximumTrackedResources: Int + private let maximumControlRequestBytes: Int + /// Raw guest scatter/gather descriptors admitted from the virtqueue. Losslessly adjacent + /// entries are normalized, but ordinary Linux GEM/shmem pages can remain physically + /// discontiguous. The resulting list is bounded by the authenticated worker contract and by + /// independent request-byte and referenced-byte ceilings. + private let maximumRawMemoryEntries: Int + private let maximumMemoryEntries: Int + private let maximumRendererReferencedBytes: UInt64 + private let maximumPendingFences: Int + private let maximumPendingFenceResponseBytes: Int + private let maximumCopiedScanoutSurfaceBytes: UInt64 + private let quiescenceTimeout: TimeInterval + + private struct ResourceRetirementKey: Hashable, Sendable { + var resourceID: UInt32 + var generation: UInt64 + } + + private struct QuiescingResource: Sendable { + var key: ResourceRetirementKey + var requiresBlobUnmap: Bool + } + + private struct ActiveQuiescence { + var receipt: VirtioGPUQuiescence + var rendererGeneration: UInt64 + var workerReboundForPristineDeviceReset: Bool + var rendererResources: [QuiescingResource] + var awaitingReleaseAcknowledgements: Set + var priorRetirements: Set + var cleanupScheduled: Bool + } + + /// Resource IDs remain reserved after guest-visible unref/reset until every display has + /// detached its generation and host destruction has completed. The same bound applies with no + /// renderer, preventing an untrusted guest from growing mailbox release storage without limit. + private let lifecycleLock = NSLock() + private let rendererRetirementQueue = DispatchQueue(label: "dev.dory.gpu.resource-retirement") + private var retiringResources: [UInt32: UInt64] = [:] + private var activeQuiescence: ActiveQuiescence? + private var rendererLifecycleHealthState: VirtioGPURendererLifecycleHealth + private var acceptingGuestCommands = true + private var createdContextIDs = Set() + + private enum HeaderFlag { + static let fence: UInt32 = 1 << 0 + static let infoRingIndex: UInt32 = 1 << 1 + } + + private enum Command { + static let getDisplayInfo: UInt32 = 0x0100 + static let resourceCreate2D: UInt32 = 0x0101 + static let resourceUnref: UInt32 = 0x0102 + static let setScanout: UInt32 = 0x0103 + static let resourceFlush: UInt32 = 0x0104 + static let transferToHost2D: UInt32 = 0x0105 + static let resourceAttachBacking: UInt32 = 0x0106 + static let resourceDetachBacking: UInt32 = 0x0107 + static let getCapsetInfo: UInt32 = 0x0108 + static let getCapset: UInt32 = 0x0109 + static let resourceAssignUUID: UInt32 = 0x010B + static let resourceCreateBlob: UInt32 = 0x010C + static let setScanoutBlob: UInt32 = 0x010D + static let ctxCreate: UInt32 = 0x0200 + static let ctxDestroy: UInt32 = 0x0201 + static let ctxAttachResource: UInt32 = 0x0202 + static let ctxDetachResource: UInt32 = 0x0203 + static let resourceCreate3D: UInt32 = 0x0204 + static let transferToHost3D: UInt32 = 0x0205 + static let transferFromHost3D: UInt32 = 0x0206 + static let submit3D: UInt32 = 0x0207 + static let resourceMapBlob: UInt32 = 0x0208 + static let resourceUnmapBlob: UInt32 = 0x0209 + static let updateCursor: UInt32 = 0x0300 + static let moveCursor: UInt32 = 0x0301 + } + + private enum Response { + static let okNoData: UInt32 = 0x1100 + static let okDisplayInfo: UInt32 = 0x1101 + static let okCapsetInfo: UInt32 = 0x1102 + static let okCapset: UInt32 = 0x1103 + static let okResourceUUID: UInt32 = 0x1105 + static let okMapInfo: UInt32 = 0x1106 + static let errorUnspecified: UInt32 = 0x1200 + static let errorInvalidParameter: UInt32 = 0x1205 + } + + private enum Feature { + static let virgl: UInt64 = 1 << 0 + static let resourceUUID: UInt64 = 1 << 2 + static let resourceBlob: UInt64 = 1 << 3 + static let contextInit: UInt64 = 1 << 4 + } + + private enum Capset { + static let venus: UInt32 = 4 + } + + private struct BlobResource { + var memory: UInt32 + var size: UInt64 + var mapping: VirtioGPUBlobMapping? + var workerMapping: DoryRendererWorkerBlobMappingAuthority? + var guestMapped = false + } + + /// - Parameters: + /// - hostMemoryBase: Guest physical base of the virtio-gpu host-visible memory window. + /// - hostMemorySize: Size of the host-visible memory window reported through virtio-mmio. + public init( + hostMemoryBase: UInt64, + hostMemorySize: UInt64 = 256 * 1024 * 1024, + scanoutCount: UInt32 = 0, + scanoutWidth: UInt32 = 1_280, + scanoutHeight: UInt32 = 800, + scanoutSizes: [VirtioGPUScanoutSize]? = nil, + renderer: VirtioGPURenderer? = nil, + rendererWorkerCandidate: DoryRendererWorkerVirtioCommandLane? = nil, + hostVisibleMemory: VirtioGPUHostVisibleMemory? = nil, + traceResourceLifecycle: Bool = false, + fenceTimeoutNanoseconds: UInt64 = 10_000_000_000, + maximumTrackedResources: Int = 4_096, + maximumControlRequestBytes: Int = 16 * 1_024 * 1_024, + maximumMemoryEntries: Int = DoryRendererWorkerLimits.production.maximumSharedRegions, + maximumRendererReferencedBytes: UInt64 = 8 * 1_024 * 1_024 * 1_024, + maximumPendingFences: Int = 4_096, + maximumPendingFenceResponseBytes: Int = 8 * 1_024 * 1_024, + maximumCopiedScanoutSurfaceBytes: UInt64 = 128 * 1_024 * 1_024, + quiescenceTimeout: TimeInterval = 5, + onScanoutFrame: (@Sendable (VirtioGPUScanoutFrame) -> Void)? = nil, + onScanoutTexture: (@Sendable (VirtioGPUScanoutTextureUpdate) -> Void)? = nil, + onMetalScanout: (@Sendable (VirtioGPUMetalScanoutUpdate) -> Void)? = nil, + onScanoutResourceReleased: (@Sendable (VirtioGPUScanoutResourceRelease) -> Void)? = nil, + onScanoutDisabled: (@Sendable (UInt32) -> Void)? = nil, + onCursorUpdate: (@Sendable (VirtioGPUCursorUpdate?) -> Void)? = nil, + onRendererWorkerFailure: (@Sendable (String) -> Void)? = nil + ) { + let boundedScanoutSizes: [VirtioGPUScanoutSize] + if let scanoutSizes { + boundedScanoutSizes = Array(scanoutSizes.prefix(16)) + } else { + boundedScanoutSizes = Array( + repeating: VirtioGPUScanoutSize( + width: scanoutWidth, + height: scanoutHeight + ), + count: Int(min(scanoutCount, 16)) + ) + } + let boundedScanoutCount = UInt32(boundedScanoutSizes.count) + let boundedControlRequestBytes = min( + 64 * 1_024 * 1_024, + max(96, maximumControlRequestBytes) + ) + // A configuration carrying two independent renderer authorities is never partially + // selected. It stays inert instead of guessing which process owns resource/fence state. + let hasRendererAuthorityConflict = renderer != nil && rendererWorkerCandidate != nil + let selectedWorkerCandidate = hasRendererAuthorityConflict + ? nil + : rendererWorkerCandidate + let rendererRegionLimit = selectedWorkerCandidate?.maximumSharedRegions + ?? DoryRendererWorkerLimits.production.maximumSharedRegions + let boundedMemoryEntries = max(1, min(maximumMemoryEntries, rendererRegionLimit)) + let boundedRawMemoryEntries = max( + 1, + min( + DoryRendererWorkerLimits.absoluteMaximumSharedRegions, + (boundedControlRequestBytes - 32) / 16 + ) + ) + let rendererReferencedByteLimit = selectedWorkerCandidate?.maximumReferencedBytes + ?? DoryRendererWorkerLimits.absoluteMaximumReferencedBytes + let boundedRendererReferencedBytes = max( + 1, + min(maximumRendererReferencedBytes, rendererReferencedByteLimit) + ) + let rendererExecutor = hasRendererAuthorityConflict ? nil : renderer.map { + VirtioGPURendererCommandExecutor( + renderer: $0, + maximumCommandBytes: boundedControlRequestBytes, + maximumMemoryEntries: boundedMemoryEntries, + maximumReferencedBytes: boundedRendererReferencedBytes + ) + } + self.traceResourceLifecycle = traceResourceLifecycle + self.fenceTimeoutNanoseconds = fenceTimeoutNanoseconds + self.maximumTrackedResources = max(1, maximumTrackedResources) + // The split-ring parser has a separate 64 MiB absolute chain ceiling. GPU commands are + // intentionally tighter so one untrusted submit cannot force an allocation at that limit. + // Ninety-six bytes keeps every fixed-size core command representable; variable command and + // backing payloads remain available up to the explicit host policy bound. + self.maximumControlRequestBytes = boundedControlRequestBytes + self.maximumRawMemoryEntries = boundedRawMemoryEntries + self.maximumMemoryEntries = boundedMemoryEntries + self.maximumRendererReferencedBytes = boundedRendererReferencedBytes + self.maximumPendingFences = max(1, maximumPendingFences) + self.maximumPendingFenceResponseBytes = max(24, maximumPendingFenceResponseBytes) + self.maximumCopiedScanoutSurfaceBytes = max(4, maximumCopiedScanoutSurfaceBytes) + self.quiescenceTimeout = max(0.1, quiescenceTimeout) + self.rendererExecutor = rendererExecutor + self.rendererWorkerCandidate = selectedWorkerCandidate + self.capsets = rendererExecutor?.capsets ?? selectedWorkerCandidate?.capsets ?? [] + let hasRendererAuthority = rendererExecutor != nil || selectedWorkerCandidate != nil + self.rendererLifecycleHealthState = hasRendererAuthority + ? .ready(epoch: 1) + : .notConfigured + self.configuredRendererDeviceFeatures = hasRendererAuthority + ? Feature.virgl | Feature.resourceUUID | Feature.resourceBlob | Feature.contextInit + : 0 + self.sharedMemoryRegions = [ + VirtioSharedMemoryRegion(id: 1, guestBase: hostMemoryBase, length: hostVisibleMemory?.length ?? hostMemorySize) + ] + self.scanoutCount = boundedScanoutCount + self.scanoutSizes = boundedScanoutSizes + self.hostVisibleMemory = hostVisibleMemory + self.onScanoutFrame = onScanoutFrame + self.onScanoutTexture = onScanoutTexture + self.onMetalScanout = onMetalScanout + self.onScanoutResourceReleased = onScanoutResourceReleased + self.onScanoutDisabled = onScanoutDisabled + self.onCursorUpdate = onCursorUpdate + self.onRendererWorkerFailure = onRendererWorkerFailure + rendererExecutor?.installCallbacks( + fence: { [weak self] generation, contextID, ringIndex, fenceID in + self?.fenceSignaled( + generation: generation, + contextID: contextID, + ringIndex: ringIndex, + fenceID: fenceID + ) + }, + runtimeFailure: { [weak self] generation, failure in + self?.recordRendererFailure(failure, generation: generation) + } + ) + selectedWorkerCandidate?.installCallbacks( + fence: { [weak self] generation, contextID, ringIndex, fenceID in + self?.fenceSignaled( + generation: generation, + contextID: contextID, + ringIndex: ringIndex, + fenceID: fenceID + ) + }, + runtimeFailure: { [weak self] generation, error in + self?.rendererWorkerCandidateFailed( + generation: generation, + error: error + ) + } + ) + if hasRendererAuthorityConflict { + if let state = rendererWorkerCandidate?.snapshot().state { + let generation: UInt64 = switch state { + case .active(let value), .revoked(let value), .failed(let value): value + } + rendererWorkerCandidate?.revoke(deviceGeneration: generation) + } + FileHandle.standardError.write(Data( + "dory-gpu: renderer authority conflict; acceleration remains disabled\n".utf8 + )) + } + } + + public var configSpace: [UInt8] { + displayLock.lock() + let events = pendingDisplayEvents + let count = scanoutCount + displayLock.unlock() + var config = [UInt8]() + config.appendLE(events) // events_read + config.appendLE(UInt32(0)) // events_clear + config.appendLE(count) // num_scanouts + config.appendLE(rendererCapabilitiesAreAdvertised ? UInt32(capsets.count) : 0) // num_capsets + return config + } + + /// Publishes a new preferred scanout size and raises VIRTIO_GPU_EVENT_DISPLAY. The Linux DRM + /// driver responds by re-reading GET_DISPLAY_INFO and issuing a real modeset, so the guest + /// compositor renders at the Retina window's pixel dimensions instead of scaling one fixed + /// framebuffer on the host. + public func updateScanoutSize( + scanoutID: UInt32, + width: UInt32, + height: UInt32, + transport: VirtioMMIOTransport + ) { + let updated = VirtioGPUScanoutSize(width: width, height: height) + displayLock.lock() + let index = Int(scanoutID) + let changed = scanoutSizes.indices.contains(index) && scanoutSizes[index] != updated + if changed { + scanoutSizes[index] = updated + pendingDisplayEvents |= 1 // VIRTIO_GPU_EVENT_DISPLAY + } + displayLock.unlock() + if changed { transport.notifyConfigChange() } + } + + /// Source-compatible primary-scanout resize bridge. + public func updateScanoutSize( + width: UInt32, + height: UInt32, + transport: VirtioMMIOTransport + ) { + updateScanoutSize( + scanoutID: 0, + width: width, + height: height, + transport: transport + ) + } + + public func writeConfig(offset: UInt64, value: UInt64, width: Int) { + guard width > 0, width <= 8, offset < 8, offset + UInt64(width) > 4 else { return } + var cleared: UInt32 = 0 + for byte in 0..> UInt64(byte * 8)) & 0xFF) << UInt32((position - 4) * 8) + } + displayLock.lock() + pendingDisplayEvents &= ~cleared + displayLock.unlock() + } + + /// Clears one complete guest GPU epoch. Display disable is published before generation + /// releases; renderer teardown begins only after every release acknowledgement. Callers that + /// own process shutdown should retain and wait on the returned receipt before destroying the + /// renderer or guest memory. + @discardableResult + public func quiesce(reason: VirtioGPUQuiescenceReason) -> VirtioGPUQuiescence { + commandLock.lock() + defer { commandLock.unlock() } + + if let existing = lifecycleLock.withLock({ activeQuiescence?.receipt }) { + return existing + } + + let localWorkerStateIsPristine = reason == .deviceReset + && resources2D.isEmpty + && resources3D.isEmpty + && blobResources.isEmpty + && resourceEntries.isEmpty + && resourceUUIDs.isEmpty + && resourceGenerations.isEmpty + && rendererWorkerResourceGenerations.isEmpty + && rendererWorkerResourceContextIDs.isEmpty + && rendererWorkerPendingResourceIDs.isEmpty + && rendererWorkerPendingBackingResourceIDs.isEmpty + && rendererWorkerPendingMappingResourceIDs.isEmpty + && rendererWorkerControlCommandClaim == nil + && scanouts.isEmpty + && cursorResourceID == nil + && createdContextIDs.isEmpty + && rendererWorkerPresentationLock.withLock { + rendererWorkerPendingScanouts.isEmpty && rendererWorkerLiveScanouts.isEmpty + } + let (epoch, revokedWorkerGeneration, fenceStateIsPristine) = fenceLock.withLock { + () -> (UInt64, UInt64, Bool) in + let revokedWorkerGeneration = lifecycleEpoch + let fenceStateIsPristine = pendingFenceCount == 0 + && uncertainFences.isEmpty + && uncertainRendererCommandChains.isEmpty + && lastTransport == nil + && !fenceAdmissionBlockedUntilDeviceReset + lifecycleEpoch &+= 1 + if lifecycleEpoch == 0 { lifecycleEpoch = 1 } + recordTelemetryWhileLocked( + .resetRevokedFence, + count: UInt64(pendingFenceCount) + ) + recordTelemetryWhileLocked( + .revokedUncertainRendererCommand, + count: UInt64(uncertainRendererCommandChains.count) + ) + pendingFences.removeAll() + uncertainFences.removeAll() + uncertainRendererCommandChains.removeAll() + pendingFenceCount = 0 + pendingFenceResponseBytes = 0 + lastTransport = nil + fenceAdmissionBlockedUntilDeviceReset = + rendererExecutor != nil || rendererWorkerCandidate != nil + return (lifecycleEpoch, revokedWorkerGeneration, fenceStateIsPristine) + } + let workerReboundForPristineDeviceReset = localWorkerStateIsPristine + && fenceStateIsPristine + && rendererWorkerCandidate?.rebindPristineDeviceGeneration( + from: revokedWorkerGeneration, + to: epoch + ) == true + if !workerReboundForPristineDeviceReset { + rendererWorkerCandidate?.revoke(deviceGeneration: revokedWorkerGeneration) + revokeRendererWorkerScanouts() + } + rendererWorkerControlCommandClaim = nil + + let rendererGeneration: UInt64 + let executorAdmissionFault: VirtioGPURendererHealthFault? + if let rendererExecutor { + switch rendererExecutor.beginQuiescence(successorGeneration: epoch) { + case .admitted(let sourceGeneration): + rendererGeneration = sourceGeneration + executorAdmissionFault = nil + case .rejected(let rejection): + rendererGeneration = epoch + executorAdmissionFault = .resetFailed( + "renderer executor rejected quiescence: \(rejection)" + ) + } + } else { + rendererGeneration = epoch + executorAdmissionFault = nil + } + + let resources = (resources2D.keys.map { resourceID in + QuiescingResource( + key: ResourceRetirementKey( + resourceID: resourceID, + generation: resourceGenerations[resourceID] ?? 0 + ), + requiresBlobUnmap: false + ) + } + resources3D.keys.map { resourceID in + QuiescingResource( + key: ResourceRetirementKey( + resourceID: resourceID, + generation: resourceGenerations[resourceID] ?? 0 + ), + requiresBlobUnmap: false + ) + } + blobResources.map { resourceID, blob in + QuiescingResource( + key: ResourceRetirementKey( + resourceID: resourceID, + generation: resourceGenerations[resourceID] ?? 0 + ), + requiresBlobUnmap: blob.mapping?.requiresRendererUnmap == true + || blob.workerMapping != nil + ) + }).sorted { + if $0.key.resourceID != $1.key.resourceID { + return $0.key.resourceID < $1.key.resourceID + } + return $0.key.generation < $1.key.generation + } + let receipt = VirtioGPUQuiescence(epoch: epoch, reason: reason) + + let existingFault: VirtioGPURendererHealthFault? = lifecycleLock.withLock { + acceptingGuestCommands = false + if let executorAdmissionFault { + rendererLifecycleHealthState = .failed( + epoch: epoch, + fault: executorAdmissionFault + ) + return executorAdmissionFault + } + if case .failed(_, let fault) = rendererLifecycleHealthState { + for resource in resources { + retiringResources[resource.key.resourceID] = resource.key.generation + } + return fault + } + let prior = Set(retiringResources.map { + ResourceRetirementKey(resourceID: $0.key, generation: $0.value) + }) + for resource in resources { + retiringResources[resource.key.resourceID] = resource.key.generation + } + activeQuiescence = ActiveQuiescence( + receipt: receipt, + rendererGeneration: rendererGeneration, + workerReboundForPristineDeviceReset: workerReboundForPristineDeviceReset, + rendererResources: resources, + awaitingReleaseAcknowledgements: Set(resources.map(\.key)), + priorRetirements: prior, + cleanupScheduled: false + ) + rendererLifecycleHealthState = rendererAuthorityIsConfigured + ? .quiescing(epoch: epoch) + : .notConfigured + return nil + } + + let blobResourceIDs = blobResources.keys.sorted() + for resourceID in blobResourceIDs { hostVisibleMemory?.unmap(resourceID: resourceID) } + resources2D.removeAll() + resources3D.removeAll() + blobResources.removeAll() + resourceEntries.removeAll() + resourceUUIDs.removeAll() + resourceGenerations.removeAll() + rendererWorkerResourceGenerations.removeAll() + rendererWorkerResourceContextIDs.removeAll() + rendererWorkerPendingResourceIDs.removeAll() + rendererWorkerPendingBackingResourceIDs.removeAll() + rendererWorkerPendingMappingResourceIDs.removeAll() + rendererWorkerScanoutDiagnosticLock.withLock { + rendererWorkerScanoutDiagnosticStages.removeAll() + } + scanouts.removeAll() + cursorResourceID = nil + createdContextIDs.removeAll() + commandFailureCounts.removeAll() + displayLock.withLock { pendingDisplayEvents = 0 } + + // A disable causes each mailbox to retire a queued direct presentation before the release + // objects below can acknowledge renderer destruction. + for scanoutID in 0.. 0 + || (workerSnapshot?.armedFences ?? 0) > 0 + ) + let revokedFenceGeneration = fenceLock.withLock { () -> UInt64? in + guard workerRequiresRevocation + || pendingFenceCount > 0 + || !uncertainRendererCommandChains.isEmpty else { + return nil + } + let revokedGeneration = lifecycleEpoch + recordTelemetryWhileLocked( + .queueRevokedFence, + count: UInt64(pendingFenceCount) + ) + pendingFences.removeAll() + uncertainFences.removeAll() + recordTelemetryWhileLocked( + .revokedUncertainRendererCommand, + count: UInt64(uncertainRendererCommandChains.count) + ) + uncertainRendererCommandChains.removeAll() + pendingFenceCount = 0 + pendingFenceResponseBytes = 0 + lastTransport = nil + lifecycleEpoch &+= 1 + if lifecycleEpoch == 0 { lifecycleEpoch = 1 } + fenceAdmissionBlockedUntilDeviceReset = true + return revokedGeneration + } + if let revokedFenceGeneration { + if rendererWorkerControlCommandClaim?.generation == revokedFenceGeneration { + rendererWorkerControlCommandClaim = nil + } + rendererExecutor?.revokeActiveGeneration() + rendererWorkerCandidate?.revoke(deviceGeneration: revokedFenceGeneration) + revokeRendererWorkerScanouts() + } + } + + public func handleKick(queue: Int, transport: VirtioMMIOTransport) { + guard queue == 0 || queue == 1 else { return } + let outcome = drainQueue(queue: queue, transport: transport) + switch outcome { + case .drained(let wantsInterrupt), + .popFailed(let wantsInterrupt), + .completionFailed(let wantsInterrupt): + if wantsInterrupt { transport.notifyUsed() } + case .pendingReadFailed: + break + } + } + + private func drainQueue( + queue: Int, + transport: VirtioMMIOTransport + ) -> QueueDrainOutcome { + if queue == 0, + rendererWorkerCandidate != nil, + commandLock.withLock({ + rendererWorkerControlCommandClaim != nil + }) { + // The command at the head of the accepted worker stream still owns controlq ordering. + // Unknown outcomes intentionally keep this barrier until the whole device is reset. + return .drained(wantsInterrupt: false) + } + let virtqueue = transport.queues[queue] + let pending: UInt16 + do { + pending = try virtqueue.pendingCount() + } catch { + recordTelemetry(.queuePendingReadFailure) + return .pendingReadFailed + } + + // Process only the validated snapshot. Even if a future SMP guest publishes more work + // while this kick is running, one notification can never monopolize the VCPU indefinitely. + var wantsInterrupt = false + for _ in 0.. RendererWorkerControlCommandClaim? { + commandLock.withLock { + guard rendererWorkerControlCommandClaim == nil else { return nil } + let generation = fenceLock.withLock { lifecycleEpoch } + let claim = RendererWorkerControlCommandClaim( + generation: generation, + token: nextRendererWorkerControlCommandToken + ) + nextRendererWorkerControlCommandToken &+= 1 + if nextRendererWorkerControlCommandToken == 0 { + nextRendererWorkerControlCommandToken = 1 + } + rendererWorkerControlCommandClaim = claim + return claim + } + } + + /// Releases only the exact command claim. A late completion from either a revoked generation + /// or an older pipelined command in the active generation cannot open the queue. + @discardableResult + private func completeRendererWorkerControlCommand( + claim: RendererWorkerControlCommandClaim + ) -> Bool { + commandLock.withLock { + guard rendererWorkerControlCommandClaim == claim else { return false } + rendererWorkerControlCommandClaim = nil + return true + } + } + + private func admit( + chain: VirtqueueChain, + cursorQueue: Bool, + transport: VirtioMMIOTransport + ) -> QueueAdmissionOutcome { + guard !chain.containsZeroLengthDescriptor else { + return .rejected(.invalidDescriptorLayout) + } + return chain.withLeaseHeld { access in + let segments = access.segments + guard !segments.isEmpty else { + return .rejected(.invalidDescriptorLayout) + } + var encounteredWritable = false + for segment in segments { + if segment.isDeviceWritable { + encounteredWritable = true + } else if encounteredWritable { + // Virtio requests are a readable prefix followed by a writable suffix. Never + // normalize writable/readable/writable guest chains into a valid-looking pair. + return .rejected(.invalidDescriptorLayout) + } + } + + let readable = access.readableByteCount + let maximum = cursorQueue ? 56 : maximumControlRequestBytes + guard readable <= maximum else { return .rejected(.oversizedRequest) } + guard cursorQueue ? readable == 56 : readable >= 24 else { + return .rejected(.invalidDescriptorLayout) + } + + if !cursorQueue, rendererWorkerCandidate != nil { + let header = access.readBytes(maximum: 24) + if header.count == 24 { + let command = header.leUInt32(at: 0) + let exactWorkerRequestBytes: Int? = switch command { + case Command.resourceCreate2D: 40 + case Command.resourceUnref, + Command.resourceAssignUUID, + Command.resourceDetachBacking, + Command.ctxAttachResource, + Command.ctxDetachResource, + Command.resourceUnmapBlob: 32 + case Command.ctxDestroy: 24 + case Command.setScanout, Command.resourceFlush: 48 + case Command.transferToHost2D: 56 + case Command.ctxCreate, Command.setScanoutBlob: 96 + case Command.resourceCreate3D, + Command.transferToHost3D, + Command.transferFromHost3D: 72 + case Command.resourceMapBlob: 40 + default: nil + } + if let exactWorkerRequestBytes, readable != exactWorkerRequestBytes { + return .workerRejected(requestHeader: header) + } + if (command == Command.resourceAttachBacking && readable < 32) + || (command == Command.resourceCreateBlob && readable < 56) + || (command == Command.submit3D && readable < 32) { + return .workerRejected(requestHeader: header) + } + if command == Command.resourceCreate2D { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + guard readable == 40 else { + return .workerRejected(requestHeader: header) + } + let request = access.readBytes(maximum: 40) + let resourceID = request.leUInt32(at: 24) + let format = request.leUInt32(at: 28) + let width = request.leUInt32(at: 32) + let height = request.leUInt32(at: 36) + let flags = request.leUInt32(at: 4) + let fenceID = request.leUInt64(at: 8) + let hasValidFenceHeader = (flags == 0 && fenceID == 0) + || (flags == HeaderFlag.fence && fenceID != 0) + guard request.count == 40, + hasValidFenceHeader, + request.leUInt32(at: 16) == 0, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + width > 0, + height > 0, + width <= 16_384, + height <= 16_384, + Self.isSupportedScanoutFormat(format), + let copiedByteCount = Self.rgbaByteCount( + width: width, + height: height + ), + copiedByteCount <= maximumCopiedScanoutSurfaceBytes, + let payload = try? DoryRendererResource3DCreatePayload( + target: 2, + format: format, + bind: (1 << 1) | (1 << 18), + width: width, + height: height, + depth: 1, + arraySize: 1, + lastLevel: 0, + samples: 0, + flags: 1, + maximumReferencedBytes: maximumRendererReferencedBytes + ) else { + return .workerRejected(requestHeader: request) + } + return .workerCreateResource3D(WorkerCreateResource3DAdmission( + request: request, + resourceID: resourceID, + payload: payload, + kind: .resource2D, + fence: flags == HeaderFlag.fence + ? FenceRequest( + key: FenceKey(contextID: 0, ringIndex: 0), + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false + ) + : nil + )) + } + if command == Command.resourceCreate3D { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + guard readable == 72 else { + return .workerRejected(requestHeader: header) + } + let request = access.readBytes(maximum: 72) + let resourceID = request.leUInt32(at: 24) + let flags = request.leUInt32(at: 4) + let fenceID = request.leUInt64(at: 8) + let hasValidFenceHeader = (flags == 0 && fenceID == 0) + || (flags == HeaderFlag.fence && fenceID != 0) + guard request.count == 72, + hasValidFenceHeader, + request.leUInt32(at: 16) == 0, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + let payload = try? DoryRendererResource3DCreatePayload( + target: request.leUInt32(at: 28), + format: request.leUInt32(at: 32), + bind: request.leUInt32(at: 36), + width: request.leUInt32(at: 40), + height: request.leUInt32(at: 44), + depth: request.leUInt32(at: 48), + arraySize: request.leUInt32(at: 52), + lastLevel: request.leUInt32(at: 56), + samples: request.leUInt32(at: 60), + flags: request.leUInt32(at: 64), + maximumReferencedBytes: maximumRendererReferencedBytes + ) else { + return .workerRejected(requestHeader: request) + } + return .workerCreateResource3D(WorkerCreateResource3DAdmission( + request: request, + resourceID: resourceID, + payload: payload, + kind: .resource3D, + fence: flags == HeaderFlag.fence + ? FenceRequest( + key: FenceKey(contextID: 0, ringIndex: 0), + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false + ) + : nil + )) + } + if command == Command.transferToHost3D + || command == Command.transferFromHost3D { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let request = access.readBytes(maximum: 72) + let resourceID = request.leUInt32(at: 56) + let contextID = request.leUInt32(at: 16) + let flags = request.leUInt32(at: 4) + let fenceID = request.leUInt64(at: 8) + let hasValidFenceHeader = (flags == 0 && fenceID == 0) + || (flags == HeaderFlag.fence && fenceID != 0) + let resourceGeneration = commandLock.withLock { () -> UInt64? in + guard resourceEntries[resourceID] != nil, + contextID == 0 || createdContextIDs.contains(contextID) else { + return nil + } + return rendererWorkerResourceGenerations[resourceID] + } + guard request.count == 72, + hasValidFenceHeader, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + let resourceGeneration, + let payload = try? DoryRendererTransfer3DPayload( + level: request.leUInt32(at: 60), + stride: request.leUInt32(at: 64), + layerStride: request.leUInt32(at: 68), + offset: request.leUInt64(at: 48), + x: request.leUInt32(at: 24), + y: request.leUInt32(at: 28), + z: request.leUInt32(at: 32), + width: request.leUInt32(at: 36), + height: request.leUInt32(at: 40), + depth: request.leUInt32(at: 44) + ) else { + return .workerRejected(requestHeader: request) + } + return .workerTransfer(WorkerTransferAdmission( + request: request, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + contextID: contextID, + payload: payload, + direction: command == Command.transferToHost3D + ? .toHost + : .fromHost, + fence: flags == HeaderFlag.fence + ? FenceRequest( + key: FenceKey(contextID: 0, ringIndex: 0), + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false + ) + : nil + )) + } + if command == Command.ctxCreate || command == Command.ctxDestroy { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + guard header.leUInt32(at: 4) == 0, + header.leUInt32(at: 16) != 0 else { + return .workerRejected(requestHeader: header) + } + if command == Command.ctxDestroy { + guard readable == 24 else { + return .workerRejected(requestHeader: header) + } + return .workerControl(WorkerControlAdmission( + request: header, + contextID: header.leUInt32(at: 16), + operation: .destroyContext + )) + } + + guard readable == 96 else { + return .workerRejected(requestHeader: header) + } + let request = access.readBytes(maximum: 96) + guard request.count == 96 else { + return .rejected(.invalidDescriptorLayout) + } + let nameLength = Int(request.leUInt32(at: 24)) + let contextInit = request.leUInt32(at: 28) + let resolvedCapset = Self.rendererContextFlags( + requested: contextInit, + capsets: capsets + ) + guard nameLength <= DoryRendererContextCreatePayload.maximumNameBytes, + contextInit & ~UInt32(0xff) == 0, + resolvedCapset == 2 || resolvedCapset == Capset.venus else { + return .workerRejected(requestHeader: request) + } + let rawName = request[32..<(32 + nameLength)].prefix { $0 != 0 } + let name = rawName.isEmpty + ? "virtio-gpu" + : String(decoding: rawName, as: UTF8.self) + guard !name.utf8.contains(0), + name.utf8.count + <= DoryRendererContextCreatePayload.maximumNameBytes else { + return .workerRejected(requestHeader: request) + } + return .workerControl(WorkerControlAdmission( + request: request, + contextID: header.leUInt32(at: 16), + operation: .createContext(name: name, capsetID: resolvedCapset) + )) + } + } + } + + if !cursorQueue, rendererWorkerCandidate != nil, readable == 48 { + let request = access.readBytes(maximum: 48) + if request.count == 48, + request.leUInt32(at: 0) == Command.resourceFlush { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let resourceID = request.leUInt32(at: 40) + let workerState = commandLock.withLock { () -> ( + WorkerScanoutSurface?, + UInt64, + UInt64, + [WorkerFlushTarget] + )? in + guard let workerGeneration = + rendererWorkerResourceGenerations[resourceID], + let displayGeneration = resourceGenerations[resourceID], + let requestedRect = try? scanoutRect(from: request, at: 24) else { + return nil + } + var surface: WorkerScanoutSurface? + var targets = [WorkerFlushTarget]() + for (scanoutID, binding) in scanouts.sorted(by: { $0.key < $1.key }) { + guard binding.resourceID == resourceID else { continue } + guard let candidate = rendererWorkerScanoutSurface( + for: binding + ) else { return nil } + guard Self.contains( + rect: requestedRect, + width: candidate.width, + height: candidate.height + ), surface == nil || surface == candidate else { + return nil + } + surface = candidate + guard let dirty = Self.intersection( + requestedRect, + binding.rect + ) else { continue } + targets.append(WorkerFlushTarget( + scanoutID: scanoutID, + sourceRect: binding.rect, + dirtyRect: VirtioGPURect( + x: dirty.x - binding.rect.x, + y: dirty.y - binding.rect.y, + width: dirty.width, + height: dirty.height + ) + )) + } + return ( + surface, + workerGeneration, + displayGeneration, + targets + ) + } + if let workerState { + let flags = request.leUInt32(at: 4) + let fenceID = request.leUInt64(at: 8) + let hasValidFenceHeader = (flags == 0 && fenceID == 0) + || (flags == HeaderFlag.fence && fenceID != 0) + let canonicalHeader = hasValidFenceHeader + && request.leUInt32(at: 16) == 0 + && request[20..<24].allSatisfy({ $0 == 0 }) + && resourceID != 0 + && request.leUInt32(at: 44) == 0 + guard canonicalHeader else { + logRendererWorkerScanoutFailure( + resourceID: resourceID, + stage: "flush-header", + detail: "targets=\(workerState.3.count)" + ) + return .workerRejected(requestHeader: request) + } + return .workerFlushScanout(WorkerFlushScanoutAdmission( + request: request, + resourceID: resourceID, + workerResourceGeneration: workerState.1, + displayResourceGeneration: workerState.2, + surface: workerState.0, + targets: workerState.3, + fence: flags == HeaderFlag.fence + ? FenceRequest( + key: FenceKey(contextID: 0, ringIndex: 0), + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false + ) + : nil + )) + } + // A non-worker 2D/blob flush continues through the established software path; + // malformed or stale worker resource identity fails closed here. + let rejectedWorkerRoute = commandLock.withLock { () -> ( + rendererOwned: Bool, + detail: String + ) in + let rendererOwned = resources2D[resourceID] != nil + || resources3D[resourceID] != nil + || rendererWorkerResourceGenerations[resourceID] != nil + guard rendererOwned else { return (false, "") } + let requestedRect = try? scanoutRect(from: request, at: 24) + let rectDescription = requestedRect.map { + "\($0.x),\($0.y)/\($0.width)x\($0.height)" + } ?? "invalid" + let bindings = scanouts + .filter { $0.value.resourceID == resourceID } + .sorted { $0.key < $1.key } + .map { scanoutID, binding -> String in + switch binding.source { + case .resource2D: + return "\(scanoutID):2d" + case .resource3D: + return "\(scanoutID):3d" + case .blob(let format, let width, let height, let stride, let offset): + return "\(scanoutID):blob/\(format)/\(width)x\(height)/" + + "\(stride)/\(offset)" + } + } + .joined(separator: ",") + let detail = "blob=\(blobResources[resourceID] != nil) worker-generation=" + + "\(rendererWorkerResourceGenerations[resourceID].map(String.init) ?? "missing")" + + " display-generation=" + + "\(resourceGenerations[resourceID].map(String.init) ?? "missing")" + + " contexts=\((rendererWorkerResourceContextIDs[resourceID] ?? []).sorted())" + + " rect=\(rectDescription) bindings=[\(bindings)]" + return (true, detail) + } + if rejectedWorkerRoute.rendererOwned { + logRendererWorkerScanoutFailure( + resourceID: resourceID, + stage: "flush-state-missing", + detail: rejectedWorkerRoute.detail + ) + return .workerRejected(requestHeader: request) + } + } + } + + if !cursorQueue, rendererWorkerCandidate != nil, readable == 32 { + let request = access.readBytes(maximum: 32) + if request.count == 32 { + let command = request.leUInt32(at: 0) + if command == Command.resourceUnref { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let resourceID = request.leUInt32(at: 24) + let flags = request.leUInt32(at: 4) + let fenceID = request.leUInt64(at: 8) + let hasValidFenceHeader = (flags == 0 && fenceID == 0) + || (flags == HeaderFlag.fence && fenceID != 0) + let resourceState = commandLock.withLock { () -> ( + workerGeneration: UInt64, + displayGeneration: UInt64 + )? in + guard resources2D[resourceID] != nil + || resources3D[resourceID] != nil + || blobResources[resourceID] != nil, + let workerGeneration = + rendererWorkerResourceGenerations[resourceID], + let displayGeneration = resourceGenerations[resourceID], + rendererWorkerResourceContextIDs[resourceID]?.isEmpty + != false, + !rendererWorkerPendingResourceIDs.contains(resourceID), + !rendererWorkerPendingBackingResourceIDs.contains(resourceID), + !rendererWorkerPendingMappingResourceIDs.contains(resourceID), + !isResourceRetiring(resourceID), + blobResources[resourceID]?.guestMapped != true, + blobResources[resourceID]?.workerMapping == nil else { + return nil + } + return (workerGeneration, displayGeneration) + } + guard hasValidFenceHeader, + request.leUInt32(at: 16) == 0, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + request.leUInt32(at: 28) == 0, + let resourceState else { + return .workerRejected(requestHeader: request) + } + return .workerUnref(WorkerUnrefAdmission( + request: request, + resourceID: resourceID, + workerResourceGeneration: resourceState.workerGeneration, + displayResourceGeneration: resourceState.displayGeneration, + fence: flags == HeaderFlag.fence + ? FenceRequest( + key: FenceKey(contextID: 0, ringIndex: 0), + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false + ) + : nil + )) + } + if command == Command.resourceDetachBacking { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let resourceID = request.leUInt32(at: 24) + let flags = request.leUInt32(at: 4) + let fenceID = request.leUInt64(at: 8) + let hasValidFenceHeader = (flags == 0 && fenceID == 0) + || (flags == HeaderFlag.fence && fenceID != 0) + let resourceState = commandLock.withLock { () -> UInt64? in + guard resourceEntries[resourceID] != nil, + rendererWorkerPendingBackingResourceIDs + .contains(resourceID) == false else { return nil } + return rendererWorkerResourceGenerations[resourceID] + } + guard hasValidFenceHeader, + request.leUInt32(at: 16) == 0, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + request.leUInt32(at: 28) == 0, + let resourceState else { + return .workerRejected(requestHeader: request) + } + return .workerDetachBacking(WorkerDetachBackingAdmission( + request: request, + resourceID: resourceID, + resourceGeneration: resourceState, + fence: flags == HeaderFlag.fence + ? FenceRequest( + key: FenceKey(contextID: 0, ringIndex: 0), + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false + ) + : nil + )) + } + if command == Command.ctxAttachResource + || command == Command.ctxDetachResource { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let contextID = request.leUInt32(at: 16) + let resourceID = request.leUInt32(at: 24) + guard request.leUInt32(at: 4) == 0, + request.leUInt64(at: 8) == 0, + contextID != 0, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + request.leUInt32(at: 28) == 0 else { + return .workerRejected(requestHeader: request) + } + let workerRouted = commandLock.withLock { + rendererWorkerResourceGenerations[resourceID] != nil + } + guard workerRouted else { + return .workerRejected(requestHeader: request) + } + return .workerControl(WorkerControlAdmission( + request: request, + contextID: contextID, + operation: command == Command.ctxAttachResource + ? .attachResource(resourceID: resourceID) + : .detachResource(resourceID: resourceID) + )) + } + if command == Command.resourceUnmapBlob { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let resourceID = request.leUInt32(at: 24) + let resourceGeneration = commandLock.withLock { + rendererWorkerResourceGenerations[resourceID] + } + guard request.leUInt32(at: 4) == 0, + request.leUInt64(at: 8) == 0, + request.leUInt32(at: 16) == 0, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + request.leUInt32(at: 28) == 0, + let resourceGeneration else { + return .workerRejected(requestHeader: request) + } + return .workerUnmapBlob(WorkerUnmapBlobAdmission( + request: request, + resourceID: resourceID, + resourceGeneration: resourceGeneration + )) + } + } + } + + if !cursorQueue, rendererWorkerCandidate != nil, readable == 40 { + let request = access.readBytes(maximum: 40) + if request.count == 40, + request.leUInt32(at: 0) == Command.resourceMapBlob { + guard access.hasWritableSegments, + access.writableByteCount >= 32 else { + return .rejected(.insufficientResponseCapacity) + } + let resourceID = request.leUInt32(at: 24) + let resourceGeneration = commandLock.withLock { + rendererWorkerResourceGenerations[resourceID] + } + guard request.leUInt32(at: 4) == 0, + request.leUInt64(at: 8) == 0, + request.leUInt32(at: 16) == 0, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + request.leUInt32(at: 28) == 0, + let resourceGeneration else { + return .workerRejected(requestHeader: request) + } + return .workerMapBlob(WorkerMapBlobAdmission( + request: request, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + hostVisibleOffset: request.leUInt64(at: 32) + )) + } + } + + if !cursorQueue, rendererWorkerCandidate != nil, readable == 56 { + let request = access.readBytes(maximum: 56) + if request.count == 56, + request.leUInt32(at: 0) == Command.transferToHost2D { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let resourceID = request.leUInt32(at: 48) + let workerState = commandLock.withLock { () -> ( + resource: Resource2D, + generation: UInt64 + )? in + guard let resource = resources2D[resourceID], + !resource.backing.isEmpty, + resourceEntries[resourceID] != nil, + let generation = rendererWorkerResourceGenerations[resourceID] else { + return nil + } + return (resource, generation) + } + let rect = try? scanoutRect(from: request, at: 24) + let offset = request.leUInt64(at: 40) + let flags = request.leUInt32(at: 4) + let fenceID = request.leUInt64(at: 8) + let hasValidFenceHeader = (flags == 0 && fenceID == 0) + || (flags == HeaderFlag.fence && fenceID != 0) + guard hasValidFenceHeader, + request.leUInt32(at: 16) == 0, + request[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + request.leUInt32(at: 52) == 0, + let workerState, + let rect, + Self.contains( + rect: rect, + width: workerState.resource.width, + height: workerState.resource.height + ), + let resourceByteCount = Self.rgbaByteCount( + width: workerState.resource.width, + height: workerState.resource.height + ), + offset < resourceByteCount, + let payload = try? DoryRendererTransfer3DPayload( + level: 0, + stride: 0, + layerStride: 0, + offset: offset, + x: rect.x, + y: rect.y, + z: 0, + width: rect.width, + height: rect.height, + depth: 1 + ) else { + return .workerRejected(requestHeader: request) + } + return .workerTransfer(WorkerTransferAdmission( + request: request, + resourceID: resourceID, + resourceGeneration: workerState.generation, + contextID: 0, + payload: payload, + direction: .toHost, + fence: flags == HeaderFlag.fence + ? FenceRequest( + key: FenceKey(contextID: 0, ringIndex: 0), + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false + ) + : nil + )) + } + } + + if !cursorQueue, rendererWorkerCandidate != nil, readable >= 56 { + let header = access.readBytes(maximum: 56) + if header.count == 56, + header.leUInt32(at: 0) == Command.resourceCreateBlob { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let resourceID = header.leUInt32(at: 24) + let entryCount = Int(header.leUInt32(at: 36)) + let (entryBytes, multiplyOverflow) = entryCount + .multipliedReportingOverflow(by: 16) + let (expectedBytes, addOverflow) = 56 + .addingReportingOverflow(entryBytes) + guard header.leUInt32(at: 4) == 0, + header.leUInt64(at: 8) == 0, + header[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + entryCount <= maximumRawMemoryEntries, + !multiplyOverflow, + !addOverflow, + readable == expectedBytes, + let payload = try? DoryRendererBlobCreatePayload( + blobMemory: header.leUInt32(at: 28), + blobFlags: header.leUInt32(at: 32), + blobID: header.leUInt64(at: 40), + size: header.leUInt64(at: 48) + ), + payload.size <= maximumRendererReferencedBytes else { + return .workerRejected(requestHeader: header) + } + let request = access.readBytes(maximum: expectedBytes) + guard request.count == expectedBytes, + let entries = try? memoryEntries( + from: request, + count: UInt32(entryCount), + offset: 56, + transport: transport + ) else { + return .rejected(.invalidDescriptorLayout) + } + var referencedBytes: UInt64 = 0 + for entry in entries { + let (sum, overflow) = referencedBytes.addingReportingOverflow( + UInt64(entry.length) + ) + guard !overflow, sum <= maximumRendererReferencedBytes else { + return .workerRejected(requestHeader: header) + } + referencedBytes = sum + } + let regions: DoryRendererWorkerSharedRegionSet + if entries.isEmpty { + regions = DoryRendererWorkerSharedRegionSet( + references: [], + descriptors: [] + ) + } else { + guard let guestBacking = try? DoryRendererWorkerSharedRegionSet + .guestBacking(entries: entries, transport: transport) else { + return .rejected(.invalidDescriptorLayout) + } + regions = guestBacking + } + return .workerCreateBlob(WorkerCreateBlobAdmission( + request: request, + resourceID: resourceID, + contextID: header.leUInt32(at: 16), + payload: payload, + entries: entries, + regions: regions + )) + } + } + + if !cursorQueue, rendererWorkerCandidate != nil, readable >= 32 { + let header = access.readBytes(maximum: 32) + if header.count == 32, + header.leUInt32(at: 0) == Command.resourceAttachBacking { + let resourceID = header.leUInt32(at: 24) + let workerResourceGeneration = commandLock.withLock { + rendererWorkerResourceGenerations[resourceID] + } + if let workerResourceGeneration { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let entryCount = Int(header.leUInt32(at: 28)) + let (entryBytes, multiplyOverflow) = entryCount + .multipliedReportingOverflow(by: 16) + let (expectedBytes, addOverflow) = 32 + .addingReportingOverflow(entryBytes) + let flags = header.leUInt32(at: 4) + let fenceID = header.leUInt64(at: 8) + let hasValidFenceHeader = (flags == 0 && fenceID == 0) + || (flags == HeaderFlag.fence && fenceID != 0) + guard hasValidFenceHeader, + header.leUInt32(at: 16) == 0, + header[20..<24].allSatisfy({ $0 == 0 }), + resourceID != 0, + entryCount > 0, + entryCount <= maximumRawMemoryEntries, + !multiplyOverflow, + !addOverflow, + readable == expectedBytes else { + return .workerRejected(requestHeader: header) + } + let request = access.readBytes(maximum: expectedBytes) + guard request.count == expectedBytes, + let entries = try? memoryEntries( + from: request, + count: UInt32(entryCount), + offset: 32, + transport: transport + ), + !entries.isEmpty else { + return .rejected(.invalidDescriptorLayout) + } + var referencedBytes: UInt64 = 0 + for entry in entries { + let (sum, overflow) = referencedBytes.addingReportingOverflow( + UInt64(entry.length) + ) + guard !overflow, sum <= maximumRendererReferencedBytes else { + return .workerRejected(requestHeader: header) + } + referencedBytes = sum + } + guard let regions = try? DoryRendererWorkerSharedRegionSet.guestBacking( + entries: entries, + transport: transport + ) else { + return .rejected(.invalidDescriptorLayout) + } + return .workerAttachBacking(WorkerAttachBackingAdmission( + request: request, + resourceID: resourceID, + resourceGeneration: workerResourceGeneration, + entries: entries, + regions: regions, + fence: flags == HeaderFlag.fence + ? FenceRequest( + key: FenceKey(contextID: 0, ringIndex: 0), + contextID: 0, + ringIndex: 0, + fenceID: fenceID, + contextFence: false + ) + : nil + )) + } + return .workerRejected(requestHeader: header) + } + } + + if !cursorQueue, rendererWorkerCandidate != nil, readable >= 32 { + let header = access.readBytes(maximum: 32) + if header.count == 32, header.leUInt32(at: 0) == Command.submit3D { + guard access.hasWritableSegments, + access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + let flags = header.leUInt32(at: 4) + let hasFence = flags & HeaderFlag.fence != 0 + let hasContextTimeline = flags & HeaderFlag.infoRingIndex != 0 + let fenceID = header.leUInt64(at: 8) + let contextID = header.leUInt32(at: 16) + let ringIndex = UInt32(header[20]) + let hasCanonicalRing = !hasContextTimeline + || ringIndex <= DoryRendererFencePayload.maximumRingIndex + let hasCanonicalFencePair = hasFence + ? fenceID != 0 && (hasContextTimeline || ringIndex == 0) + : fenceID == 0 && (hasContextTimeline || ringIndex == 0) + guard flags & ~(HeaderFlag.fence | HeaderFlag.infoRingIndex) == 0, + hasCanonicalRing, + hasCanonicalFencePair, + contextID != 0, + header[21..<24].allSatisfy({ $0 == 0 }), + header.leUInt32(at: 28) == 0 else { + return .workerRejected(requestHeader: header) + } + let commandByteCount = Int(header.leUInt32(at: 24)) + let (end, overflow) = 32.addingReportingOverflow(commandByteCount) + guard !overflow, end == readable else { + return .workerRejected(requestHeader: header) + } + let regions: DoryRendererWorkerSharedRegionSet + let snapshotStarted = DispatchTime.now().uptimeNanoseconds + do { + regions = try DoryRendererWorkerSharedRegionSet.immutableSubmit3D( + from: access, + readableOffset: 32, + byteCount: commandByteCount, + maximumByteCount: maximumControlRequestBytes - 32 + ) + } catch { + return .rejected(.invalidDescriptorLayout) + } + let snapshotFinished = DispatchTime.now().uptimeNanoseconds + let snapshotNanoseconds = snapshotFinished >= snapshotStarted + ? snapshotFinished - snapshotStarted + : 0 + rendererWorkerMetricsLock.withLock { + rendererWorkerSnapshotMetrics.count = Self.saturatingAdd( + rendererWorkerSnapshotMetrics.count, + 1 + ) + rendererWorkerSnapshotMetrics.bytes = Self.saturatingAdd( + rendererWorkerSnapshotMetrics.bytes, + UInt64(commandByteCount) + ) + rendererWorkerSnapshotMetrics.nanoseconds = Self.saturatingAdd( + rendererWorkerSnapshotMetrics.nanoseconds, + snapshotNanoseconds + ) + rendererWorkerSnapshotMetrics.maximumNanoseconds = max( + rendererWorkerSnapshotMetrics.maximumNanoseconds, + snapshotNanoseconds + ) + } + let fence = hasFence ? FenceRequest( + key: hasContextTimeline + ? FenceKey(contextID: contextID, ringIndex: ringIndex) + : FenceKey(contextID: 0, ringIndex: 0), + contextID: contextID, + ringIndex: hasContextTimeline ? ringIndex : 0, + fenceID: fenceID, + contextFence: hasContextTimeline + ) : nil + return .workerSubmit(WorkerSubmitAdmission( + requestHeader: header, + regions: regions, + fence: fence + )) + } + } + let request = access.readBytes(maximum: maximum) + guard request.count == readable else { + return .rejected(.invalidDescriptorLayout) + } + + if cursorQueue { + // Linux's cursor fast path supplies only the outbound 56-byte command. Accept an + // optional response suffix for other conforming drivers, but never require one. + guard !access.hasWritableSegments || access.writableByteCount >= 24 else { + return .rejected(.insufficientResponseCapacity) + } + return .admitted( + request: request, + writesResponse: access.hasWritableSegments + ) + } + + guard access.hasWritableSegments, + access.writableByteCount >= maximumResponseByteCount(for: request) else { + return .rejected(.insufficientResponseCapacity) + } + return .admitted(request: request, writesResponse: true) + } ?? .revoked + } + + private func maximumResponseByteCount(for request: [UInt8]) -> Int { + guard request.count >= 4 else { return 24 } + switch request.leUInt32(at: 0) { + case Command.getDisplayInfo: + return 24 + 16 * 24 + case Command.getCapsetInfo, Command.resourceAssignUUID: + return 40 + case Command.resourceMapBlob: + return 32 + case Command.getCapset: + guard request.count >= 32 else { return 24 } + guard rendererCapabilitiesAreAdvertised else { return 24 } + let id = request.leUInt32(at: 24) + let version = request.leUInt32(at: 28) + guard let capset = capsets.first(where: { + $0.id == id && version <= $0.maxVersion + }) else { return 24 } + let (total, overflow) = 24.addingReportingOverflow(capset.data.count) + return overflow ? Int.max : total + default: + return 24 + } + } + + private func prepareFenceAdmission( + request: [UInt8], + responseByteCount: Int, + transport: VirtioMMIOTransport + ) -> FenceAdmission { + guard rendererExecutor != nil, request.count >= 24 else { return .notRequested } let flags = request.leUInt32(at: 4) - guard flags & HeaderFlag.fence != 0 else { return false } - guard response.leUInt32(at: 0) & 0xFF00 == 0x1100 else { return false } + guard flags & HeaderFlag.fence != 0 else { return .notRequested } let fenceID = request.leUInt64(at: 8) let contextID = request.leUInt32(at: 16) let ringIndex = UInt32(request[20]) let contextFence = flags & HeaderFlag.infoRingIndex != 0 + let key = contextFence + ? FenceKey(contextID: contextID, ringIndex: ringIndex) + : FenceKey(contextID: 0, ringIndex: 0) + return fenceLock.withLock { + guard !fenceAdmissionBlockedUntilDeviceReset, + pendingFenceCount < maximumPendingFences, + responseByteCount <= maximumPendingFenceResponseBytes, + pendingFenceResponseBytes + <= maximumPendingFenceResponseBytes - responseByteCount, + lastTransport == nil || lastTransport === transport else { + return .rejected + } + return .admitted(FenceRequest( + key: key, + contextID: contextID, + ringIndex: ringIndex, + fenceID: fenceID, + contextFence: contextFence + )) + } + } + + /// Holds a successfully processed fenced command until the renderer signals its timeline. + /// Presentation itself follows RESOURCE_FLUSH and shares the renderer texture; it does not + /// perform a separate readback operation that needs fence coupling. + private func deferForFence( + admission: FenceAdmission, + response: [UInt8], + chain: VirtqueueChain, + transport: VirtioMMIOTransport + ) -> FenceDeferralOutcome { + guard let rendererExecutor, + case .admitted(let fence) = admission, + response.count >= 4, + response.leUInt32(at: 0) & 0xFF00 == 0x1100 else { + return .immediate + } + // Publish the waiter before creating the renderer fence. With a fast host GPU (and in + // particular after a synchronizing readback), virglrenderer may invoke its completion + // callback from createFence itself or immediately on another thread. Registering after + // createFence loses that edge forever and stalls the guest compositor on its first frame. + let waiter = fenceLock.withLock { () -> PendingFence in + let token = nextFenceToken + nextFenceToken &+= 1 + if nextFenceToken == 0 { nextFenceToken = 1 } + let pending = PendingFence( + token: token, + fenceID: fence.fenceID, + epoch: lifecycleEpoch, + response: response, + chain: chain, + createdAtMonotonicNanoseconds: DispatchTime.now().uptimeNanoseconds, + timeoutReported: false + ) + pendingFences[fence.key, default: []].append(pending) + pendingFenceCount += 1 + pendingFenceResponseBytes += response.count + lastTransport = transport + return pending + } + let fenceOutcome = rendererExecutor.execute( + .createFence( + contextID: fence.contextID, + ringIndex: fence.ringIndex, + guestFenceID: fence.fenceID, + contextFence: fence.contextFence + ), + generation: waiter.epoch + ) + switch fenceOutcome { + case .success(.none): + fenceLock.withLock { + fenceCount = Self.saturatingAdd(fenceCount, 1) + } + return .deferred + case .success: + preconditionFailure("create-fence returned an invalid executor payload") + case .rejected(let rejection): + let detail = "renderer fence rejected after command commit: \(rejection)" + fenceLock.withLock { + recordTelemetryWhileLocked(.rendererCommandUncertainty) + if var waiting = pendingFences[fence.key], + let index = waiting.firstIndex(where: { $0.token == waiter.token }) { + uncertainFences.append(waiting.remove(at: index)) + pendingFences[fence.key] = waiting.isEmpty ? nil : waiting + } + fenceRegistrationFailureCount = Self.saturatingAdd( + fenceRegistrationFailureCount, + 1 + ) + fenceAdmissionBlockedUntilDeviceReset = true + } + failRendererLifecycle( + .fenceRegistrationFailed(detail), + epoch: waiter.epoch + ) + return .outcomeUnknown + case .outcomeUnknown(let uncertainty): + fenceLock.withLock { + recordTelemetryWhileLocked(.rendererCommandUncertainty) + if var waiting = pendingFences[fence.key], + let index = waiting.firstIndex(where: { $0.token == waiter.token }) { + uncertainFences.append(waiting.remove(at: index)) + pendingFences[fence.key] = waiting.isEmpty ? nil : waiting + } + fenceRegistrationFailureCount = Self.saturatingAdd( + fenceRegistrationFailureCount, + 1 + ) + fenceAdmissionBlockedUntilDeviceReset = true + if let failure = uncertainty.runtimeFailure { + recordRendererFailureWhileLocked(failure) + } + } + // The command may already be executing in the renderer. There is no safe successful + // or error completion without a fence, so retain this exact descriptor for reset and + // quarantine new renderer-backed work rather than fabricating completion. + failRendererLifecycle( + .fenceRegistrationFailed(uncertainty.detail), + epoch: waiter.epoch + ) + return .outcomeUnknown + } + } + + private func startRendererWorkerControl( + _ admission: WorkerControlAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + guard let rendererWorkerCandidate else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let completion: DoryRendererWorkerVirtioCommandLane.Completion = { + [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerControl( + result, + admission: admission, + chain: chain, + claim: claim, + transport: transport + ) + } + do { + switch admission.operation { + case .createContext(let name, let capsetID): + try rendererWorkerCandidate.createContext( + contextID: admission.contextID, + capsetID: capsetID, + name: name, + deviceGeneration: generation, + completion: completion + ) + case .destroyContext: + try rendererWorkerCandidate.destroyContext( + contextID: admission.contextID, + deviceGeneration: generation, + completion: completion + ) + case .attachResource(let resourceID): + guard let resourceGeneration = commandLock.withLock({ + rendererWorkerResourceGenerations[resourceID] + }) else { + throw DoryRendererWorkerVirtioCommandLaneError.invalidSubmitRegions + } + try rendererWorkerCandidate.attachResource( + contextID: admission.contextID, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deviceGeneration: generation, + completion: completion + ) + case .detachResource(let resourceID): + guard let resourceGeneration = commandLock.withLock({ + rendererWorkerResourceGenerations[resourceID] + }) else { + throw DoryRendererWorkerVirtioCommandLaneError.invalidSubmitRegions + } + try rendererWorkerCandidate.detachResource( + contextID: admission.contextID, + resourceID: resourceID, + resourceGeneration: resourceGeneration, + deviceGeneration: generation, + completion: completion + ) + } + } catch { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + return nil + } + + private func finishRendererWorkerControl( + _ result: Result, + admission: WorkerControlAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success: + let committed = commandLock.withLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration else { return false } + switch admission.operation { + case .createContext(let name, let capsetID): + createdContextIDs.insert(admission.contextID) + traceResourceEvent( + "worker-context-create", + contextID: admission.contextID, + detail: "name=\(name) capset=\(capsetID)" + ) + case .destroyContext: + createdContextIDs.remove(admission.contextID) + for resourceID in Array(rendererWorkerResourceContextIDs.keys) { + rendererWorkerResourceContextIDs[resourceID]?.remove( + admission.contextID + ) + if rendererWorkerResourceContextIDs[resourceID]?.isEmpty == true { + rendererWorkerResourceContextIDs.removeValue(forKey: resourceID) + } + } + traceResourceEvent( + "worker-context-destroy", + contextID: admission.contextID + ) + case .attachResource(let resourceID): + guard rendererWorkerResourceGenerations[resourceID] != nil else { + return false + } + rendererWorkerResourceContextIDs[resourceID, default: []].insert( + admission.contextID + ) + traceResourceEvent( + "worker-attach", + contextID: admission.contextID, + resourceID: resourceID + ) + case .detachResource(let resourceID): + rendererWorkerResourceContextIDs[resourceID]?.remove(admission.contextID) + if rendererWorkerResourceContextIDs[resourceID]?.isEmpty == true { + rendererWorkerResourceContextIDs.removeValue(forKey: resourceID) + } + traceResourceEvent( + "worker-detach", + contextID: admission.contextID, + resourceID: resourceID + ) + } + return true + } + guard committed else { + recordTelemetry(.revokedCompletion) + return + } + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure(let error) where error.provesNoRendererMutation: + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure: + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + } + + private func startRendererWorkerCreateResource3D( + _ admission: WorkerCreateResource3DAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + guard let rendererWorkerCandidate else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let reserved = commandLock.withLock { () -> Bool in + guard canAdmitResource(admission.resourceID) else { return false } + return rendererWorkerPendingResourceIDs.insert(admission.resourceID).inserted + } + guard reserved else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let pendingFence = admission.fence.flatMap { + reserveRendererWorkerFence( + $0, + response: responseHeader(type: Response.okNoData, request: admission.request), + chain: chain, + generation: generation, + transport: transport + ) + } + if admission.fence != nil, pendingFence == nil { + _ = commandLock.withLock { + rendererWorkerPendingResourceIDs.remove(admission.resourceID) + } + recordTelemetry(.fenceAdmissionRejection) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + do { + try rendererWorkerCandidate.createResource3D( + resourceID: admission.resourceID, + payload: admission.payload, + deviceGeneration: generation + ) { [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerCreateResource3D( + result, + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + if error.provesNoRendererMutation { + _ = commandLock.withLock { + rendererWorkerPendingResourceIDs.remove(admission.resourceID) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } catch { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + return nil + } + + private func finishRendererWorkerCreateResource3D( + _ result: Result, + admission: WorkerCreateResource3DAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success(let workerResourceGeneration): + let committed = commandLock.withLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerPendingResourceIDs.remove(admission.resourceID) != nil else { + return false + } + switch admission.kind { + case .resource2D: + resources2D[admission.resourceID] = Resource2D( + format: admission.payload.format, + width: admission.payload.width, + height: admission.payload.height + ) + case .resource3D: + resources3D[admission.resourceID] = Resource3D( + format: admission.payload.format, + width: admission.payload.width, + height: admission.payload.height + ) + } + rendererWorkerResourceGenerations[admission.resourceID] = + workerResourceGeneration + registerResourceGeneration(admission.resourceID) + traceResourceEvent( + admission.kind == .resource2D + ? "worker-create-2d" + : "worker-create-3d", + resourceID: admission.resourceID, + detail: "worker-generation=\(workerResourceGeneration)" + ) + return true + } + guard committed else { + recordTelemetry(.revokedCompletion) + return + } + if let fence = admission.fence, let pendingFence { + startRendererWorkerGlobalFenceAfterMutation( + fence, + pending: pendingFence, + claim: claim, + generation: generation, + transport: transport + ) + } else { + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + } + case .failure(let error) where error.provesNoRendererMutation: + _ = commandLock.withLock { + rendererWorkerPendingResourceIDs.remove(admission.resourceID) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure: + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + } + + private func startRendererWorkerCreateBlob( + _ admission: WorkerCreateBlobAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + defer { + for descriptor in admission.regions.descriptors { try? descriptor.close() } + } + guard let rendererWorkerCandidate else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let reserved = commandLock.withLock { () -> Bool in + guard canAdmitResource(admission.resourceID) else { return false } + return rendererWorkerPendingResourceIDs.insert(admission.resourceID).inserted + } + guard reserved else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + do { + try rendererWorkerCandidate.createBlob( + resourceID: admission.resourceID, + contextID: admission.contextID, + payload: admission.payload, + regions: admission.regions, + deviceGeneration: generation + ) { [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerCreateBlob( + result, + admission: admission, + chain: chain, + claim: claim, + transport: transport + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + if error.provesNoRendererMutation { + _ = commandLock.withLock { + rendererWorkerPendingResourceIDs.remove(admission.resourceID) + } + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } catch { + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + return nil + } + + private func finishRendererWorkerCreateBlob( + _ result: Result, + admission: WorkerCreateBlobAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success(let workerResourceGeneration): + let committed = commandLock.withLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerPendingResourceIDs.remove(admission.resourceID) != nil else { + return false + } + blobResources[admission.resourceID] = BlobResource( + memory: admission.payload.blobMemory, + size: admission.payload.size, + mapping: nil, + workerMapping: nil + ) + resourceEntries[admission.resourceID] = admission.entries + rendererWorkerResourceGenerations[admission.resourceID] = + workerResourceGeneration + registerResourceGeneration(admission.resourceID) + traceResourceEvent( + "worker-create-blob", + contextID: admission.contextID, + resourceID: admission.resourceID, + detail: "worker-generation=\(workerResourceGeneration) " + + "size=\(admission.payload.size)" + ) + return true + } + guard committed else { + recordTelemetry(.revokedCompletion) + return + } + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure(let error) where error.provesNoRendererMutation: + _ = commandLock.withLock { + rendererWorkerPendingResourceIDs.remove(admission.resourceID) + } + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure: + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + } + + private func startRendererWorkerAttachBacking( + _ admission: WorkerAttachBackingAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + defer { + for descriptor in admission.regions.descriptors { try? descriptor.close() } + } + guard let rendererWorkerCandidate else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let reserved = commandLock.withLock { () -> Bool in + guard rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + resourceEntries[admission.resourceID] == nil else { return false } + return rendererWorkerPendingBackingResourceIDs + .insert(admission.resourceID).inserted + } + guard reserved else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let pendingFence = admission.fence.flatMap { + reserveRendererWorkerFence( + $0, + response: responseHeader(type: Response.okNoData, request: admission.request), + chain: chain, + generation: generation, + transport: transport + ) + } + if admission.fence != nil, pendingFence == nil { + _ = commandLock.withLock { + rendererWorkerPendingBackingResourceIDs.remove(admission.resourceID) + } + recordTelemetry(.fenceAdmissionRejection) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + do { + try rendererWorkerCandidate.attachBacking( + resourceID: admission.resourceID, + resourceGeneration: admission.resourceGeneration, + regions: admission.regions, + deviceGeneration: generation + ) { [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerAttachBacking( + result, + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + if error.provesNoRendererMutation { + _ = commandLock.withLock { + rendererWorkerPendingBackingResourceIDs.remove(admission.resourceID) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } catch { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + return nil + } + + private func finishRendererWorkerAttachBacking( + _ result: Result, + admission: WorkerAttachBackingAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success: + let committed = commandLock.withLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + rendererWorkerPendingBackingResourceIDs + .remove(admission.resourceID) != nil else { return false } + resourceEntries[admission.resourceID] = admission.entries + if var resource2D = resources2D[admission.resourceID] { + resource2D.backing = admission.entries + resources2D[admission.resourceID] = resource2D + } + traceResourceEvent( + "worker-attach-backing", + resourceID: admission.resourceID, + detail: "entries=\(admission.entries.count)" + ) + return true + } + guard committed else { + recordTelemetry(.revokedCompletion) + return + } + if let fence = admission.fence, let pendingFence { + startRendererWorkerGlobalFenceAfterMutation( + fence, + pending: pendingFence, + claim: claim, + generation: generation, + transport: transport + ) + } else { + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + } + case .failure(let error) where error.provesNoRendererMutation: + _ = commandLock.withLock { + rendererWorkerPendingBackingResourceIDs.remove(admission.resourceID) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure: + // The worker may retain the guest-memory authority. Preserve the reservation and chain + // until reset so detach/unref cannot race unknown foreign backing ownership. + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + } + + private func startRendererWorkerDetachBacking( + _ admission: WorkerDetachBackingAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + guard let rendererWorkerCandidate else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let reserved = commandLock.withLock { () -> Bool in + guard rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + resourceEntries[admission.resourceID] != nil else { return false } + return rendererWorkerPendingBackingResourceIDs + .insert(admission.resourceID).inserted + } + guard reserved else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let pendingFence = admission.fence.flatMap { + reserveRendererWorkerFence( + $0, + response: responseHeader(type: Response.okNoData, request: admission.request), + chain: chain, + generation: generation, + transport: transport + ) + } + if admission.fence != nil, pendingFence == nil { + _ = commandLock.withLock { + rendererWorkerPendingBackingResourceIDs.remove(admission.resourceID) + } + recordTelemetry(.fenceAdmissionRejection) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + do { + try rendererWorkerCandidate.detachBacking( + resourceID: admission.resourceID, + resourceGeneration: admission.resourceGeneration, + deviceGeneration: generation + ) { [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerDetachBacking( + result, + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + if error.provesNoRendererMutation { + _ = commandLock.withLock { + rendererWorkerPendingBackingResourceIDs.remove(admission.resourceID) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } catch { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + return nil + } + + private func finishRendererWorkerDetachBacking( + _ result: Result, + admission: WorkerDetachBackingAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success: + let committed = commandLock.withLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + rendererWorkerPendingBackingResourceIDs + .remove(admission.resourceID) != nil else { return false } + resourceEntries.removeValue(forKey: admission.resourceID) + if var resource2D = resources2D[admission.resourceID] { + resource2D.backing = [] + resources2D[admission.resourceID] = resource2D + } + traceResourceEvent( + "worker-detach-backing", + resourceID: admission.resourceID + ) + return true + } + guard committed else { + recordTelemetry(.revokedCompletion) + return + } + if let fence = admission.fence, let pendingFence { + startRendererWorkerGlobalFenceAfterMutation( + fence, + pending: pendingFence, + claim: claim, + generation: generation, + transport: transport + ) + } else { + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + } + case .failure(let error) where error.provesNoRendererMutation: + _ = commandLock.withLock { + rendererWorkerPendingBackingResourceIDs.remove(admission.resourceID) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure: + // Foreign backing ownership may have changed. Keep both the local reservation and the + // guest descriptor owned until reset establishes a new renderer generation. + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + } + + private func startRendererWorkerTransfer( + _ admission: WorkerTransferAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + guard let rendererWorkerCandidate, + commandLock.withLock({ + rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration + }) else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let pendingFence = admission.fence.flatMap { + reserveRendererWorkerFence( + $0, + response: responseHeader(type: Response.okNoData, request: admission.request), + chain: chain, + generation: generation, + transport: transport + ) + } + if admission.fence != nil, pendingFence == nil { + recordTelemetry(.fenceAdmissionRejection) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let completion: DoryRendererWorkerVirtioCommandLane.Completion = { + [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerTransfer( + result, + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + do { + switch admission.direction { + case .toHost: + try rendererWorkerCandidate.transferToHost3D( + resourceID: admission.resourceID, + resourceGeneration: admission.resourceGeneration, + contextID: admission.contextID, + payload: admission.payload, + deviceGeneration: generation, + completion: completion + ) + case .fromHost: + try rendererWorkerCandidate.transferFromHost3D( + resourceID: admission.resourceID, + resourceGeneration: admission.resourceGeneration, + contextID: admission.contextID, + payload: admission.payload, + deviceGeneration: generation, + completion: completion + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + if error.provesNoRendererMutation { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } catch { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + return nil + } + + private func finishRendererWorkerTransfer( + _ result: Result, + admission: WorkerTransferAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success: + let current = commandLock.withLock { + rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration + && fenceLock.withLock { lifecycleEpoch == generation } + } + guard current else { + recordTelemetry(.revokedCompletion) + return + } + if let fence = admission.fence, let pendingFence { + startRendererWorkerGlobalFenceAfterMutation( + fence, + pending: pendingFence, + claim: claim, + generation: generation, + transport: transport + ) + } else { + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + } + case .failure(let error) where error.provesNoRendererMutation: + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure: + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + } + + private func startRendererWorkerUnref( + _ admission: WorkerUnrefAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + guard rendererWorkerCandidate != nil else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let pendingFence = admission.fence.flatMap { + reserveRendererWorkerFence( + $0, + response: responseHeader(type: Response.okNoData, request: admission.request), + chain: chain, + generation: generation, + transport: transport + ) + } + if admission.fence != nil, pendingFence == nil { + recordTelemetry(.fenceAdmissionRejection) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + + let retirementReserved = commandLock.withLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[admission.resourceID] + == admission.workerResourceGeneration, + resourceGenerations[admission.resourceID] + == admission.displayResourceGeneration, + rendererWorkerResourceContextIDs[admission.resourceID]?.isEmpty != false, + !rendererWorkerPendingResourceIDs.contains(admission.resourceID), + !rendererWorkerPendingBackingResourceIDs.contains(admission.resourceID), + !rendererWorkerPendingMappingResourceIDs.contains(admission.resourceID), + lifecycleLock.withLock({ () -> Bool in + guard retiringResources[admission.resourceID] == nil else { return false } + retiringResources[admission.resourceID] = admission.displayResourceGeneration + return true + }) else { return false } + traceResourceEvent( + "worker-unref-release-begin", + resourceID: admission.resourceID, + detail: "worker-generation=\(admission.workerResourceGeneration)" + ) + return true + } + guard retirementReserved else { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + // Keep the guest-visible bindings intact while the exact display generation drains. The + // worker cannot unref with a live lease, but a proven pre-mutation rejection must leave + // local state recoverable; destructive binding removal therefore waits for worker ACK. + publishResourceRelease( + resourceID: admission.resourceID, + generation: admission.displayResourceGeneration + ) { [weak self, weak transport] in + guard let self, let transport else { return } + self.submitRendererWorkerUnrefAfterRelease( + admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + return nil + } + + private func submitRendererWorkerUnrefAfterRelease( + _ admission: WorkerUnrefAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + guard let rendererWorkerCandidate, + commandLock.withLock({ + rendererWorkerResourceGenerations[admission.resourceID] + == admission.workerResourceGeneration + && resourceGenerations[admission.resourceID] + == admission.displayResourceGeneration + && lifecycleLock.withLock { + retiringResources[admission.resourceID] + == admission.displayResourceGeneration + } + && fenceLock.withLock { lifecycleEpoch == generation } + }) else { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + return + } + do { + try rendererWorkerCandidate.unrefResource( + resourceID: admission.resourceID, + resourceGeneration: admission.workerResourceGeneration, + deviceGeneration: generation + ) { [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerUnref( + result, + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + finishRendererWorkerUnref( + .failure(error), + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } catch { + finishRendererWorkerUnref( + .failure(.unexpectedWorkerReply), + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + } + + private func finishRendererWorkerUnref( + _ result: Result, + admission: WorkerUnrefAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success: + let removedState = commandLock.withLock { () -> ( + cursor: Bool, + scanoutIDs: [UInt32] + )? in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[admission.resourceID] + == admission.workerResourceGeneration, + resourceGenerations[admission.resourceID] + == admission.displayResourceGeneration, + lifecycleLock.withLock({ + retiringResources[admission.resourceID] + == admission.displayResourceGeneration + }) else { return nil } + resources2D.removeValue(forKey: admission.resourceID) + resources3D.removeValue(forKey: admission.resourceID) + blobResources.removeValue(forKey: admission.resourceID) + resourceEntries.removeValue(forKey: admission.resourceID) + resourceUUIDs.removeValue(forKey: admission.resourceID) + resourceGenerations.removeValue(forKey: admission.resourceID) + rendererWorkerResourceGenerations.removeValue(forKey: admission.resourceID) + rendererWorkerResourceContextIDs.removeValue(forKey: admission.resourceID) + let removedCursor = cursorResourceID == admission.resourceID + if removedCursor { cursorResourceID = nil } + let scanoutIDs = scanouts.compactMap { scanoutID, binding in + binding.resourceID == admission.resourceID ? scanoutID : nil + } + scanouts = scanouts.filter { $0.value.resourceID != admission.resourceID } + hostVisibleMemory?.unmap(resourceID: admission.resourceID) + lifecycleLock.withLock { + if retiringResources[admission.resourceID] + == admission.displayResourceGeneration { + retiringResources.removeValue(forKey: admission.resourceID) + } + } + traceResourceEvent( + "worker-unref-complete", + resourceID: admission.resourceID, + detail: "worker-generation=\(admission.workerResourceGeneration)" + ) + return (removedCursor, scanoutIDs) + } + guard let removedState else { + recordTelemetry(.revokedCompletion) + return + } + if removedState.cursor { onCursorUpdate?(nil) } + for scanoutID in removedState.scanoutIDs.sorted() { + onScanoutDisabled?(scanoutID) + } + scheduleQuiescenceCleanupIfReady() + if let fence = admission.fence, let pendingFence { + startRendererWorkerGlobalFenceAfterMutation( + fence, + pending: pendingFence, + claim: claim, + generation: generation, + transport: transport + ) + } else { + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + } + case .failure(let error) where error.provesNoRendererMutation: + lifecycleLock.withLock { + if retiringResources[admission.resourceID] + == admission.displayResourceGeneration { + retiringResources.removeValue(forKey: admission.resourceID) + } + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure: + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + } + + private func startRendererWorkerMapBlob( + _ admission: WorkerMapBlobAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + guard let rendererWorkerCandidate, + hostVisibleMemory != nil else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let reserved = commandLock.withLock { () -> Bool in + guard rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + let blob = blobResources[admission.resourceID], + blob.mapping == nil, + blob.workerMapping == nil, + !blob.guestMapped else { return false } + return rendererWorkerPendingMappingResourceIDs + .insert(admission.resourceID).inserted + } + guard reserved else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + do { + try rendererWorkerCandidate.mapBlob( + resourceID: admission.resourceID, + resourceGeneration: admission.resourceGeneration, + deviceGeneration: generation + ) { [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerMapBlob( + result, + admission: admission, + chain: chain, + claim: claim, + transport: transport + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + if error.provesNoRendererMutation { + _ = commandLock.withLock { + rendererWorkerPendingMappingResourceIDs.remove(admission.resourceID) + } + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } catch { + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + return nil + } + + private func finishRendererWorkerMapBlob( + _ result: Result< + DoryRendererWorkerBlobMapping, + DoryRendererWorkerVirtioCommandLaneError + >, + admission: WorkerMapBlobAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success(let mapping): + let mapInfo = mapping.lease.mapInfo + let committed: Bool + do { + committed = try commandLock.withLock { () throws -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + let blob = blobResources[admission.resourceID], + blob.size == mapping.lease.mappingByteCount, + rendererWorkerPendingMappingResourceIDs + .contains(admission.resourceID), + let hostVisibleMemory else { + try? mapping.sharedMemoryDescriptor.close() + return false + } + let authority = try DoryRendererWorkerBlobMappingAuthority(mapping) + try hostVisibleMemory.map( + resourceID: admission.resourceID, + hostPointer: authority.hostPointer, + offset: admission.hostVisibleOffset, + size: authority.lease.mappingByteCount + ) + guard var updated = blobResources[admission.resourceID] else { + hostVisibleMemory.unmap(resourceID: admission.resourceID) + return false + } + updated.workerMapping = authority + updated.guestMapped = true + blobResources[admission.resourceID] = updated + rendererWorkerPendingMappingResourceIDs.remove(admission.resourceID) + traceResourceEvent( + "worker-map-blob", + resourceID: admission.resourceID, + detail: "offset=\(admission.hostVisibleOffset) " + + "size=\(authority.lease.mappingByteCount)" + ) + return true + } + } catch { + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + return + } + guard committed else { + recordTelemetry(.revokedCompletion) + return + } + var response = responseHeader( + type: Response.okMapInfo, + request: admission.request + ) + response.appendLE(mapInfo) + response.appendLE(UInt32(0)) + publishRendererWorkerCompletion( + chain: chain, + response: response, + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure(let error) where error.provesNoRendererMutation: + _ = commandLock.withLock { + rendererWorkerPendingMappingResourceIDs.remove(admission.resourceID) + } + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure: + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + } + + private func startRendererWorkerUnmapBlob( + _ admission: WorkerUnmapBlobAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + guard let rendererWorkerCandidate, + hostVisibleMemory != nil else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let reserved = commandLock.withLock { () -> Bool in + guard rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + let blob = blobResources[admission.resourceID], + blob.workerMapping != nil, + blob.guestMapped else { return false } + return rendererWorkerPendingMappingResourceIDs + .insert(admission.resourceID).inserted + } + guard reserved else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + do { + try rendererWorkerCandidate.unmapBlob( + resourceID: admission.resourceID, + resourceGeneration: admission.resourceGeneration, + deviceGeneration: generation, + beforeWorkerUnmap: { [weak self] in + self?.tearDownRendererWorkerBlobMapping( + resourceID: admission.resourceID, + resourceGeneration: admission.resourceGeneration, + deviceGeneration: generation + ) ?? false + } + ) { [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerUnmapBlob( + result, + admission: admission, + chain: chain, + claim: claim, + transport: transport + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + if error.provesNoRendererMutation { + _ = commandLock.withLock { + rendererWorkerPendingMappingResourceIDs.remove(admission.resourceID) + } + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } catch { + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + return nil + } + + /// Called only from the worker's serialized lane. The local guest mapping and every VMM-held + /// descriptor/mmap authority are gone before the lane can encode or send UNMAP_BLOB. + private func tearDownRendererWorkerBlobMapping( + resourceID: UInt32, + resourceGeneration: UInt64, + deviceGeneration: UInt64 + ) -> Bool { + commandLock.withLock { + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == deviceGeneration + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[resourceID] == resourceGeneration, + rendererWorkerPendingMappingResourceIDs.contains(resourceID), + let hostVisibleMemory, + var blob = blobResources[resourceID], + blob.workerMapping != nil, + blob.guestMapped else { return false } + hostVisibleMemory.unmap(resourceID: resourceID) + blob.guestMapped = false + blob.workerMapping = nil + blobResources[resourceID] = blob + traceResourceEvent( + "worker-unmap-blob-local-teardown", + resourceID: resourceID, + detail: "worker-generation=\(resourceGeneration)" + ) + return true + } + } + + private func finishRendererWorkerUnmapBlob( + _ result: Result, + admission: WorkerUnmapBlobAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch result { + case .success: + let committed = commandLock.withLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + let blob = blobResources[admission.resourceID], + blob.workerMapping == nil, + !blob.guestMapped, + rendererWorkerPendingMappingResourceIDs + .remove(admission.resourceID) != nil else { return false } + traceResourceEvent( + "worker-unmap-blob", + resourceID: admission.resourceID, + detail: "worker-generation=\(admission.resourceGeneration)" + ) + return true + } + guard committed else { + recordTelemetry(.revokedCompletion) + return + } + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .failure(let error) where error.provesNoRendererMutation: + // A pre-teardown rejection is recoverable. Once local authority was removed, even a + // proven worker rejection leaves a cross-process lifetime split and must wait for reset. + let localMappingIsIntact = commandLock.withLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[admission.resourceID] + == admission.resourceGeneration, + let blob = blobResources[admission.resourceID], + blob.workerMapping != nil, + blob.guestMapped else { return false } + return rendererWorkerPendingMappingResourceIDs + .remove(admission.resourceID) != nil + } + if localMappingIsIntact { + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + return + } + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + case .failure: + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + } + + /// Acquires one descriptor-backed scanout lease after the qualified guest's producer-complete + /// RESOURCE_FLUSH. Metal import and command-buffer submission remain asynchronous and off the + /// vCPU, but the guest chain is not acknowledged until every target has accepted that command + /// buffer. This prevents a following modeset from overtaking the frame's host ownership. + private func startRendererWorkerFlushScanout( + _ admission: WorkerFlushScanoutAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + let generation = claim.generation + guard let rendererWorkerCandidate else { + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "missing-candidate" + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let remainsCurrent = commandLock.withLock { + rendererWorkerResourceGenerations[admission.resourceID] + == admission.workerResourceGeneration + && resourceGenerations[admission.resourceID] + == admission.displayResourceGeneration + } + guard remainsCurrent else { + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "stale-resource-identity" + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + let pendingFence = admission.fence.flatMap { + reserveRendererWorkerFence( + $0, + response: responseHeader(type: Response.okNoData, request: admission.request), + chain: chain, + generation: generation, + transport: transport + ) + } + if admission.fence != nil, pendingFence == nil { + recordTelemetry(.fenceAdmissionRejection) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + guard !admission.targets.isEmpty else { + if let fence = admission.fence, let pendingFence { + // A flush of a worker resource that is not currently scanned out has no host + // presentation stage. Its global fence is the complete ordered boundary. + startRendererWorkerGlobalFenceAfterMutation( + fence, + pending: pendingFence, + claim: claim, + generation: generation, + transport: transport + ) + return nil + } + return publishCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + queue: transport.queues[0] + ) + } + guard let surface = admission.surface else { + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "missing-surface" + ) + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + guard onMetalScanout != nil else { + // The accelerated candidate is never allowed to silently fall back through the legacy + // CGL/OpenGL presentation callback. Until the Metal consumer is connected, fail closed. + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "missing-metal-consumer" + ) + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + + do { + logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: "acquire-submitting" + ) + try rendererWorkerCandidate.acquireScanoutLease( + resourceID: admission.resourceID, + resourceGeneration: admission.workerResourceGeneration, + width: surface.width, + height: surface.height, + virglFormat: surface.format, + stride: surface.stride, + storageOffset: surface.offset, + deviceGeneration: generation + ) { [weak self, weak transport] disposition in + guard let self, let transport else { + if case .acquired(let scanout) = disposition { + scanout.discardTransport() + } + return + } + let dispositionStage = switch disposition { + case .acquired: "acquire-callback-acquired" + case .provenRejected: "acquire-callback-proven-rejected" + case .outcomeUnknown: "acquire-callback-outcome-unknown" + } + self.logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: dispositionStage + ) + self.finishRendererWorkerFlushScanout( + disposition, + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: "acquire-enqueued" + ) + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "lane-admission", + detail: String(describing: error) + ) + if error.provesNoRendererMutation { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + queue: transport.queues[0] + ) + } + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } catch { + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "lane-admission-unexpected", + detail: String(describing: error) + ) + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + } + return nil + } + + private func finishRendererWorkerFlushScanout( + _ disposition: DoryRendererWorkerScanoutDisposition, + admission: WorkerFlushScanoutAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + switch disposition { + case .provenRejected(let error): + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "worker-proven-rejection", + detail: String(describing: error) + ) + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + case .outcomeUnknown(let error): + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "worker-outcome-unknown", + detail: String(describing: error) + ) + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + case .acquired(let scanout): + logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: "lease-acquired" + ) + let expectedPixelFormat: DoryRendererScanoutPixelFormat? = switch admission.surface?.format { + case 1: .bgra8Unorm + case 67: .rgba8Unorm + default: nil + } + guard let surface = admission.surface, + let expectedPixelFormat, + scanout.width == surface.width, + scanout.height == surface.height, + scanout.pixelFormat == expectedPixelFormat, + Self.rendererWorkerScanoutTransportMatches( + scanout, + surface: surface + ) else { + let expected = admission.surface.map { + "\($0.width)x\($0.height)/\($0.format)/\($0.stride)/\($0.offset)" + } ?? "missing" + let actual = Self.rendererWorkerScanoutDescription(scanout) + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "lease-layout-mismatch", + detail: "expected=\(expected) actual=\(actual)" + ) + scanout.discardTransport() + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + quarantineRendererWorkerGeneration( + generation, + error: .unexpectedWorkerReply + ) + return + } + let core: DoryRendererWorkerSharedScanoutCore + do { + core = try DoryRendererWorkerSharedScanoutCore( + scanout: scanout, + consumerCount: admission.targets.count, + release: { [weak self] scanout in + self?.releaseRendererWorkerScanoutLease( + scanout, + generation: generation, + attempt: 0 + ) + }, + terminal: { [weak self] token in + self?.removeRendererWorkerScanout(token) + } + ) + } catch { + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + quarantineRendererWorkerGeneration( + generation, + error: .unexpectedWorkerReply + ) + return + } + let registered = rendererWorkerPresentationLock.withLock { () -> Bool in + let token = scanout.releaseToken + guard rendererWorkerPendingScanouts[token] == nil else { return false } + if let live = rendererWorkerLiveScanouts[token] { + guard live.value == nil else { return false } + rendererWorkerLiveScanouts.removeValue(forKey: token) + } + rendererWorkerPendingScanouts[token] = core + return true + } + guard registered else { + core.revoke() + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: true + ) + if pendingFence == nil { + retainUnknownRendererWorkerChain(chain, generation: generation) + } + quarantineRendererWorkerGeneration( + generation, + error: .unexpectedWorkerReply + ) + return + } + logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: "lease-registered" + ) + rendererWorkerPresentationQueue.async { [weak self] in + self?.logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: "presentation-queue-entered" + ) + self?.publishRendererWorkerScanout( + core, + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + } + } + + private func publishRendererWorkerScanout( + _ core: DoryRendererWorkerSharedScanoutCore, + admission: WorkerFlushScanoutAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: "publication-begin" + ) + let updates = commandLock.withLock { () -> [VirtioGPUMetalScanoutUpdate]? in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration, + rendererWorkerResourceGenerations[admission.resourceID] + == admission.workerResourceGeneration, + resourceGenerations[admission.resourceID] + == admission.displayResourceGeneration, + admission.targets.allSatisfy({ target in + guard let binding = scanouts[target.scanoutID], + binding.resourceID == admission.resourceID, + binding.rect == target.sourceRect, + admission.surface == rendererWorkerScanoutSurface( + for: binding + ) else { return false } + return true + }) else { return nil } + + var presentations = [VirtioGPUMetalScanoutPresentation]() + presentations.reserveCapacity(admission.targets.count) + for index in admission.targets.indices { + guard let presentation = core.makePresentation(consumerID: UInt32(index)) else { + return nil + } + presentations.append(presentation) + } + + let movedToLive = rendererWorkerPresentationLock.withLock { () -> Bool in + let token = core.releaseToken + guard rendererWorkerPendingScanouts[token] === core else { return false } + rendererWorkerPendingScanouts.removeValue(forKey: token) + rendererWorkerLiveScanouts[token] = DoryRendererWorkerWeakScanoutCore(core) + return true + } + guard movedToLive else { return nil } + + let submissionGroup = DoryRendererWorkerHostSubmissionGroup( + count: admission.targets.count + ) { [weak self, weak transport] accepted in + guard let self, let transport else { return } + self.finishRendererWorkerHostSubmission( + accepted: accepted, + core: core, + admission: admission, + chain: chain, + claim: claim, + pendingFence: pendingFence, + transport: transport + ) + } + var updates = [VirtioGPUMetalScanoutUpdate]() + updates.reserveCapacity(admission.targets.count) + for (index, target) in admission.targets.enumerated() { + updates.append(VirtioGPUMetalScanoutUpdate( + scanoutID: target.scanoutID, + resourceID: admission.resourceID, + resourceGeneration: admission.displayResourceGeneration, + rendererResourceGeneration: admission.workerResourceGeneration, + presentation: presentations[index], + sourceRect: target.sourceRect, + dirtyRect: target.dirtyRect, + hostSubmission: VirtioGPUMetalScanoutHostSubmission { accepted in + submissionGroup.resolve(accepted: accepted) + } + )) + } + return updates + } + guard let updates, let onMetalScanout else { + let current = fenceLock.withLock { lifecycleEpoch == generation } + if current { + core.retireWithoutPresentation() + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "host-publication-rejected" + ) + _ = publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + } else { + core.revoke() + } + return + } + // The command claim serializes a following SET_SCANOUT until host submission resolves. + // Invoke the external mailbox only after the state snapshot and pending→live transition + // have released commandLock; a synchronous accept/reject may reenter queue completion. + for update in updates { + logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: "mailbox-submitting" + ) + onMetalScanout(update) + } + } + + private func finishRendererWorkerHostSubmission( + accepted: Bool, + core: DoryRendererWorkerSharedScanoutCore, + admission: WorkerFlushScanoutAdmission, + chain: VirtqueueChain, + claim: RendererWorkerControlCommandClaim, + pendingFence: PendingFence?, + transport: VirtioMMIOTransport + ) { + let generation = claim.generation + logRendererWorkerScanoutProgress( + resourceID: admission.resourceID, + stage: accepted ? "host-submission-accepted" : "host-submission-rejected" + ) + guard accepted else { + let current = fenceLock.withLock { lifecycleEpoch == generation } + if current { + core.retireWithoutPresentation() + abandonRendererWorkerMutationFence( + admission.fence, + pending: pendingFence, + outcomeUnknown: false + ) + logRendererWorkerScanoutFailure( + resourceID: admission.resourceID, + stage: "metal-command-buffer-rejected" + ) + _ = publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.request + ), + generation: generation, + controlClaim: claim, + transport: transport + ) + } else { + core.revoke() + } + return + } + + if let fence = admission.fence { + guard let pendingFence else { + core.retireWithoutPresentation() + retainUnknownRendererWorkerChain(chain, generation: generation) + quarantineRendererWorkerGeneration( + generation, + error: .unexpectedWorkerReply + ) + return + } + // RESOURCE_FLUSH is complete only once the Metal consumer has committed its host + // submission. Export the renderer's global fence strictly after that acceptance; the + // pending guest response remains owned until the descriptor signals. + startRendererWorkerGlobalFenceAfterMutation( + fence, + pending: pendingFence, + claim: claim, + generation: generation, + transport: transport + ) + return + } + + let completed = publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: admission.request), + generation: generation, + controlClaim: claim, + transport: transport + ) + guard !completed else { return } + let current = fenceLock.withLock { lifecycleEpoch == generation } + if current { + core.retireWithoutPresentation() + } else { + core.revoke() + } + } + + private func releaseRendererWorkerScanoutLease( + _ scanout: DoryRendererWorkerScanoutAuthority, + generation: UInt64, + attempt: Int + ) { + guard fenceLock.withLock({ lifecycleEpoch == generation }), + let rendererWorkerCandidate else { return } + do { + let completion: DoryRendererWorkerVirtioCommandLane.Completion = { [weak self] result in + guard let self else { return } + switch result { + case .success: + break + case .failure(let error) + where error.provesNoRendererMutation && attempt == 0: + self.rendererWorkerPresentationQueue.async { [weak self] in + self?.releaseRendererWorkerScanoutLease( + scanout, + generation: generation, + attempt: 1 + ) + } + case .failure(let error): + self.quarantineRendererWorkerGeneration(generation, error: error) + } + } + switch scanout { + case .sharedMemory(let value): + try rendererWorkerCandidate.releaseScanoutLease( + value.lease, + deviceGeneration: generation, + completion: completion + ) + case .sharedTexture(let value): + try rendererWorkerCandidate.releaseScanoutLease( + value.lease, + deviceGeneration: generation, + completion: completion + ) + } + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + if error.provesNoRendererMutation && attempt == 0 { + rendererWorkerPresentationQueue.async { [weak self] in + self?.releaseRendererWorkerScanoutLease( + scanout, + generation: generation, + attempt: 1 + ) + } + } else { + quarantineRendererWorkerGeneration(generation, error: error) + } + } catch { + quarantineRendererWorkerGeneration(generation, error: .unexpectedWorkerReply) + } + } + + private func removeRendererWorkerScanout( + _ token: DoryRendererScanoutReleaseToken + ) { + rendererWorkerPresentationLock.withLock { + rendererWorkerPendingScanouts.removeValue(forKey: token) + rendererWorkerLiveScanouts.removeValue(forKey: token) + } + } + + private func revokeRendererWorkerScanouts() { + let cores = rendererWorkerPresentationLock.withLock { () -> [ + DoryRendererWorkerSharedScanoutCore + ] in + var unique = [ObjectIdentifier: DoryRendererWorkerSharedScanoutCore]() + for core in rendererWorkerPendingScanouts.values { + unique[ObjectIdentifier(core)] = core + } + for weakCore in rendererWorkerLiveScanouts.values { + if let core = weakCore.value { unique[ObjectIdentifier(core)] = core } + } + rendererWorkerPendingScanouts.removeAll(keepingCapacity: false) + rendererWorkerLiveScanouts.removeAll(keepingCapacity: false) + return Array(unique.values) + } + for core in cores { core.revoke() } + } + + private func retainUnknownRendererWorkerChain( + _ chain: VirtqueueChain, + generation: UInt64 + ) { + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + + private func quarantineRendererWorkerGeneration( + _ generation: UInt64, + error: DoryRendererWorkerVirtioCommandLaneError + ) { + guard fenceLock.withLock({ lifecycleEpoch == generation }) else { return } + rendererWorkerCandidate?.revoke(deviceGeneration: generation) + revokeRendererWorkerScanouts() + rendererWorkerCandidateFailed(generation: generation, error: error) + } + + /// Transfers a previously snapshotted submit authority to the signed-worker lane and returns + /// immediately after bounded local admission. A nil result means the exact descriptor chain + /// is now owned by an asynchronous submit/fence completion path. + private func startRendererWorkerSubmit( + _ admission: WorkerSubmitAdmission, + chain: VirtqueueChain, + generation: UInt64, + transport: VirtioMMIOTransport + ) -> QueueCompletionOutcome? { + defer { + for descriptor in admission.regions.descriptors { try? descriptor.close() } + } + guard let rendererWorkerCandidate else { + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.requestHeader + ), + queue: transport.queues[0] + ) + } + let successResponse = responseHeader( + type: Response.okNoData, + request: admission.requestHeader + ) + + if let fence = admission.fence { + guard let pending = reserveRendererWorkerFence( + fence, + response: successResponse, + chain: chain, + generation: generation, + transport: transport + ) else { + recordTelemetry(.fenceAdmissionRejection) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.requestHeader + ), + queue: transport.queues[0] + ) + } + do { + try rendererWorkerCandidate.submit3DThenCreateFence( + contextID: fence.contextID, + regions: admission.regions, + ringIndex: fence.ringIndex, + fenceID: fence.fenceID, + contextFence: fence.contextFence, + deviceGeneration: generation + ) { [weak self, weak transport] disposition in + guard let self, let transport else { return } + self.finishRendererWorkerFencedSubmit( + disposition, + fence: fence, + pending: pending, + requestHeader: admission.requestHeader, + transport: transport + ) + } + } catch { + _ = removeRendererWorkerFence( + fence, + token: pending.token, + makeUncertain: false + ) + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.requestHeader + ), + queue: transport.queues[0] + ) + } + return nil + } + do { - try renderer.createFence(contextID: contextID, ringIndex: ringIndex, fenceID: fenceID, contextFence: contextFence) + try rendererWorkerCandidate.submit3D( + contextID: admission.requestHeader.leUInt32(at: 16), + regions: admission.regions, + deviceGeneration: generation + ) { [weak self, weak transport] result in + guard let self, let transport else { return } + self.finishRendererWorkerUnfencedSubmit( + result, + chain: chain, + generation: generation, + requestHeader: admission.requestHeader, + transport: transport + ) + } } catch { - return false + return publishCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: admission.requestHeader + ), + queue: transport.queues[0] + ) + } + return nil + } + + private func reserveRendererWorkerFence( + _ fence: FenceRequest, + response: [UInt8], + chain: VirtqueueChain, + generation: UInt64, + transport: VirtioMMIOTransport + ) -> PendingFence? { + return fenceLock.withLock { + let fenceIDIsUnique = !pendingFences.values.joined().contains { + $0.fenceID == fence.fenceID && $0.epoch == generation + } && !uncertainFences.contains { + $0.fenceID == fence.fenceID && $0.epoch == generation + } + guard lifecycleEpoch == generation, + (!fence.contextFence || fence.contextID != 0), + fence.contextFence || ( + fence.key == FenceKey(contextID: 0, ringIndex: 0) + && fence.ringIndex == 0 + ), + fence.fenceID != 0, + fenceIDIsUnique, + !fenceAdmissionBlockedUntilDeviceReset, + pendingFenceCount < maximumPendingFences, + response.count <= maximumPendingFenceResponseBytes, + pendingFenceResponseBytes + <= maximumPendingFenceResponseBytes - response.count, + lastTransport == nil || lastTransport === transport else { + return nil + } + let token = nextFenceToken + nextFenceToken &+= 1 + if nextFenceToken == 0 { nextFenceToken = 1 } + let pending = PendingFence( + token: token, + fenceID: fence.fenceID, + epoch: generation, + response: response, + chain: chain, + createdAtMonotonicNanoseconds: DispatchTime.now().uptimeNanoseconds, + timeoutReported: false + ) + pendingFences[fence.key, default: []].append(pending) + pendingFenceCount += 1 + pendingFenceResponseBytes += response.count + lastTransport = transport + return pending + } + } + + /// Completes the second stage of an ordinary fenced control command. The mutation has already + /// been authenticated by the worker; only an exported global fence may release the guest + /// response. Any failure from this point is therefore outcome-unknown for the compound command. + private func startRendererWorkerGlobalFenceAfterMutation( + _ fence: FenceRequest, + pending: PendingFence, + claim: RendererWorkerControlCommandClaim, + generation: UInt64, + transport: VirtioMMIOTransport + ) { + guard let rendererWorkerCandidate else { + _ = removeRendererWorkerFence(fence, token: pending.token, makeUncertain: true) + return + } + let completion: DoryRendererWorkerVirtioCommandLane.Completion = { + [weak self, weak transport] result in + guard let self, let transport else { return } + switch result { + case .success: + self.fenceLock.withLock { + self.fenceCount = Self.saturatingAdd(self.fenceCount, 1) + } + let released = transport.withQueueLock { + self.fenceLock.withLock { self.lifecycleEpoch == generation } + && self.completeRendererWorkerControlCommand(claim: claim) + } + if released { self.scheduleRendererWorkerQueueResume(transport) } + case .failure(let error): + _ = self.removeRendererWorkerFence( + fence, + token: pending.token, + makeUncertain: true + ) + self.quarantineRendererWorkerGeneration(generation, error: error) + } + } + do { + try rendererWorkerCandidate.createGlobalFence( + fenceID: fence.fenceID, + deviceGeneration: generation, + completion: completion + ) + } catch let error as DoryRendererWorkerVirtioCommandLaneError { + _ = removeRendererWorkerFence(fence, token: pending.token, makeUncertain: true) + quarantineRendererWorkerGeneration(generation, error: error) + } catch { + _ = removeRendererWorkerFence(fence, token: pending.token, makeUncertain: true) + quarantineRendererWorkerGeneration(generation, error: .unexpectedWorkerReply) + } + } + + private func abandonRendererWorkerMutationFence( + _ fence: FenceRequest?, + pending: PendingFence?, + outcomeUnknown: Bool + ) { + guard let fence, let pending else { return } + _ = removeRendererWorkerFence( + fence, + token: pending.token, + makeUncertain: outcomeUnknown + ) + } + + @discardableResult + private func removeRendererWorkerFence( + _ fence: FenceRequest, + token: UInt64, + makeUncertain: Bool + ) -> PendingFence? { + fenceLock.withLock { + guard var waiting = pendingFences[fence.key], + let index = waiting.firstIndex(where: { $0.token == token }) else { + return nil + } + let removed = waiting.remove(at: index) + pendingFences[fence.key] = waiting.isEmpty ? nil : waiting + if makeUncertain { + uncertainFences.append(removed) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } else { + pendingFenceCount = max(0, pendingFenceCount - 1) + pendingFenceResponseBytes = max( + 0, + pendingFenceResponseBytes - removed.response.count + ) + if pendingFenceCount == 0 { lastTransport = nil } + } + return removed + } + } + + private func finishRendererWorkerFencedSubmit( + _ disposition: DoryRendererWorkerVirtioSubmissionDisposition, + fence: FenceRequest, + pending: PendingFence, + requestHeader: [UInt8], + transport: VirtioMMIOTransport + ) { + switch disposition { + case .fenceArmed: + // The pending chain remains owned until the completion descriptor becomes readable. + fenceLock.withLock { + fenceCount = Self.saturatingAdd(fenceCount, 1) + } + scheduleRendererWorkerQueueResume(transport) + return + case .provenRejected: + guard removeRendererWorkerFence( + fence, + token: pending.token, + makeUncertain: false + ) != nil else { return } + publishRendererWorkerCompletion( + chain: pending.chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: requestHeader + ), + generation: pending.epoch, + transport: transport + ) + case .outcomeUnknown: + _ = removeRendererWorkerFence( + fence, + token: pending.token, + makeUncertain: true + ) + } + } + + private func finishRendererWorkerUnfencedSubmit( + _ result: Result, + chain: VirtqueueChain, + generation: UInt64, + requestHeader: [UInt8], + transport: VirtioMMIOTransport + ) { + switch result { + case .success: + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader(type: Response.okNoData, request: requestHeader), + generation: generation, + transport: transport + ) + case .failure(let error) where error.provesNoRendererMutation: + publishRendererWorkerCompletion( + chain: chain, + response: responseHeader( + type: Response.errorInvalidParameter, + request: requestHeader + ), + generation: generation, + transport: transport + ) + case .failure: + fenceLock.withLock { + guard lifecycleEpoch == generation else { return } + uncertainRendererCommandChains.append(chain) + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked(.rendererCommandUncertainty) + } + } + } + + @discardableResult + private func publishRendererWorkerCompletion( + chain: VirtqueueChain, + response: [UInt8], + generation: UInt64, + controlClaim: RendererWorkerControlCommandClaim? = nil, + transport: VirtioMMIOTransport + ) -> Bool { + let shouldResume = transport.withQueueLock { () -> Bool in + let isCurrentGeneration = fenceLock.withLock { + lifecycleEpoch == generation + } + guard isCurrentGeneration else { + recordTelemetry(.revokedCompletion) + return false + } + switch publishCompletion( + chain: chain, + response: response, + queue: transport.queues[0] + ) { + case .published(let wantsInterrupt): + if let controlClaim { + // The queue lock excludes a new kick between used publication and release. + // Token equality excludes completions from older pipelined work in this epoch. + completeRendererWorkerControlCommand(claim: controlClaim) + } + if wantsInterrupt { transport.notifyUsed() } + return true + case .revoked: + return false + case .failed: + recordTelemetry(.undeliveredFenceCompletion) + return false + } + } + // A stale completion must not kick a replacement queue generation. Only a completion that + // published its used entry may continue draining descriptors already available behind it. + if shouldResume { + scheduleRendererWorkerQueueResume(transport) + } + return shouldResume + } + + private func scheduleRendererWorkerQueueResume(_ transport: VirtioMMIOTransport) { + rendererWorkerResumeQueue.async { [weak self, weak transport] in + guard let self, let transport else { return } + transport.withQueueLock { + self.handleKick(queue: 0, transport: transport) + } + } + } + + private func rendererWorkerCandidateFailed( + generation: UInt64, + error: DoryRendererWorkerVirtioCommandLaneError + ) { + let affected = fenceLock.withLock { () -> Int in + guard lifecycleEpoch == generation else { return 0 } + var count = 0 + for key in Array(pendingFences.keys) { + guard var waiting = pendingFences[key] else { continue } + let uncertain = waiting.filter { $0.epoch == generation } + waiting.removeAll { $0.epoch == generation } + pendingFences[key] = waiting.isEmpty ? nil : waiting + uncertainFences.append(contentsOf: uncertain) + count += uncertain.count + } + fenceAdmissionBlockedUntilDeviceReset = true + recordTelemetryWhileLocked( + .rendererCommandUncertainty, + count: UInt64(max(1, count)) + ) + return count + } + failRendererLifecycle( + .commandOutcomeUnknown( + operation: "renderer-worker", + detail: String(describing: error) + ), + epoch: generation + ) + FileHandle.standardError.write(Data(( + "dory-gpu: renderer worker generation \(generation) failed; " + + "retained \(affected) uncertain fenced chains: \(error)\n" + ).utf8)) + onRendererWorkerFailure?( + "generation \(generation) failed: \(String(describing: error))" + ) + } + + public var statistics: VirtioGPUStatistics { + let worker = rendererWorkerCandidate?.snapshot() + let snapshots = rendererWorkerMetricsLock.withLock { + rendererWorkerSnapshotMetrics + } + let softwareCopies = softwareScanoutMetricsLock.withLock { + softwareScanoutCopiedBytes + } + return fenceLock.withLock { + let now = DispatchTime.now().uptimeNanoseconds + var newlyTimedOut: UInt64 = 0 + var hasTimedOutPendingFence = false + for key in Array(pendingFences.keys) { + guard var waiting = pendingFences[key] else { continue } + for index in waiting.indices { + if !waiting[index].timeoutReported { + let started = waiting[index].createdAtMonotonicNanoseconds + let age = now >= started ? now - started : 0 + if age >= fenceTimeoutNanoseconds { + waiting[index].timeoutReported = true + newlyTimedOut = Self.saturatingAdd(newlyTimedOut, 1) + } + } + hasTimedOutPendingFence = + hasTimedOutPendingFence || waiting[index].timeoutReported + } + pendingFences[key] = waiting + } + for index in uncertainFences.indices { + if !uncertainFences[index].timeoutReported { + let started = uncertainFences[index].createdAtMonotonicNanoseconds + let age = now >= started ? now - started : 0 + if age >= fenceTimeoutNanoseconds { + uncertainFences[index].timeoutReported = true + newlyTimedOut = Self.saturatingAdd(newlyTimedOut, 1) + } + } + hasTimedOutPendingFence = hasTimedOutPendingFence + || uncertainFences[index].timeoutReported + } + fenceTimeoutCount = Self.saturatingAdd(fenceTimeoutCount, newlyTimedOut) + return VirtioGPUStatistics( + fences: fenceCount, + fenceRegistrationFailures: fenceRegistrationFailureCount, + fenceTimeouts: fenceTimeoutCount, + hasTimedOutPendingFence: hasTimedOutPendingFence, + rendererDeviceLosses: rendererDeviceLossCount, + hasLostRendererDevice: rendererDeviceLossLatched, + queuePendingReadFailures: queuePendingReadFailureCount, + queuePopFailures: queuePopFailureCount, + invalidDescriptorChains: invalidDescriptorChainCount, + oversizedRequests: oversizedRequestCount, + insufficientResponseCapacity: insufficientResponseCapacityCount, + queuePushFailures: queuePushFailureCount, + revokedCompletions: revokedCompletionCount, + undeliveredFenceCompletions: undeliveredFenceCompletionCount, + responseWriteFailures: responseWriteFailureCount, + fenceAdmissionRejections: fenceAdmissionRejectionCount, + queueRevokedFences: queueRevokedFenceCount, + resetRevokedFences: resetRevokedFenceCount, + rendererCommandUncertainties: rendererCommandUncertaintyCount, + revokedUncertainRendererCommands: revokedUncertainRendererCommandCount, + rendererWorkerSnapshotCount: snapshots.count, + rendererWorkerSnapshotBytes: snapshots.bytes, + rendererWorkerSnapshotNanoseconds: snapshots.nanoseconds, + rendererWorkerMaximumSnapshotNanoseconds: snapshots.maximumNanoseconds, + rendererWorkerQueuedCommands: worker?.queuedCommands ?? 0, + rendererWorkerMaximumQueuedCommands: + worker?.maximumObservedQueuedCommands ?? 0, + rendererWorkerRejectedAdmissions: worker?.rejectedAdmissions ?? 0, + rendererWorkerCompletedControlCommands: + worker?.completedControlCommands ?? 0, + rendererWorkerCompletedResourceCommands: + worker?.completedResourceCommands ?? 0, + rendererWorkerCompletedSubmissions: worker?.completedSubmissions ?? 0, + rendererWorkerArmedFences: worker?.armedFences ?? 0, + rendererWorkerCompletedFences: worker?.completedFences ?? 0, + // The accelerated presentation contract has no copied-frame operation. + rendererWorkerScanoutCopyBytes: 0, + softwareScanoutCopiedBytes: softwareCopies + ) + } + } + + private static func saturatingAdd(_ value: UInt64, _ increment: UInt64) -> UInt64 { + let (sum, overflow) = value.addingReportingOverflow(increment) + return overflow ? UInt64.max : sum + } + + private func recordAdmissionRejection(_ rejection: QueueAdmissionRejection) { + switch rejection { + case .invalidDescriptorLayout: + recordTelemetry(.invalidDescriptorChain) + case .oversizedRequest: + recordTelemetry(.oversizedRequest) + case .insufficientResponseCapacity: + recordTelemetry(.insufficientResponseCapacity) + } + } + + private func recordTelemetry(_ event: TelemetryEvent, count: UInt64 = 1) { + fenceLock.withLock { recordTelemetryWhileLocked(event, count: count) } + } + + private func recordTelemetryWhileLocked( + _ event: TelemetryEvent, + count: UInt64 = 1 + ) { + switch event { + case .queuePendingReadFailure: + queuePendingReadFailureCount = Self.saturatingAdd( + queuePendingReadFailureCount, + count + ) + case .queuePopFailure: + queuePopFailureCount = Self.saturatingAdd(queuePopFailureCount, count) + case .invalidDescriptorChain: + invalidDescriptorChainCount = Self.saturatingAdd( + invalidDescriptorChainCount, + count + ) + case .oversizedRequest: + oversizedRequestCount = Self.saturatingAdd(oversizedRequestCount, count) + case .insufficientResponseCapacity: + insufficientResponseCapacityCount = Self.saturatingAdd( + insufficientResponseCapacityCount, + count + ) + case .queuePushFailure: + queuePushFailureCount = Self.saturatingAdd(queuePushFailureCount, count) + case .revokedCompletion: + revokedCompletionCount = Self.saturatingAdd(revokedCompletionCount, count) + case .undeliveredFenceCompletion: + undeliveredFenceCompletionCount = Self.saturatingAdd( + undeliveredFenceCompletionCount, + count + ) + case .responseWriteFailure: + responseWriteFailureCount = Self.saturatingAdd( + responseWriteFailureCount, + count + ) + case .fenceAdmissionRejection: + fenceAdmissionRejectionCount = Self.saturatingAdd( + fenceAdmissionRejectionCount, + count + ) + case .queueRevokedFence: + queueRevokedFenceCount = Self.saturatingAdd(queueRevokedFenceCount, count) + case .resetRevokedFence: + resetRevokedFenceCount = Self.saturatingAdd(resetRevokedFenceCount, count) + case .rendererCommandUncertainty: + rendererCommandUncertaintyCount = Self.saturatingAdd( + rendererCommandUncertaintyCount, + count + ) + case .revokedUncertainRendererCommand: + revokedUncertainRendererCommandCount = Self.saturatingAdd( + revokedUncertainRendererCommandCount, + count + ) + } + } + + private func publishCompletion( + chain: VirtqueueChain, + response: [UInt8]?, + queue: Virtqueue + ) -> QueueCompletionOutcome { + if let response { + let wroteResponse = chain.withLeaseHeld { access -> Bool in + guard access.writableByteCount >= response.count else { return false } + return access.writeBytes(response) == response.count + } + guard let wroteResponse else { + recordTelemetry(.revokedCompletion) + return .revoked + } + guard wroteResponse else { + recordTelemetry(.responseWriteFailure) + return .failed + } + } + do { + switch try queue.pushOutcome(chain, written: response?.count ?? 0) { + case .published(let wantsInterrupt): + return .published(wantsInterrupt: wantsInterrupt) + case .revoked: + recordTelemetry(.revokedCompletion) + return .revoked + } + } catch { + recordTelemetry(.queuePushFailure) + return .failed } - // ctx0 fences signal without context/ring coordinates, so they queue under (0, 0). - let key = contextFence - ? FenceKey(contextID: contextID, ringIndex: ringIndex) - : FenceKey(contextID: 0, ringIndex: 0) - fenceLock.lock() - pendingFences[key, default: []].append(PendingFence(fenceID: fenceID, response: response, chain: chain)) - fenceLock.unlock() - return true } - /// Renderer-thread entry: completes every pending descriptor on the signaled timeline whose - /// fence id is covered (fences signal in creation order within a ring). - private func fenceSignaled(contextID: UInt32, ringIndex: UInt32, fenceID: UInt64) { + /// Renderer-thread entry: completes the signaled fence and every earlier admission on its + /// timeline. Registration order is the authority: guest 64-bit ids may wrap or be + /// non-monotonic, and the global renderer callback uses an unrelated 32-bit host token. + private func fenceSignaled( + generation: UInt64, + contextID: UInt32, + ringIndex: UInt32, + fenceID: UInt64 + ) { let key = FenceKey(contextID: contextID, ringIndex: ringIndex) fenceLock.lock() var completed = [PendingFence]() if var waiting = pendingFences[key] { - // The legacy ctx0 API carries 32-bit ids on the callback; compare within that width. - let signaled: (PendingFence) -> Bool = key == FenceKey(contextID: 0, ringIndex: 0) - ? { UInt32(truncatingIfNeeded: $0.fenceID) <= UInt32(truncatingIfNeeded: fenceID) } - : { $0.fenceID <= fenceID } - completed = waiting.filter(signaled) - waiting.removeAll(where: signaled) + if let target = waiting.firstIndex(where: { + $0.epoch == generation && $0.fenceID == fenceID + }) { + var survivors = [PendingFence]() + survivors.reserveCapacity(waiting.count) + for (index, pending) in waiting.enumerated() { + if index <= target && pending.epoch == generation { + completed.append(pending) + } else { + survivors.append(pending) + } + } + waiting = survivors + } pendingFences[key] = waiting.isEmpty ? nil : waiting } let transport = lastTransport + pendingFenceCount = max(0, pendingFenceCount - completed.count) + let completedResponseBytes = completed.reduce(into: 0) { total, pending in + total += pending.response.count + } + pendingFenceResponseBytes = max( + 0, + pendingFenceResponseBytes - completedResponseBytes + ) + if transport == nil, !completed.isEmpty { + recordTelemetryWhileLocked( + .revokedCompletion, + count: UInt64(completed.count) + ) + } fenceLock.unlock() guard !completed.isEmpty, let transport else { return } transport.withQueueLock { + // Reset is serialized by this same transport lock. Recheck the lifecycle only after + // acquiring it: a callback may have removed its waiter, then blocked here while reset + // cleared and reconfigured the queue. + let lifecycle = fenceLock.withLock { (lifecycleEpoch, lastTransport === transport) } + guard lifecycle.1 else { + recordTelemetry(.revokedCompletion, count: UInt64(completed.count)) + return + } + let staleCount = completed.lazy.filter { $0.epoch != lifecycle.0 }.count + if staleCount > 0 { + recordTelemetry(.revokedCompletion, count: UInt64(staleCount)) + } + completed.removeAll { $0.epoch != lifecycle.0 } + guard !completed.isEmpty else { return } var interrupt = false - for pending in completed { - let written = pending.chain.writeBytes(pending.response) - let wants = (try? transport.queues[0].push(pending.chain, written: written)) ?? false - interrupt = interrupt || wants + completionLoop: for (index, pending) in completed.enumerated() { + switch publishCompletion( + chain: pending.chain, + response: pending.response, + queue: transport.queues[0] + ) { + case .published(let wants): + interrupt = interrupt || wants + case .revoked: + let remaining = completed.count - index - 1 + if remaining > 0 { + recordTelemetry(.revokedCompletion, count: UInt64(remaining)) + } + break completionLoop + case .failed: + recordTelemetry( + .undeliveredFenceCompletion, + count: UInt64(completed.count - index) + ) + break completionLoop + } + } + if interrupt { + transport.notifyUsed() + } + } + fenceLock.withLock { + if pendingFenceCount == 0, lastTransport === transport { + lastTransport = nil + } + } + } + + private func registerResourceGeneration(_ resourceID: UInt32) { + resourceGenerations[resourceID] = nextResourceGeneration + nextResourceGeneration &+= 1 + if nextResourceGeneration == 0 { nextResourceGeneration = 1 } + } + + public var rendererLifecycleHealth: VirtioGPURendererLifecycleHealth { + lifecycleLock.withLock { rendererLifecycleHealthState } + } + + private func isResourceRetiring(_ resourceID: UInt32) -> Bool { + lifecycleLock.withLock { retiringResources[resourceID] != nil } + } + + private func canAdmitResource(_ resourceID: UInt32) -> Bool { + guard resources2D[resourceID] == nil, + resources3D[resourceID] == nil, + blobResources[resourceID] == nil, + !rendererWorkerPendingResourceIDs.contains(resourceID) else { return false } + return lifecycleLock.withLock { + retiringResources[resourceID] == nil + && resources2D.count + resources3D.count + blobResources.count + + retiringResources.count < maximumTrackedResources + } + } + + private func beginResourceRetirement( + resourceID: UInt32, + generation: UInt64, + requiresBlobUnmap: Bool, + rendererGeneration: UInt64 + ) { + let inserted = lifecycleLock.withLock { () -> Bool in + guard retiringResources[resourceID] == nil else { return false } + retiringResources[resourceID] = generation + return true + } + guard inserted else { + FileHandle.standardError.write(Data( + "dory-gpu: duplicate renderer retirement resource=\(resourceID) generation=\(generation)\n".utf8 + )) + return + } + + publishResourceRelease( + resourceID: resourceID, + generation: generation + ) { [self] in + rendererRetirementQueue.async { [self] in + do { + if requiresBlobUnmap, rendererExecutor != nil { + _ = try executeRendererCommand( + .unmapBlob(resourceID: resourceID), + generation: rendererGeneration, + purpose: .retirement + ) + } + if rendererExecutor != nil { + _ = try executeRendererCommand( + .unrefResource(resourceID: resourceID), + generation: rendererGeneration, + purpose: .retirement + ) + } + lifecycleLock.withLock { + if retiringResources[resourceID] == generation { + retiringResources.removeValue(forKey: resourceID) + } + } + scheduleQuiescenceCleanupIfReady() + } catch { + let fault = VirtioGPURendererHealthFault.resourceRetirementFailed( + resourceID: resourceID, + generation: generation, + detail: String(describing: error) + ) + failRendererLifecycle(fault) + FileHandle.standardError.write(Data(( + "dory-gpu: renderer retirement failed resource=\(resourceID) " + + "generation=\(generation): \(error)\n" + ).utf8)) + } + } + } + } + + private func scheduleQuiescenceCleanupIfReady() { + let epoch: UInt64? = lifecycleLock.withLock { + guard var active = activeQuiescence, + !active.cleanupScheduled, + active.awaitingReleaseAcknowledgements.isEmpty, + active.priorRetirements.allSatisfy({ key in + retiringResources[key.resourceID] != key.generation + }) else { return nil } + active.cleanupScheduled = true + activeQuiescence = active + return active.receipt.epoch + } + guard let epoch else { return } + rendererRetirementQueue.async { [self] in + finishQuiescence(epoch: epoch) + } + } + + private func acknowledgeQuiescenceRelease( + _ key: ResourceRetirementKey, + epoch: UInt64 + ) { + lifecycleLock.withLock { + guard var active = activeQuiescence, active.receipt.epoch == epoch else { return } + active.awaitingReleaseAcknowledgements.remove(key) + activeQuiescence = active + } + scheduleQuiescenceCleanupIfReady() + } + + private func finishQuiescence(epoch: UInt64) { + guard let active = lifecycleLock.withLock({ + activeQuiescence?.receipt.epoch == epoch ? activeQuiescence : nil + }) else { return } + + var firstFault: VirtioGPURendererHealthFault? + for resource in active.rendererResources.sorted(by: { + ($0.key.resourceID, $0.key.generation) < ($1.key.resourceID, $1.key.generation) + }) { + do { + if resource.requiresBlobUnmap, rendererExecutor != nil { + _ = try executeRendererCommand( + .unmapBlob(resourceID: resource.key.resourceID), + generation: active.rendererGeneration, + purpose: .retirement + ) + } + if rendererExecutor != nil { + _ = try executeRendererCommand( + .unrefResource(resourceID: resource.key.resourceID), + generation: active.rendererGeneration, + purpose: .retirement + ) + } + lifecycleLock.withLock { + if retiringResources[resource.key.resourceID] == resource.key.generation { + retiringResources.removeValue(forKey: resource.key.resourceID) + } + } + } catch { + if firstFault == nil { + firstFault = .resourceRetirementFailed( + resourceID: resource.key.resourceID, + generation: resource.key.generation, + detail: String(describing: error) + ) + } + } + } + if let firstFault { + failRendererLifecycle(firstFault, epoch: epoch) + return + } + + if rendererExecutor != nil { + do { + let reset = try executeRendererCommand( + .resetAfterDeviceQuiesce(successorGeneration: epoch), + generation: active.rendererGeneration, + purpose: .retirement + ) + guard case .reset(let result) = reset else { + preconditionFailure("renderer reset returned an invalid executor payload") + } + if case .requiresRecreation(let detail) = result { + failRendererLifecycle(.resetRequiresRecreation(detail), epoch: epoch) + return + } + } catch let signal as RendererCommandOutcomeUnknownSignal { + failRendererLifecycle( + .resetFailed(signal.uncertainty.detail), + epoch: epoch + ) + return + } catch { + failRendererLifecycle(.resetFailed(String(describing: error)), epoch: epoch) + return + } + } + + let workerCannotResumeAfterReset = active.receipt.reason == .deviceReset + && rendererWorkerCandidate != nil + && !active.workerReboundForPristineDeviceReset + if active.receipt.reason == .deviceReset, !workerCannotResumeAfterReset { + fenceLock.withLock { + // resetAfterDeviceQuiesce() is the renderer-owned barrier that makes every old + // callback permanently unreachable. Only this boundary can safely reopen fenced + // admission after a QueueReady revocation or fence-registration uncertainty. + fenceAdmissionBlockedUntilDeviceReset = false + } + } + + let receipt: VirtioGPUQuiescence? = lifecycleLock.withLock { + guard activeQuiescence?.receipt.epoch == epoch else { return nil } + let receipt = activeQuiescence?.receipt + activeQuiescence = nil + let workerIsReady = active.receipt.reason == .deviceReset + && active.workerReboundForPristineDeviceReset + rendererLifecycleHealthState = rendererExecutor != nil || workerIsReady + ? .ready(epoch: epoch) + : .notConfigured + acceptingGuestCommands = active.receipt.reason == .deviceReset + && !workerCannotResumeAfterReset + return receipt + } + receipt?.complete(.completed) + if workerCannotResumeAfterReset { + onRendererWorkerFailure?( + "virtio-gpu device reset revoked the one-shot renderer generation" + ) + } + } + + private func failRendererLifecycle( + _ fault: VirtioGPURendererHealthFault, + epoch requestedEpoch: UInt64? = nil + ) { + let currentEpoch = fenceLock.withLock { lifecycleEpoch } + let epoch = requestedEpoch ?? currentEpoch + let receipt: VirtioGPUQuiescence? = lifecycleLock.withLock { + rendererLifecycleHealthState = !rendererAuthorityIsConfigured + ? .notConfigured + : .failed(epoch: epoch, fault: fault) + acceptingGuestCommands = false + guard activeQuiescence?.receipt.epoch == epoch else { return nil } + let receipt = activeQuiescence?.receipt + activeQuiescence = nil + return receipt + } + receipt?.complete(.failed(fault)) + } + + private func publishResourceRelease( + resourceID: UInt32, + generation: UInt64, + completion: @escaping @Sendable () -> Void + ) { + let release = VirtioGPUScanoutResourceRelease( + resourceID: resourceID, + resourceGeneration: generation, + scanoutCount: scanoutCount, + completion: completion + ) + if let onScanoutResourceReleased { + onScanoutResourceReleased(release) + if scanoutCount == 0 { release.acknowledgeAll() } + } else { + release.acknowledgeAll() + } + } + + private func process( + request: [UInt8], + cursorQueue: Bool, + transport: VirtioMMIOTransport + ) -> CommandProcessingOutcome { + do { + return .response(try processResponse( + request: request, + cursorQueue: cursorQueue, + transport: transport + )) + } catch let signal as RendererCommandOutcomeUnknownSignal { + return .outcomeUnknown(signal.uncertainty) + } catch { + logCommandFailure(request: request, error: error) + return .response(responseHeader( + type: Response.errorInvalidParameter, + request: request + )) + } + } + + private func processResponse( + request: [UInt8], + cursorQueue: Bool, + transport: VirtioMMIOTransport + ) throws -> [UInt8] { + guard request.count >= 4 else { + return responseHeader(type: Response.errorUnspecified, request: request) + } + + let command = request.leUInt32(at: 0) + let acceptsCommand = lifecycleLock.withLock { acceptingGuestCommands } + if !acceptsCommand, + command != Command.getDisplayInfo, + command != Command.getCapsetInfo, + command != Command.getCapset { + return responseHeader(type: Response.errorInvalidParameter, request: request) + } + if cursorQueue { + return try cursorCommand(command, request: request) + } + + if rendererAuthorityIsConfigured, + command != Command.getDisplayInfo, + command != Command.getCapsetInfo, + command != Command.getCapset, + !rendererLifecycleIsReady { + let health = rendererLifecycleHealth + let error = VMError.invalidConfiguration( + "virtio-gpu renderer lifecycle is not ready: \(health)" + ) + logCommandFailure(request: request, error: error) + return responseHeader(type: Response.errorInvalidParameter, request: request) + } + + switch command { + case Command.getDisplayInfo: + displayLock.lock() + let sizes = scanoutSizes + displayLock.unlock() + var response = responseHeader(type: Response.okDisplayInfo, request: request) + for index in 0..<16 { + let size = sizes.indices.contains(index) ? sizes[index] : nil + response.appendLE(UInt32(0)) + response.appendLE(UInt32(0)) + response.appendLE(size?.width ?? 0) + response.appendLE(size?.height ?? 0) + response.appendLE(size == nil ? UInt32(0) : UInt32(1)) + response.appendLE(UInt32(0)) + } + return response + case Command.resourceCreate2D: + return try scanoutCommand(request: request) { + try requireLength(request, 40) + let resourceID = request.leUInt32(at: 24) + let format = request.leUInt32(at: 28) + let width = request.leUInt32(at: 32) + let height = request.leUInt32(at: 36) + guard resourceID != 0, + canAdmitResource(resourceID), + width > 0, height > 0, + width <= 16_384, height <= 16_384, + Self.isSupportedScanoutFormat(format), + let copiedByteCount = Self.rgbaByteCount(width: width, height: height), + copiedByteCount <= maximumCopiedScanoutSurfaceBytes else { + throw VMError.invalidConfiguration("invalid virtio-gpu 2D resource") + } + // VirGL clients may use a RESOURCE_CREATE_2D allocation as a texture after + // attaching it to a renderer context. Keep the renderer's global resource table in + // lockstep with the device-side scanout table, matching QEMU's virgl path. Without + // this registration virgl_renderer_ctx_attach_resource silently ignores the ID and + // a later sampler-view creation fails as an illegal resource. + if rendererExecutor != nil { + _ = try executeRendererCommand(.createResource3D( + VirtioGPUResourceCreate3D( + resourceID: resourceID, + target: 2, + format: format, + bind: 1 << 1, + width: width, + height: height, + depth: 1, + arraySize: 1, + lastLevel: 0, + samples: 0, + flags: 1 + ), + entries: [] + )) + } + resources2D[resourceID] = Resource2D( + format: format, + width: width, + height: height + ) + registerResourceGeneration(resourceID) + return responseHeader(type: Response.okNoData, request: request) + } + case Command.setScanout: + return try scanoutCommand(request: request) { + try requireLength(request, 48) + let scanoutID = request.leUInt32(at: 40) + let resourceID = request.leUInt32(at: 44) + guard scanoutID < scanoutCount else { + throw VMError.invalidConfiguration("invalid virtio-gpu scanout id") + } + // Linux disables a scanout with resource_id=0 while changing modes. The rectangle + // is ignored by the protocol in that case and is commonly all zeroes, so do not + // reject the legitimate disable before inspecting the resource id. + if resourceID == 0 { + let previous = scanouts.removeValue(forKey: scanoutID) + if let previous, case .blob = previous.source, rendererExecutor != nil { + try releaseBlobMappingIfUnused(resourceID: previous.resourceID) + } + onScanoutDisabled?(scanoutID) + return responseHeader(type: Response.okNoData, request: request) + } + let rect = try scanoutRect(from: request, at: 24) + let source: ScanoutBinding.Source + var preparedPresentation: VirtioGPUTexturePresentation? + defer { + // Until ownership is transferred to `publishBoundScanout`, every error path + // must explicitly retire the producer-completion authority. + preparedPresentation?.discardWithoutPresentation() + } + let resourceWidth: UInt32 + let resourceHeight: UInt32 + if let resource = resources2D[resourceID] { + source = .resource2D + resourceWidth = resource.width + resourceHeight = resource.height + if rendererWorkerCandidate != nil { + guard rendererWorkerResourceGenerations[resourceID] != nil, + Self.rendererWorkerScanoutFormat(resource.format) != nil, + onMetalScanout != nil else { + throw VMError.invalidConfiguration( + "virtio-gpu renderer worker scanout is unavailable" + ) + } + } + } else if let resource = resources3D[resourceID] { + source = .resource3D + resourceWidth = resource.width + resourceHeight = resource.height + if rendererWorkerCandidate != nil { + guard rendererWorkerResourceGenerations[resourceID] != nil, + Self.rendererWorkerScanoutFormat(resource.format) != nil, + onMetalScanout != nil else { + throw VMError.invalidConfiguration( + "virtio-gpu renderer worker scanout is unavailable" + ) + } + } else { + guard rendererExecutor != nil, + let generation = resourceGenerations[resourceID] else { + throw VMError.invalidConfiguration( + "virtio-gpu renderer is unavailable" + ) + } + let result = try executeRendererCommand(.makeScanoutPresentation( + resourceID: resourceID, + resourceGeneration: generation + )) + guard case .scanoutPresentation(let presentation) = result else { + preconditionFailure( + "make-scanout-presentation returned an invalid payload" + ) + } + let texture = presentation.texture + guard presentation.resourceID == resourceID, + presentation.resourceGeneration == generation, + texture.textureID != 0, + texture.format == resource.format, + texture.width == resource.width, + texture.height == resource.height else { + presentation.discardWithoutPresentation() + throw VMError.invalidConfiguration( + "virtio-gpu renderer returned inconsistent scanout texture authority" + ) + } + preparedPresentation = presentation + } + } else { + throw VMError.invalidConfiguration("invalid virtio-gpu scanout resource") + } + guard Self.contains(rect: rect, width: resourceWidth, height: resourceHeight) else { + throw VMError.invalidConfiguration("invalid virtio-gpu scanout rectangle") + } + let previous = scanouts.updateValue( + ScanoutBinding( + resourceID: resourceID, + rect: rect, + source: source + ), + forKey: scanoutID + ) + if let previous, case .blob = previous.source, rendererExecutor != nil { + try releaseBlobMappingIfUnused(resourceID: previous.resourceID) + } + if rendererWorkerCandidate != nil, + rendererWorkerResourceGenerations[resourceID] != nil { + // Worker-backed 2D and VirGL2 resources become visible only after + // RESOURCE_FLUSH acquires a producer-complete native Metal texture lease. + // SET_SCANOUT records geometry but must retain the last completed frame until + // that flush arrives. Treating this transient binding change like the + // resource_id=0 disable above makes compositors that rotate scanout buffers + // flash the host clear color between every SET_SCANOUT and RESOURCE_FLUSH. + return responseHeader(type: Response.okNoData, request: request) + } + let presentationToPublish = preparedPresentation + preparedPresentation = nil + try publishBoundScanout( + scanoutID: scanoutID, + preparedPresentation: presentationToPublish + ) + return responseHeader(type: Response.okNoData, request: request) } - if interrupt { - transport.notifyUsed() + case Command.transferToHost2D: + return try scanoutCommand(request: request) { + try requireLength(request, 56) + let rect = try scanoutRect(from: request, at: 24) + let offset = request.leUInt64(at: 40) + let resourceID = request.leUInt32(at: 48) + guard let resource = resources2D[resourceID], + Self.contains(rect: rect, width: resource.width, height: resource.height), + offset < UInt64(resource.width) * UInt64(resource.height) * 4 else { + throw VMError.invalidConfiguration("invalid virtio-gpu 2D transfer") + } + // Guest backing is directly mapped into this process. The later RESOURCE_FLUSH is + // the ownership boundary at which Dory copies a coherent frame for the host UI. + return responseHeader(type: Response.okNoData, request: request) } - } - } - - private func process(request: [UInt8], cursorQueue: Bool, transport: VirtioMMIOTransport) -> [UInt8] { - guard request.count >= 4 else { - return responseHeader(type: Response.errorUnspecified, request: request) - } - - let command = request.leUInt32(at: 0) - if cursorQueue { - switch command { - case Command.updateCursor, Command.moveCursor: + case Command.resourceFlush: + return try scanoutCommand(request: request) { + try requireLength(request, 48) + let rect = try scanoutRect(from: request, at: 24) + let resourceID = request.leUInt32(at: 40) + if let resource = resources2D[resourceID] { + guard Self.contains(rect: rect, width: resource.width, height: resource.height) else { + throw VMError.invalidConfiguration("invalid virtio-gpu resource flush") + } + try publishScanoutFrames( + resourceID: resourceID, + resource: resource, + dirtyRect: rect + ) + } else if let resource = resources3D[resourceID] { + guard Self.contains(rect: rect, width: resource.width, height: resource.height) else { + throw VMError.invalidConfiguration("invalid virtio-gpu 3D resource flush") + } + guard rendererExecutor != nil else { + throw VMError.invalidConfiguration("virtio-gpu renderer is unavailable") + } + try publishRendererDamage(resourceID: resourceID, dirtyRect: rect) + } else { + guard let blob = blobResources[resourceID] else { + throw VMError.invalidConfiguration("invalid virtio-gpu resource flush") + } + try publishBlobScanoutFrames( + resourceID: resourceID, + blob: blob, + dirtyRect: rect + ) + } return responseHeader(type: Response.okNoData, request: request) - default: - return responseHeader(type: Response.errorInvalidParameter, request: request) } - } - - switch command { - case Command.getDisplayInfo: - var response = responseHeader(type: Response.okDisplayInfo, request: request) - response.append(contentsOf: repeatElement(UInt8(0), count: 16 * 24)) - return response case Command.getCapsetInfo: return capsetInfoResponse(request: request) case Command.getCapset: return capsetResponse(request: request) + case Command.resourceAssignUUID: + return try scanoutCommand(request: request) { + try requireLength(request, 32) + let resourceID = request.leUInt32(at: 24) + guard resources2D[resourceID] != nil + || resources3D[resourceID] != nil + || blobResources[resourceID] != nil else { + throw VMError.invalidConfiguration("virtio-gpu UUID request for unknown resource") + } + let uuid = resourceUUIDs[resourceID] ?? Self.makeResourceUUID() + resourceUUIDs[resourceID] = uuid + var response = responseHeader(type: Response.okResourceUUID, request: request) + response.append(contentsOf: uuid) + return response + } case Command.ctxCreate: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { guard request.count >= 96 else { throw VMError.unexpectedExit("short virtio-gpu ctx_create") } let contextID = request.leUInt32(at: 16) let nameLength = min(Int(request.leUInt32(at: 24)), 64) let contextInit = request.leUInt32(at: 28) let nameBytes = request[32..<(32 + nameLength)].prefix { $0 != 0 } let name = String(decoding: nameBytes, as: UTF8.self) - try renderer.createContext(id: contextID, flags: contextInit & 0xff, name: name) + _ = try executeRendererCommand(.createContext( + id: contextID, + flags: Self.rendererContextFlags(requested: contextInit, capsets: capsets), + name: name + )) + createdContextIDs.insert(contextID) + traceResourceEvent("context-create", contextID: contextID, detail: "name=\(name) init=0x\(String(contextInit, radix: 16))") return responseHeader(type: Response.okNoData, request: request) } case Command.ctxDestroy: - return rendererCommand(request: request) { renderer in - try renderer.destroyContext(id: request.leUInt32(at: 16)) + return try scanoutCommand(request: request) { + let contextID = request.leUInt32(at: 16) + _ = try executeRendererCommand(.destroyContext(id: contextID)) + createdContextIDs.remove(contextID) + traceResourceEvent("context-destroy", contextID: contextID) return responseHeader(type: Response.okNoData, request: request) } case Command.ctxAttachResource: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { try requireLength(request, 32) - try renderer.attachResource(contextID: request.leUInt32(at: 16), resourceID: request.leUInt32(at: 24)) + let contextID = request.leUInt32(at: 16) + let resourceID = request.leUInt32(at: 24) + traceResourceEvent("attach-begin", contextID: contextID, resourceID: resourceID) + if rendererWorkerCandidate != nil, + rendererWorkerResourceGenerations[resourceID] == nil { + guard createdContextIDs.contains(contextID), + resources2D[resourceID] != nil else { + throw VMError.invalidConfiguration( + "invalid local virtio-gpu context attachment" + ) + } + traceResourceEvent( + "attach-end", + contextID: contextID, + resourceID: resourceID, + detail: "authority=local-2d" + ) + return responseHeader(type: Response.okNoData, request: request) + } + _ = try executeRendererCommand(.attachResource( + contextID: contextID, + resourceID: resourceID + )) + traceResourceEvent("attach-end", contextID: contextID, resourceID: resourceID) return responseHeader(type: Response.okNoData, request: request) } case Command.ctxDetachResource: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { try requireLength(request, 32) - try renderer.detachResource(contextID: request.leUInt32(at: 16), resourceID: request.leUInt32(at: 24)) + let contextID = request.leUInt32(at: 16) + let resourceID = request.leUInt32(at: 24) + if rendererWorkerCandidate != nil, + rendererWorkerResourceGenerations[resourceID] == nil { + guard createdContextIDs.contains(contextID), + resources2D[resourceID] != nil else { + throw VMError.invalidConfiguration( + "invalid local virtio-gpu context detachment" + ) + } + traceResourceEvent( + "detach", + contextID: contextID, + resourceID: resourceID, + detail: "authority=local-2d" + ) + return responseHeader(type: Response.okNoData, request: request) + } + _ = try executeRendererCommand(.detachResource( + contextID: contextID, + resourceID: resourceID + )) + traceResourceEvent("detach", contextID: contextID, resourceID: resourceID) return responseHeader(type: Response.okNoData, request: request) } case Command.submit3D: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { try requireLength(request, 32) let size = Int(request.leUInt32(at: 24)) - guard request.count >= 32 + size else { throw VMError.unexpectedExit("short virtio-gpu submit_3d") } - try renderer.submit3D(contextID: request.leUInt32(at: 16), command: Array(request[32..<(32 + size)])) + let (end, overflow) = 32.addingReportingOverflow(size) + guard !overflow, end <= request.count else { + throw VMError.unexpectedExit("short virtio-gpu submit_3d") + } + _ = try executeRendererCommand(.submit3D( + contextID: request.leUInt32(at: 16), + command: Array(request[32.. 0, + size <= UInt64(Int.max), + size <= maximumRendererReferencedBytes, + resourceAvailable else { + throw VMError.invalidConfiguration( + "invalid virtio-gpu blob resource " + + "(id=\(resourceID) size=\(size) " + + "available=\(resourceAvailable))" + ) + } let entries = try memoryEntries(from: request, count: request.leUInt32(at: 36), offset: 56, transport: transport) - try renderer.createBlob( + _ = try executeRendererCommand(.createBlob( resourceID: resourceID, contextID: request.leUInt32(at: 16), blobMemory: request.leUInt32(at: 28), @@ -481,13 +8148,25 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi blobID: request.leUInt64(at: 40), size: size, entries: entries - ) + )) resourceEntries[resourceID] = entries - blobResources[resourceID] = BlobResource(size: size) + blobResources[resourceID] = BlobResource( + memory: request.leUInt32(at: 28), + size: size, + mapping: nil, + workerMapping: nil + ) + registerResourceGeneration(resourceID) + traceResourceEvent( + "create-blob", + contextID: request.leUInt32(at: 16), + resourceID: resourceID, + detail: "memory=\(request.leUInt32(at: 28)) blob=\(request.leUInt64(at: 40)) size=\(size)" + ) return responseHeader(type: Response.okNoData, request: request) } case Command.resourceMapBlob: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { try requireLength(request, 40) let resourceID = request.leUInt32(at: 24) let offset = request.leUInt64(at: 32) @@ -497,51 +8176,168 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi } // virglrenderer owns the blob's host memory; ask it to map, then expose that pointer to // the guest by hv_vm_mapping it into the window at the requested offset. - let mapping = try renderer.mapBlob(resourceID: resourceID) + let mapping = try ensureBlobMapping(resourceID: resourceID) try hostVisibleMemory.map( resourceID: resourceID, hostPointer: mapping.hostPointer, offset: offset, size: mapping.size != 0 ? mapping.size : blob.size ) + if var updated = blobResources[resourceID] { + updated.guestMapped = true + blobResources[resourceID] = updated + } var response = responseHeader(type: Response.okMapInfo, request: request) response.appendLE(mapping.mapInfo) response.appendLE(UInt32(0)) return response } case Command.resourceUnmapBlob: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { try requireLength(request, 32) let resourceID = request.leUInt32(at: 24) hostVisibleMemory?.unmap(resourceID: resourceID) - try renderer.unmapBlob(resourceID: resourceID) + guard var blob = blobResources[resourceID] else { + throw VMError.invalidConfiguration("virtio-gpu unmap of unknown blob") + } + blob.guestMapped = false + blobResources[resourceID] = blob + try releaseBlobMappingIfUnused(resourceID: resourceID) return responseHeader(type: Response.okNoData, request: request) } case Command.resourceUnref: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { try requireLength(request, 32) let resourceID = request.leUInt32(at: 24) + let generation = resourceGenerations[resourceID] ?? 0 + let removed2D = resources2D.removeValue(forKey: resourceID) + let removed3D = resources3D.removeValue(forKey: resourceID) + let removedBlob = blobResources.removeValue(forKey: resourceID) + guard removed2D != nil || removed3D != nil || removedBlob != nil else { + throw VMError.invalidConfiguration( + "virtio-gpu unref of unknown or retiring resource \(resourceID)" + ) + } + if cursorResourceID == resourceID { + cursorResourceID = nil + onCursorUpdate?(nil) + } + resourceUUIDs.removeValue(forKey: resourceID) + traceResourceEvent("unref-begin", contextID: request.leUInt32(at: 16), resourceID: resourceID) hostVisibleMemory?.unmap(resourceID: resourceID) - try renderer.unrefResource(resourceID: resourceID) + scanouts = scanouts.filter { $0.value.resourceID != resourceID } resourceEntries.removeValue(forKey: resourceID) - blobResources.removeValue(forKey: resourceID) + resourceGenerations.removeValue(forKey: resourceID) + beginResourceRetirement( + resourceID: resourceID, + generation: generation, + requiresBlobUnmap: removedBlob?.mapping?.requiresRendererUnmap == true, + rendererGeneration: fenceLock.withLock { lifecycleEpoch } + ) + let kind = removed2D != nil ? "2d" : (removed3D != nil ? "3d" : "blob") + traceResourceEvent("unref-guest-retired", resourceID: resourceID, detail: "kind=\(kind)") return responseHeader(type: Response.okNoData, request: request) } case Command.transferToHost3D: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { let transfer = try transfer3D(from: request) - try renderer.transferToHost3D(transfer, entries: resourceEntries[transfer.resourceID] ?? []) + _ = try executeRendererCommand(.transferToHost3D( + transfer, + entries: resourceEntries[transfer.resourceID] ?? [] + )) return responseHeader(type: Response.okNoData, request: request) } case Command.transferFromHost3D: - return rendererCommand(request: request) { renderer in + return try scanoutCommand(request: request) { let transfer = try transfer3D(from: request) - try renderer.transferFromHost3D(transfer, entries: resourceEntries[transfer.resourceID] ?? []) + _ = try executeRendererCommand(.transferFromHost3D( + transfer, + entries: resourceEntries[transfer.resourceID] ?? [] + )) + return responseHeader(type: Response.okNoData, request: request) + } + case Command.setScanoutBlob: + return try scanoutCommand(request: request) { + try requireLength(request, 96) + let scanoutID = request.leUInt32(at: 40) + let resourceID = request.leUInt32(at: 44) + let width = request.leUInt32(at: 48) + let height = request.leUInt32(at: 52) + let format = request.leUInt32(at: 56) + let stride = request.leUInt32(at: 64) + let offset = request.leUInt32(at: 80) + guard scanoutID < scanoutCount else { + throw VMError.invalidConfiguration("invalid virtio-gpu blob scanout id") + } + if resourceID == 0 { + let previous = scanouts.removeValue(forKey: scanoutID) + if let previous, case .blob = previous.source, rendererExecutor != nil { + try releaseBlobMappingIfUnused(resourceID: previous.resourceID) + } + onScanoutDisabled?(scanoutID) + return responseHeader(type: Response.okNoData, request: request) + } + let rect = try scanoutRect(from: request, at: 24) + let workerBacked = rendererWorkerResourceGenerations[resourceID] != nil + guard let blob = blobResources[resourceID], + width > 0, height > 0, + width <= 16_384, height <= 16_384, + Self.isSupportedScanoutFormat(format), + !workerBacked || Self.rendererWorkerScanoutFormat(format) != nil, + !workerBacked || onMetalScanout != nil, + Self.contains(rect: rect, width: width, height: height), + stride >= width * 4, + let copiedByteCount = Self.rgbaByteCount(width: width, height: height), + copiedByteCount <= maximumCopiedScanoutSurfaceBytes, + request.leUInt32(at: 68) == 0, + request.leUInt32(at: 72) == 0, + request.leUInt32(at: 76) == 0, + request.leUInt32(at: 84) == 0, + request.leUInt32(at: 88) == 0, + request.leUInt32(at: 92) == 0, + Self.scanoutByteRangeIsValid( + width: width, + height: height, + stride: stride, + offset: offset, + resourceSize: blob.size + ) else { + throw VMError.invalidConfiguration("invalid virtio-gpu blob scanout resource") + } + let previous = scanouts.updateValue( + ScanoutBinding( + resourceID: resourceID, + rect: rect, + source: .blob( + format: format, + width: width, + height: height, + stride: stride, + offset: offset + ) + ), + forKey: scanoutID + ) + if let previous, + previous.resourceID != resourceID, + case .blob = previous.source, + rendererExecutor != nil { + try releaseBlobMappingIfUnused(resourceID: previous.resourceID) + } + if workerBacked { + // The producer is renderer-owned HOST3D SHM. SET_SCANOUT_BLOB establishes + // geometry only; RESOURCE_FLUSH acquires the exact worker layout plus its + // context-timeline fence before any Metal consumer can observe the bytes. + onScanoutDisabled?(scanoutID) + return responseHeader(type: Response.okNoData, request: request) + } + try publishBlobScanoutFrames( + resourceID: resourceID, + blob: blob, + dirtyRect: rect + ) return responseHeader(type: Response.okNoData, request: request) } - case Command.resourceCreate2D, Command.setScanout, Command.resourceFlush, - Command.transferToHost2D, Command.setScanoutBlob: - return responseHeader(type: Response.okNoData, request: request) default: return responseHeader(type: Response.errorInvalidParameter, request: request) } @@ -549,6 +8345,9 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi private func capsetInfoResponse(request: [UInt8]) -> [UInt8] { guard request.count >= 32 else { return responseHeader(type: Response.errorInvalidParameter, request: request) } + guard rendererCapabilitiesAreAdvertised else { + return responseHeader(type: Response.errorInvalidParameter, request: request) + } let index = Int(request.leUInt32(at: 24)) guard index < capsets.count else { return responseHeader(type: Response.errorInvalidParameter, request: request) } let capset = capsets[index] @@ -562,6 +8361,9 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi private func capsetResponse(request: [UInt8]) -> [UInt8] { guard request.count >= 32 else { return responseHeader(type: Response.errorInvalidParameter, request: request) } + guard rendererCapabilitiesAreAdvertised else { + return responseHeader(type: Response.errorInvalidParameter, request: request) + } let id = request.leUInt32(at: 24) let version = request.leUInt32(at: 28) guard let capset = capsets.first(where: { $0.id == id }), @@ -573,18 +8375,855 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi return response } - private func rendererCommand( + private func executeRendererCommand( + _ command: VirtioGPURendererCommand, + generation explicitGeneration: UInt64? = nil, + purpose: VirtioGPURendererCommandPurpose = .guest + ) throws -> VirtioGPURendererCommandValue { + guard let rendererExecutor else { + throw VMError.invalidConfiguration("virtio-gpu renderer is unavailable") + } + let generation = explicitGeneration ?? fenceLock.withLock { lifecycleEpoch } + switch rendererExecutor.execute( + command, + generation: generation, + purpose: purpose + ) { + case .success(let value): + return value + case .rejected(let rejection): + throw VirtioGPURendererCommandRejected(String(describing: rejection)) + case .outcomeUnknown(let uncertainty): + recordTelemetry(.rendererCommandUncertainty) + if let failure = uncertainty.runtimeFailure { + recordRendererFailure(failure, generation: uncertainty.generation) + } + failRendererLifecycle( + .commandOutcomeUnknown( + operation: uncertainty.operation, + detail: uncertainty.detail + ), + epoch: uncertainty.generation + ) + throw RendererCommandOutcomeUnknownSignal(uncertainty: uncertainty) + } + } + + private var rendererLifecycleIsReady: Bool { + lifecycleLock.withLock { + if case .ready = rendererLifecycleHealthState { return true } + return !rendererAuthorityIsConfigured + } + } + + private var rendererAuthorityIsConfigured: Bool { + rendererExecutor != nil || rendererWorkerCandidate != nil + } + + /// Feature and capset discovery is guest-visible state, so it must close with command + /// admission. In particular, successful shutdown and a renderer that requires recreation are + /// both quarantined rather than masquerading as a newly usable renderer epoch. + private var rendererCapabilitiesAreAdvertised: Bool { + lifecycleLock.withLock { + guard acceptingGuestCommands else { return false } + if case .ready = rendererLifecycleHealthState { return true } + return false + } + } + + private func scanoutCommand( request: [UInt8], - _ body: (VirtioGPURenderer) throws -> [UInt8] - ) -> [UInt8] { - guard let renderer else { return responseHeader(type: Response.errorInvalidParameter, request: request) } + _ body: () throws -> [UInt8] + ) throws -> [UInt8] { do { - return try body(renderer) + return try body() + } catch let signal as RendererCommandOutcomeUnknownSignal { + throw signal } catch { + recordRendererFailure(error) + logCommandFailure(request: request, error: error) return responseHeader(type: Response.errorInvalidParameter, request: request) } } + private func recordRendererFailure( + _ error: Error, + generation: UInt64? = nil + ) { + if let generation, + fenceLock.withLock({ lifecycleEpoch != generation }) { + return + } + fenceLock.withLock { recordRendererFailureWhileLocked(error) } + } + + private func recordRendererFailureWhileLocked(_ error: Error) { + guard let failure = error as? VirtioGPURendererRuntimeFailure, + case .deviceLost = failure, + !rendererDeviceLossLatched else { return } + rendererDeviceLossLatched = true + rendererDeviceLossCount = Self.saturatingAdd(rendererDeviceLossCount, 1) + } + + /// Linux may retry a failed renderer command many times per second. Preserve the status and + /// identifying fields needed for diagnosis without flooding the VM's serial log. + private func logCommandFailure(request: [UInt8], error: Error) { + guard request.count >= 4 else { return } + let command = request.leUInt32(at: 0) + let count = commandFailureCounts[command, default: 0] + 1 + commandFailureCounts[command] = count + guard count <= 3 else { return } + let contextID = request.count >= 20 ? request.leUInt32(at: 16) : 0 + let detail: String + if command == Command.submit3D { + let size = request.count >= 28 ? request.leUInt32(at: 24) : 0 + detail = "bytes=\(size)" + } else { + let resourceID = request.count >= 28 ? request.leUInt32(at: 24) : 0 + detail = "resource=\(resourceID)" + } + FileHandle.standardError.write(Data( + "dory-gpu: command=0x\(String(command, radix: 16)) context=\(contextID) \(detail) failed: \(error)\n".utf8 + )) + } + + /// A malformed compositor can repeat RESOURCE_FLUSH indefinitely. Keep the exact failure + /// boundary needed for physical qualification while emitting at most one line per resource + /// and stage for the current device generation. + private func logRendererWorkerScanoutFailure( + resourceID: UInt32, + stage: String, + detail: String = "" + ) { + let key = "\(resourceID):\(stage)" + let firstOccurrence = rendererWorkerScanoutDiagnosticLock.withLock { + rendererWorkerScanoutDiagnosticStages.insert(key).inserted + } + guard firstOccurrence else { return } + let suffix = detail.isEmpty ? "" : " detail=\(detail)" + FileHandle.standardError.write(Data( + "dory-gpu: worker scanout resource=\(resourceID) stage=\(stage)\(suffix)\n".utf8 + )) + } + + /// Physical qualification crosses the renderer lane, its presentation queue, AppKit, and + /// Metal. Emit each ownership handoff once per resource and device generation so a stalled + /// frame has an exact last-known stage without turning the production frame loop into logging. + private func logRendererWorkerScanoutProgress( + resourceID: UInt32, + stage: String + ) { + let key = "progress:\(resourceID):\(stage)" + let firstOccurrence = rendererWorkerScanoutDiagnosticLock.withLock { + rendererWorkerScanoutDiagnosticStages.insert(key).inserted + } + guard firstOccurrence else { return } + FileHandle.standardError.write(Data( + "dory-gpu: worker scanout progress resource=\(resourceID) stage=\(stage)\n".utf8 + )) + } + + private func traceResourceEvent( + _ event: String, + contextID: UInt32 = 0, + resourceID: UInt32? = nil, + detail: String = "" + ) { + guard traceResourceLifecycle else { return } + resourceTraceSequence &+= 1 + let resource = resourceID.map(String.init) ?? "-" + let kind: String + if let resourceID { + if resources2D[resourceID] != nil { kind = "2d" } + else if resources3D[resourceID] != nil { kind = "3d" } + else if blobResources[resourceID] != nil { kind = "blob" } + else { kind = "missing" } + } else { + kind = "-" + } + let suffix = detail.isEmpty ? "" : " \(detail)" + FileHandle.standardError.write(Data( + "dory-gpu-trace: seq=\(resourceTraceSequence) event=\(event) context=\(contextID) resource=\(resource) kind=\(kind)\(suffix)\n".utf8 + )) + } + + private func scanoutRect(from request: [UInt8], at offset: Int) throws -> VirtioGPURect { + try requireLength(request, offset + 16) + let rect = VirtioGPURect( + x: request.leUInt32(at: offset), + y: request.leUInt32(at: offset + 4), + width: request.leUInt32(at: offset + 8), + height: request.leUInt32(at: offset + 12) + ) + guard rect.width > 0, rect.height > 0 else { + throw VMError.invalidConfiguration("empty virtio-gpu rectangle") + } + return rect + } + + private static func contains(rect: VirtioGPURect, width: UInt32, height: UInt32) -> Bool { + rect.x <= width && rect.width <= width - rect.x + && rect.y <= height && rect.height <= height - rect.y + } + + private static func intersection(_ lhs: VirtioGPURect, _ rhs: VirtioGPURect) -> VirtioGPURect? { + let left = max(UInt64(lhs.x), UInt64(rhs.x)) + let top = max(UInt64(lhs.y), UInt64(rhs.y)) + let right = min(UInt64(lhs.x) + UInt64(lhs.width), UInt64(rhs.x) + UInt64(rhs.width)) + let bottom = min(UInt64(lhs.y) + UInt64(lhs.height), UInt64(rhs.y) + UInt64(rhs.height)) + guard right > left, bottom > top else { return nil } + return VirtioGPURect( + x: UInt32(left), + y: UInt32(top), + width: UInt32(right - left), + height: UInt32(bottom - top) + ) + } + + private static func isSupportedScanoutFormat(_ format: UInt32) -> Bool { + // All formats below are the 32-bit virtio-gpu formats accepted by Linux's DRM helper. + // The host presentation layer retains the format so it can select the matching Metal + // swizzle instead of rewriting every pixel in this transport thread. + [1, 2, 3, 4, 67, 68, 121, 134].contains(format) + } + + /// Alpha and X variants have identical byte/channel layout. The KMS wire format describes + /// whether scanout consumes the high byte, while Venus reports the Vulkan resource's alpha + /// format. Normalize only that semantic padding bit before authenticating the renderer-owned + /// allocation; every other virtio format remains outside the zero-copy Metal contract. + private static func rendererWorkerScanoutFormat(_ format: UInt32) -> UInt32? { + switch format { + case 1, 2: 1 + case 67, 68: 67 + default: nil + } + } + + /// Resolves the one renderer request layout accepted for a bound worker resource. VirGL2 + /// resource3D scanout is a tightly packed native texture; Venus HOST3D blob scanout retains + /// its authenticated linear layout. Admission and post-XPC publication both use this exact + /// projection so they cannot disagree about the surface being presented. + private func rendererWorkerScanoutSurface( + for binding: ScanoutBinding + ) -> WorkerScanoutSurface? { + switch binding.source { + case .resource2D: + guard rendererWorkerResourceGenerations[binding.resourceID] != nil, + let resource = resources2D[binding.resourceID], + let format = Self.rendererWorkerScanoutFormat(resource.format) else { + return nil + } + let (stride, overflow) = resource.width.multipliedReportingOverflow(by: 4) + guard !overflow else { return nil } + return WorkerScanoutSurface( + width: resource.width, + height: resource.height, + format: format, + stride: stride, + offset: 0 + ) + case .resource3D: + guard let resource = resources3D[binding.resourceID], + let format = Self.rendererWorkerScanoutFormat(resource.format) else { + return nil + } + let (stride, overflow) = resource.width.multipliedReportingOverflow(by: 4) + guard !overflow else { return nil } + return WorkerScanoutSurface( + width: resource.width, + height: resource.height, + format: format, + stride: stride, + offset: 0 + ) + case .blob(let format, let width, let height, let stride, let offset): + guard let rendererFormat = Self.rendererWorkerScanoutFormat(format) else { + return nil + } + return WorkerScanoutSurface( + width: width, + height: height, + format: rendererFormat, + stride: stride, + offset: offset + ) + } + } + + private static func rendererWorkerScanoutTransportMatches( + _ scanout: DoryRendererWorkerScanoutAuthority, + surface: WorkerScanoutSurface + ) -> Bool { + switch scanout { + case .sharedMemory(let value): + return value.lease.stride == surface.stride + && value.lease.storageOffset == UInt64(surface.offset) + case .sharedTexture: + let (expectedStride, overflow) = surface.width.multipliedReportingOverflow(by: 4) + return !overflow && surface.stride == expectedStride && surface.offset == 0 + } + } + + private static func rendererWorkerScanoutDescription( + _ scanout: DoryRendererWorkerScanoutAuthority + ) -> String { + switch scanout { + case .sharedMemory(let value): + return "shm/\(value.lease.width)x\(value.lease.height)/" + + "\(value.lease.pixelFormat.rawValue)/\(value.lease.stride)/" + + "\(value.lease.storageOffset)" + case .sharedTexture(let value): + return "metal/\(value.lease.width)x\(value.lease.height)/" + + "\(value.lease.pixelFormat.rawValue)" + } + } + + private static func rgbaByteCount(width: UInt32, height: UInt32) -> UInt64? { + let (pixels, pixelOverflow) = UInt64(width).multipliedReportingOverflow( + by: UInt64(height) + ) + guard !pixelOverflow else { return nil } + let (bytes, byteOverflow) = pixels.multipliedReportingOverflow(by: 4) + return byteOverflow ? nil : bytes + } + + private static func scanoutByteRangeIsValid( + width: UInt32, + height: UInt32, + stride: UInt32, + offset: UInt32, + resourceSize: UInt64 + ) -> Bool { + guard width > 0, height > 0 else { return false } + let finalRow = UInt64(height - 1) * UInt64(stride) + let finalPixel = UInt64(width) * 4 + return UInt64(offset) <= resourceSize + && finalRow <= resourceSize - UInt64(offset) + && finalPixel <= resourceSize - UInt64(offset) - finalRow + } + + private func cursorCommand(_ command: UInt32, request: [UInt8]) throws -> [UInt8] { + try scanoutCommand(request: request) { + guard command == Command.updateCursor || command == Command.moveCursor else { + throw VMError.invalidConfiguration("unsupported virtio-gpu cursor command") + } + try requireLength(request, 56) + let scanoutID = request.leUInt32(at: 24) + guard scanoutID < scanoutCount else { + throw VMError.invalidConfiguration("invalid virtio-gpu cursor scanout") + } + if command == Command.moveCursor { + return responseHeader(type: Response.okNoData, request: request) + } + + let resourceID = request.leUInt32(at: 40) + if resourceID == 0 { + cursorResourceID = nil + onCursorUpdate?(nil) + return responseHeader(type: Response.okNoData, request: request) + } + let resource = try copiedCursorResource(resourceID: resourceID) + let hotX = request.leUInt32(at: 44) + let hotY = request.leUInt32(at: 48) + guard hotX < resource.width, hotY < resource.height else { + throw VMError.invalidConfiguration("virtio-gpu cursor hotspot is outside the image") + } + cursorResourceID = resourceID + onCursorUpdate?(VirtioGPUCursorUpdate( + scanoutID: scanoutID, + resourceID: resourceID, + x: request.leUInt32(at: 28), + y: request.leUInt32(at: 32), + width: resource.width, + height: resource.height, + hotX: hotX, + hotY: hotY, + bytes: resource.bytes + )) + return responseHeader(type: Response.okNoData, request: request) + } + } + + private func copiedCursorResource(resourceID: UInt32) throws -> CursorResourceSnapshot { + if let resource = resources2D[resourceID] { + try validateCursorResource( + format: resource.format, + width: resource.width, + height: resource.height + ) + return CursorResourceSnapshot( + width: resource.width, + height: resource.height, + bytes: try copiedBytes(for: resource) + ) + } + + if rendererWorkerResourceGenerations[resourceID] != nil, + resources3D[resourceID] != nil { + // A private worker Metal texture is not CPU-readable authority. Until an explicit + // asynchronous worker readback contract exists, fail the cursor update instead of + // acknowledging a stale or fabricated local copy. + throw VMError.invalidConfiguration( + "virtio-gpu worker 3D cursor requires authenticated readback" + ) + } + + guard let resource = resources3D[resourceID], rendererExecutor != nil else { + throw VMError.invalidConfiguration( + "virtio-gpu cursor references an unknown resource" + ) + } + try validateCursorResource( + format: resource.format, + width: resource.width, + height: resource.height + ) + let stride = UInt64(resource.width) * 4 + let byteCount = stride * UInt64(resource.height) + guard stride <= UInt64(UInt32.max), byteCount <= UInt64(Int.max) else { + throw VMError.invalidConfiguration("virtio-gpu cursor resource is too large") + } + var pixels = Data(count: Int(byteCount)) + try pixels.withUnsafeMutableBytes { bytes in + guard let baseAddress = bytes.baseAddress else { + throw VMError.invalidConfiguration("virtio-gpu 3D cursor has no storage") + } + _ = try executeRendererCommand(.transferFromHost3D( + VirtioGPUTransfer3D( + resourceID: resourceID, + contextID: 0, + level: 0, + stride: UInt32(stride), + layerStride: 0, + offset: 0, + box: [0, 0, 0, resource.width, resource.height, 1] + ), + entries: [VirtioGPUMemoryEntry(pointer: baseAddress, length: bytes.count)] + )) + } + return CursorResourceSnapshot( + width: resource.width, + height: resource.height, + bytes: pixels + ) + } + + private func validateCursorResource(format: UInt32, width: UInt32, height: UInt32) throws { + // Linux normally advertises ARGB8888 for the cursor plane, but accelerated Mutter creates + // the 64x64 VirGL cursor resource as B8G8R8X8 and still writes ARGB cursor payload bytes. + // QEMU's VirGL cursor path intentionally copies that payload without format conversion. + guard format == 1 || format == 2, + width > 0, height > 0, + width <= 256, height <= 256 else { + throw VMError.invalidConfiguration( + "virtio-gpu cursor requires a bounded BGRA/BGRX resource " + + "(format=\(format) size=\(width)x\(height))" + ) + } + } + + /// Cursor snapshots are explicitly bounded to 256×256 and require a complete image. Display + /// damage does not use this full-resource helper; it is extracted directly below. + private func copiedBytes(for resource: Resource2D) throws -> Data { + guard let byteCount = Self.rgbaByteCount( + width: resource.width, + height: resource.height + ), byteCount <= UInt64(Int.max) else { + throw VMError.invalidConfiguration("virtio-gpu 2D backing is too large") + } + return try copyBackingRange( + entries: resource.backing, + offset: 0, + count: Int(byteCount) + ) + } + + /// Publishes one copied frame at a time. Returning an array here would keep every full-damage + /// scanout copy alive until the batch completed (up to 16 × the per-frame ceiling). + private func publishScanoutFrames( + resourceID: UInt32, + resource: Resource2D, + dirtyRect: VirtioGPURect + ) throws { + guard let onScanoutFrame else { return } + guard let requiredResourceBytes = Self.rgbaByteCount( + width: resource.width, + height: resource.height + ), Self.backingCovers( + resource.backing, + byteCount: requiredResourceBytes + ) else { + throw VMError.invalidConfiguration("virtio-gpu 2D backing is incomplete") + } + let sourceStride = UInt64(resource.width) * 4 + for (scanoutID, binding) in scanouts.sorted(by: { $0.key < $1.key }) + where binding.resourceID == resourceID { + guard case .resource2D = binding.source else { continue } + guard let dirty = Self.intersection(dirtyRect, binding.rect) else { continue } + let outputStride = Int(dirty.width) * 4 + var pixels = Data(capacity: outputStride * Int(dirty.height)) + for row in 0.. Bool { + var total: UInt64 = 0 + for entry in entries { + guard entry.length >= 0 else { return false } + let (next, overflow) = total.addingReportingOverflow(UInt64(entry.length)) + guard !overflow else { return false } + total = next + if total >= byteCount { return true } + } + return byteCount == 0 + } + + /// Blob scanouts are likewise streamed so the renderer/guest backing plus one output frame is + /// the producer's maximum live copy, independent of the number of scanouts. + private func publishBlobScanoutFrames( + resourceID: UInt32, + blob: BlobResource, + dirtyRect: VirtioGPURect + ) throws { + let bindings = scanouts.sorted(by: { $0.key < $1.key }).compactMap { + (scanoutID, binding) -> (UInt32, ScanoutBinding, UInt32, UInt32, UInt32, UInt32, UInt32)? in + guard binding.resourceID == resourceID, + case let .blob(format, width, height, stride, offset) = binding.source else { + return nil + } + return (scanoutID, binding, format, width, height, stride, offset) + } + guard !bindings.isEmpty else { return } + + let guestEntries = blob.memory == 1 ? (resourceEntries[resourceID] ?? []) : [] + let mapping: VirtioGPUBlobMapping? + if guestEntries.isEmpty { + guard rendererExecutor != nil else { + throw VMError.invalidConfiguration("virtio-gpu blob scanout has no accessible backing") + } + mapping = try ensureBlobMapping(resourceID: resourceID) + } else { + mapping = nil + } + + for (scanoutID, binding, format, width, height, stride, offset) in bindings { + guard Self.contains(rect: dirtyRect, width: width, height: height) else { + throw VMError.invalidConfiguration("virtio-gpu blob damage exceeds framebuffer") + } + guard let dirty = Self.intersection(dirtyRect, binding.rect) else { continue } + let outputStride = Int(dirty.width) * 4 + var pixels = Data(capacity: outputStride * Int(dirty.height)) + for row in 0.. (UInt32, ScanoutBinding, VirtioGPURect)? in + guard binding.resourceID == resourceID, + case .resource3D = binding.source, + let damaged = Self.intersection(dirtyRect, binding.rect) else { + return nil + } + return (scanoutID, binding, damaged) + } + // Acquire every producer-completion authority before publishing any update. A renderer + // failure must leave all scanouts on their previous coherent frame, not partially advance a + // multi-display resource. + var updates = [VirtioGPUScanoutTextureUpdate]() + do { + for (scanoutID, binding, damaged) in targets { + let result = try executeRendererCommand(.makeScanoutPresentation( + resourceID: resourceID, + resourceGeneration: generation + )) + guard case .scanoutPresentation(let presentation) = result else { + preconditionFailure("make-scanout-presentation returned an invalid payload") + } + guard presentation.resourceID == resourceID, + presentation.resourceGeneration == generation else { + presentation.discardWithoutPresentation() + throw VMError.invalidConfiguration( + "virtio-gpu renderer returned mismatched presentation identity" + ) + } + updates.append(VirtioGPUScanoutTextureUpdate( + scanoutID: scanoutID, + presentation: presentation, + sourceRect: binding.rect, + dirtyRect: VirtioGPURect( + x: damaged.x - binding.rect.x, + y: damaged.y - binding.rect.y, + width: damaged.width, + height: damaged.height + ) + )) + } + } catch { + // Multi-scanout publication is transactional. If acquisition N fails, retire every + // already-acquired authority and publish none of the new generation's damage. + for update in updates { + update.presentation.discardWithoutPresentation() + } + throw error + } + for update in updates { + publishTextureUpdate(update) + } + } + + private func publishTextureUpdate(_ update: VirtioGPUScanoutTextureUpdate) { + if let onScanoutTexture { + onScanoutTexture(update) + } else { + update.presentation.discardWithoutPresentation() + } + } + + private func copyBackingRange( + entries: [VirtioGPUMemoryEntry], + offset: UInt64, + count: Int + ) throws -> Data { + guard count >= 0 else { + throw VMError.invalidConfiguration("invalid virtio-gpu backing range") + } + var skip = offset + var remaining = count + var bytes = Data(capacity: count) + for entry in entries where remaining > 0 { + let entryLength = UInt64(entry.length) + if skip >= entryLength { + skip -= entryLength + continue + } + let entryOffset = Int(skip) + let available = entry.length - entryOffset + let copied = min(available, remaining) + bytes.append( + entry.pointer.advanced(by: entryOffset).assumingMemoryBound(to: UInt8.self), + count: copied + ) + remaining -= copied + skip = 0 + } + guard remaining == 0 else { + throw VMError.invalidConfiguration("virtio-gpu scanout backing is incomplete") + } + return bytes + } + + private func ensureBlobMapping( + resourceID: UInt32 + ) throws -> VirtioGPUBlobMapping { + guard var blob = blobResources[resourceID] else { + throw VMError.invalidConfiguration("virtio-gpu map of unknown blob") + } + if let mapping = blob.mapping { return mapping } + let result = try executeRendererCommand(.mapBlob(resourceID: resourceID)) + guard case .blobMapping(let mapping) = result else { + preconditionFailure("map-blob returned an invalid executor payload") + } + blob.mapping = mapping + blobResources[resourceID] = blob + return mapping + } + + /// Linux creates an implicit default context for primary-node clients before userspace can issue + /// VIRTGPU_CONTEXT_INIT. The standard default is VirGL2 when it is advertised; a renderer offering + /// one capset has an unambiguous default. Resolving zero here also prevents the kernel from retrying + /// an unsupported legacy context when only one non-VirGL capset is advertised. + static func rendererContextFlags(requested: UInt32, capsets: [VirtioGPUCapset]) -> UInt32 { + let requestedCapset = requested & 0xff + if requestedCapset == 0 { + if capsets.contains(where: { $0.id == 2 }) { + return 2 + } + if capsets.count == 1 { + return capsets[0].id & 0xff + } + } + return requestedCapset + } + + private static func makeResourceUUID() -> [UInt8] { + var value = UUID().uuid + return withUnsafeBytes(of: &value) { Array($0) } + } + + private func releaseBlobMappingIfUnused( + resourceID: UInt32 + ) throws { + guard var blob = blobResources[resourceID], + let mapping = blob.mapping, + !blob.guestMapped, + !scanouts.values.contains(where: { binding in + guard binding.resourceID == resourceID else { return false } + if case .blob = binding.source { return true } + return false + }) else { + return + } + if mapping.requiresRendererUnmap { + _ = try executeRendererCommand(.unmapBlob(resourceID: resourceID)) + } + blob.mapping = nil + blobResources[resourceID] = blob + } + private func memoryEntries( from request: [UInt8], count: UInt32, @@ -592,7 +9231,14 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi transport: VirtioMMIOTransport ) throws -> [VirtioGPUMemoryEntry] { let total = Int(count) - guard request.count >= offset + total * 16 else { + guard total <= maximumRawMemoryEntries else { + throw VMError.invalidConfiguration( + "virtio-gpu raw memory entry limit exceeded: \(total) > \(maximumRawMemoryEntries)" + ) + } + let (entryBytes, multiplyOverflow) = total.multipliedReportingOverflow(by: 16) + let (end, addOverflow) = offset.addingReportingOverflow(entryBytes) + guard !multiplyOverflow, !addOverflow, offset >= 0, end <= request.count else { throw VMError.unexpectedExit("short virtio-gpu memory entry list") } var entries = [VirtioGPUMemoryEntry]() @@ -603,9 +9249,61 @@ public final class VirtioGPU: VirtioDeviceBackend, VirtioSharedMemoryRegionProvi let length = request.leUInt32(at: base + 8) guard length > 0 else { continue } let pointer = try transport.hostPointer(at: guestAddress, count: UInt64(length)) - entries.append(VirtioGPUMemoryEntry(pointer: pointer, length: Int(length))) + entries.append(VirtioGPUMemoryEntry( + pointer: pointer, + length: Int(length), + guestAddress: guestAddress + )) + } + return try Self.coalescedMemoryEntries( + entries, + maximumEntries: maximumMemoryEntries + ) + } + + /// Virtio-gpu guests commonly describe a single compositor buffer as one entry per guest page. + /// Guest RAM is one descriptor-backed object in Dory, so adjacent guest addresses whose host + /// pointers are also adjacent are the same renderer iovec and may be joined losslessly. Real + /// Linux shmem allocations are often physically discontiguous; those entries remain distinct + /// and retain their exact byte ordering up to the authenticated worker's explicit limit. + static func coalescedMemoryEntries( + _ entries: [VirtioGPUMemoryEntry], + maximumEntries: Int + ) throws -> [VirtioGPUMemoryEntry] { + let limit = max(1, maximumEntries) + var result = [VirtioGPUMemoryEntry]() + result.reserveCapacity(min(entries.count, limit)) + for entry in entries { + guard entry.length > 0, let guestAddress = entry.guestAddress else { + throw VMError.invalidConfiguration("virtio-gpu memory entry is invalid") + } + if var previous = result.last, + let previousGuestAddress = previous.guestAddress { + let (previousGuestEnd, addressOverflow) = previousGuestAddress + .addingReportingOverflow(UInt64(previous.length)) + if !addressOverflow, + previousGuestEnd == guestAddress, + previous.pointer.advanced(by: previous.length) == entry.pointer { + let (combinedLength, lengthOverflow) = previous.length + .addingReportingOverflow(entry.length) + guard !lengthOverflow else { + throw VMError.invalidConfiguration( + "virtio-gpu memory entry length overflow" + ) + } + previous.length = combinedLength + result[result.count - 1] = previous + continue + } + } + guard result.count < limit else { + throw VMError.invalidConfiguration( + "virtio-gpu normalized memory entry limit exceeded: more than \(limit)" + ) + } + result.append(entry) } - return entries + return result } private func transfer3D(from request: [UInt8]) throws -> VirtioGPUTransfer3D { diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift new file mode 100644 index 00000000..b6236921 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioInput.swift @@ -0,0 +1,1179 @@ +import DoryFSWorkerContracts +import Foundation + +/// Event totals wrap modulo 2^64. Depth fields are current gauges; high-watermarks and maximum +/// publication latency retain the largest observation for this device lifetime. +public struct VirtioInputStatistics: Equatable, Sendable { + public var submittedFrames: UInt64 + public var publishedFrames: UInt64 + public var publishedEvents: UInt64 + public var coalescedMotionFrames: UInt64 + public var droppedFrames: UInt64 + public var rejectedFrames: UInt64 + public var stateReconciliationEvents: UInt64 + public var invalidEventBuffers: UInt64 + public var invalidStatusBuffers: UInt64 + public var statusEvents: UInt64 + public var queueFaults: UInt64 + public var boundedDrainStops: UInt64 + public var workerTurns: UInt64 + public var workerYields: UInt64 + public var coalescedWorkerRequests: UInt64 + public var revokedWorkerTurns: UInt64 + public var pendingFrameSaturationEvents: UInt64 + public var pendingFrameDepth: UInt64 + public var pendingFrameHighWatermark: UInt64 + public var availableEventBufferDepth: UInt64 + public var availableEventBufferHighWatermark: UInt64 + public var eventQueueDepth: UInt64 + public var eventQueueHighWatermark: UInt64 + public var statusQueueDepth: UInt64 + public var statusQueueHighWatermark: UInt64 + public var publicationLatencyNanoseconds: UInt64 + public var maximumPublicationLatencyNanoseconds: UInt64 +} + +struct VirtioInputLimits: Equatable, Sendable { + static let production = VirtioInputLimits( + maximumEventsPerFrame: 64, + maximumPendingFrames: 256, + maximumChainsPerWorkerTurn: 64, + maximumPublishedEventsPerWorkerTurn: 64 + ) + + let maximumEventsPerFrame: Int + let maximumPendingFrames: Int + let maximumChainsPerWorkerTurn: Int + let maximumPublishedEventsPerWorkerTurn: Int + + init( + maximumEventsPerFrame: Int, + maximumPendingFrames: Int, + maximumChainsPerWorkerTurn: Int, + maximumPublishedEventsPerWorkerTurn: Int = 64 + ) { + precondition(maximumEventsPerFrame >= 2) + precondition(maximumPendingFrames > 0) + precondition(maximumChainsPerWorkerTurn > 0) + precondition(maximumChainsPerWorkerTurn <= Int(Virtqueue.maximumSize)) + precondition(maximumPublishedEventsPerWorkerTurn >= maximumEventsPerFrame) + precondition(maximumPublishedEventsPerWorkerTurn <= Int(Virtqueue.maximumSize)) + self.maximumEventsPerFrame = maximumEventsPerFrame + self.maximumPendingFrames = maximumPendingFrames + self.maximumChainsPerWorkerTurn = maximumChainsPerWorkerTurn + self.maximumPublishedEventsPerWorkerTurn = maximumPublishedEventsPerWorkerTurn + } +} + +struct VirtioInputWorkerHooks: @unchecked Sendable { + var beforeWorkerTurn: (@Sendable () -> Void)? + var beforeEventPublication: (@Sendable () -> Void)? + + static let none = VirtioInputWorkerHooks( + beforeWorkerTurn: nil, + beforeEventPublication: nil + ) +} + +public struct VirtioInputEvent: Sendable, Equatable { + public var type: UInt16 + public var code: UInt16 + public var value: Int32 + + public init(type: UInt16, code: UInt16, value: Int32) { + self.type = type + self.code = code + self.value = value + } + + public static let synchronize = VirtioInputEvent(type: 0, code: 0, value: 0) + + fileprivate var bytes: [UInt8] { + var result = [UInt8]() + result.append(contentsOf: withUnsafeBytes(of: type.littleEndian, Array.init)) + result.append(contentsOf: withUnsafeBytes(of: code.littleEndian, Array.init)) + result.append(contentsOf: withUnsafeBytes(of: UInt32(bitPattern: value).littleEndian, Array.init)) + return result + } +} + +/// Converts AppKit scroll deltas into Linux evdev wheel events while retaining sub-tick movement. +/// `NSEvent.scrollingDelta*` already incorporates the host's natural-scrolling preference, and +/// Linux `REL_WHEEL*` uses the same sign for the resulting scroll gesture. Inverting here makes a +/// Linux desktop move opposite to the host gesture and also applies natural scrolling twice. +public struct VirtioInputScrollAccumulator: Sendable { + private var verticalRemainder: Double = 0 + private var horizontalRemainder: Double = 0 + + public init() {} + + public mutating func events( + horizontalDelta: Double, + verticalDelta: Double, + hasPreciseDeltas: Bool + ) -> [VirtioInputEvent] { + let scale: Double = hasPreciseDeltas ? 12 : 120 + var result = [VirtioInputEvent]() + Self.appendScrollEvents( + delta: verticalDelta, + scale: scale, + remainder: &verticalRemainder, + highResolutionCode: 11, + discreteCode: 8, + to: &result + ) + Self.appendScrollEvents( + delta: horizontalDelta, + scale: scale, + remainder: &horizontalRemainder, + highResolutionCode: 12, + discreteCode: 6, + to: &result + ) + return result + } + + private static func appendScrollEvents( + delta: Double, + scale: Double, + remainder: inout Double, + highResolutionCode: UInt16, + discreteCode: UInt16, + to result: inout [VirtioInputEvent] + ) { + // AppKit values cross a UI/process boundary. NaN, infinity, or a finite value whose scale + // overflows must never poison the retained remainder or trap an integer conversion. + guard delta.isFinite else { return } + let raw = delta * scale + let integerLimit = Double(Int32.max) - 120 + let bounded: Double + if raw.isFinite { + bounded = min(integerLimit, max(-integerLimit, raw)) + } else { + bounded = raw.sign == .minus ? -integerLimit : integerLimit + } + + remainder += bounded + let ticks = Int32(remainder / 120) + remainder -= Double(ticks) * 120 + let highResolution = Int32(bounded.rounded()) + if highResolution != 0 { + result.append(VirtioInputEvent( + type: 2, + code: highResolutionCode, + value: highResolution + )) + } + if ticks != 0 { + result.append(VirtioInputEvent(type: 2, code: discreteCode, value: ticks)) + } + } +} + +/// Tracks key and pointer-button state at the host display boundary. AppKit can deactivate a +/// window without delivering the matching keyUp/mouseUp events, so the frontend drains this state +/// as one atomic release frame whenever its window or application loses focus. +public struct VirtioInputPressedState: Sendable { + private var pressedCodes = Set() + + public init() {} + + public mutating func record(_ event: VirtioInputEvent) { + guard event.type == 1 else { return } + if event.value == 0 { + pressedCodes.remove(event.code) + } else if event.value > 0 { + pressedCodes.insert(event.code) + } + } + + public mutating func releaseFrame() -> [VirtioInputEvent] { + let releases = pressedCodes.sorted().map { + VirtioInputEvent(type: 1, code: $0, value: 0) + } + pressedCodes.removeAll(keepingCapacity: true) + return releases + } +} + +/// A virtio keyboard or absolute tablet endpoint. +/// +/// Linux classifies an input node from its complete capability bitmap. A single node advertising +/// both a full keyboard and absolute pointer axes is not equivalent to two HID devices and can be +/// classified as a keyboard by desktop input stacks, leaving its absolute position disconnected +/// from pointer hit-testing. Production desktop VMs therefore attach one `.keyboard` endpoint and +/// one `.absolutePointer` endpoint, matching the device boundary used by QEMU's virtio keyboard and +/// tablet implementations. `.combinedCompatibility` remains available only for existing callers +/// that need the historical wire shape. +/// +/// Host input is submitted as whole evdev frames ending in SYN_REPORT; a frame waits until the +/// guest has posted enough receive buffers, so Dory never delivers half of a pointer update. +public final class VirtioInput: VirtioDeviceBackend, @unchecked Sendable { + public enum Profile: Sendable, Equatable { + case keyboard + case absolutePointer + case combinedCompatibility + } + + public let deviceID: UInt32 = 18 + public let deviceFeatures: UInt64 = 0 + public let queueCount = 2 + public let kickSynchronization: VirtioKickSynchronization = .backendManaged + + private enum ConfigSelect { + static let name: UInt8 = 0x01 + static let serial: UInt8 = 0x02 + static let deviceIDs: UInt8 = 0x03 + static let propertyBits: UInt8 = 0x10 + static let eventBits: UInt8 = 0x11 + static let absoluteInfo: UInt8 = 0x12 + } + + private enum EventType { + static let synchronize: UInt8 = 0 + static let key: UInt8 = 1 + static let relative: UInt8 = 2 + static let absolute: UInt8 = 3 + static let led: UInt8 = 17 + } + + private struct PendingFrame { + let events: [VirtioInputEvent] + let submittedAtNanoseconds: UInt64 + } + + private struct PublicationFrame { + let chains: [VirtqueueChain] + let events: [VirtioInputEvent] + let submittedAtNanoseconds: UInt64? + let isReconciliation: Bool + } + + private struct WorkerRequest { + let generation: UInt64 + let transport: VirtioMMIOTransport + } + + private enum WorkerDrainOutcome { + case drained + case more + case fault + case stale + } + + private let lock = NSLock() + private let profile: Profile + private let limits: VirtioInputLimits + private let workerHooks: VirtioInputWorkerHooks + private let monotonicNanoseconds: @Sendable () -> UInt64 + private let workerQueue = DispatchQueue( + label: "com.dory.virtio-input.worker", + qos: .userInitiated, + autoreleaseFrequency: .workItem + ) + private let workerQueueKey = DispatchSpecificKey() + private weak var transport: VirtioMMIOTransport? + private var lifecycleGeneration: UInt64 = 1 + private var terminal = false + private var deviceIsReady = false + private var queueIsReady = [false, false] + private var requestedQueueMask: UInt8 = 0 + private var workerScheduled = false + private var selectedConfig: UInt8 = 0 + private var selectedSubconfig: UInt8 = 0 + private var availableEventBuffers = [VirtqueueChain]() + private var pendingFrames = [PendingFrame]() + private var desiredPressedCodes = Set() + private var publishedPressedCodes = Set() + private var needsStateReconciliation = false + private var statisticsState = VirtioInputStatistics( + submittedFrames: 0, + publishedFrames: 0, + publishedEvents: 0, + coalescedMotionFrames: 0, + droppedFrames: 0, + rejectedFrames: 0, + stateReconciliationEvents: 0, + invalidEventBuffers: 0, + invalidStatusBuffers: 0, + statusEvents: 0, + queueFaults: 0, + boundedDrainStops: 0, + workerTurns: 0, + workerYields: 0, + coalescedWorkerRequests: 0, + revokedWorkerTurns: 0, + pendingFrameSaturationEvents: 0, + pendingFrameDepth: 0, + pendingFrameHighWatermark: 0, + availableEventBufferDepth: 0, + availableEventBufferHighWatermark: 0, + eventQueueDepth: 0, + eventQueueHighWatermark: 0, + statusQueueDepth: 0, + statusQueueHighWatermark: 0, + publicationLatencyNanoseconds: 0, + maximumPublicationLatencyNanoseconds: 0 + ) + private let statusHandler: (@Sendable (VirtioInputEvent) -> Void)? + + public convenience init( + profile: Profile = .combinedCompatibility, + statusHandler: (@Sendable (VirtioInputEvent) -> Void)? = nil + ) { + self.init(profile: profile, limits: .production, statusHandler: statusHandler) + } + + init( + profile: Profile, + limits: VirtioInputLimits, + statusHandler: (@Sendable (VirtioInputEvent) -> Void)? = nil, + workerHooks: VirtioInputWorkerHooks = .none, + monotonicNanoseconds: @escaping @Sendable () -> UInt64 = { + DispatchTime.now().uptimeNanoseconds + } + ) { + self.profile = profile + self.limits = limits + self.statusHandler = statusHandler + self.workerHooks = workerHooks + self.monotonicNanoseconds = monotonicNanoseconds + workerQueue.setSpecific(key: workerQueueKey, value: 1) + } + + deinit { + lock.lock() + terminal = true + advanceGenerationLocked() + transport = nil + deviceIsReady = false + queueIsReady = [false, false] + requestedQueueMask = 0 + workerScheduled = false + availableEventBuffers.removeAll() + pendingFrames.removeAll() + updateDepthGaugesLocked() + lock.unlock() + if DispatchQueue.getSpecific(key: workerQueueKey) == nil { + workerQueue.sync {} + } + } + + public var configSpace: [UInt8] { + lock.lock() + let select = selectedConfig + let subselect = selectedSubconfig + lock.unlock() + + var payload = [UInt8]() + switch select { + case ConfigSelect.name where subselect == 0: + payload = Array(deviceName.utf8) + case ConfigSelect.serial where subselect == 0: + payload = Array(deviceSerial.utf8) + case ConfigSelect.deviceIDs where subselect == 0: + payload.appendLE(UInt16(0x06)) // BUS_VIRTUAL + payload.appendLE(UInt16(0xD072)) + payload.appendLE(deviceProductID) + payload.appendLE(profile == .combinedCompatibility ? UInt16(0x0001) : UInt16(0x0002)) + case ConfigSelect.propertyBits where subselect == 0: + // QEMU's proven virtio-tablet contract omits PROP_BITS entirely. An + // explicit one-byte zero bitmap is not the same wire shape and can + // change how Linux input classifiers interpret an absolute device. + payload = profile == .combinedCompatibility ? [0] : [] + case ConfigSelect.eventBits: + payload = eventBitmap(type: subselect) + case ConfigSelect.absoluteInfo + where profile != .keyboard && (subselect == 0 || subselect == 1): + payload.appendLE(UInt32(0)) + payload.appendLE(UInt32(32_767)) + payload.appendLE(UInt32(0)) + payload.appendLE(UInt32(0)) + // Match virtio-tablet's unspecified resolution. Advertising an + // arbitrary physical resolution makes libinput apply dimensions + // that do not describe this normalized virtual desktop. + payload.appendLE(profile == .combinedCompatibility ? UInt32(100) : UInt32(0)) + default: + break + } + + payload = Array(payload.prefix(128)) + var config = [select, subselect, UInt8(payload.count), 0, 0, 0, 0, 0] + config.append(contentsOf: payload) + config.append(contentsOf: repeatElement(0, count: 136 - config.count)) + return config + } + + public func writeConfig(offset: UInt64, value: UInt64, width: Int) { + guard offset < 2, width > 0 else { return } + lock.lock() + for index in 0..> UInt64(index * 8)) + if position == 0 { selectedConfig = byte } + if position == 1 { selectedSubconfig = byte } + } + lock.unlock() + } + + public func deviceReady(transport: VirtioMMIOTransport) { + let request: WorkerRequest? + lock.lock() + guard !terminal else { + lock.unlock() + return + } + advanceGenerationLocked() + self.transport = transport + deviceIsReady = true + queueIsReady = transport.queues.map(\.ready) + requestedQueueMask = 0 + workerScheduled = false + request = pendingFrames.isEmpty ? nil : requestWorkerLocked(queueMask: 1) + lock.unlock() + enqueueWorker(request) + } + + public func deviceReset(transport: VirtioMMIOTransport) { + lock.lock() + advanceGenerationLocked() + self.transport = nil + deviceIsReady = false + queueIsReady = [false, false] + requestedQueueMask = 0 + workerScheduled = false + availableEventBuffers.removeAll() + pendingFrames.removeAll() + desiredPressedCodes.removeAll() + publishedPressedCodes.removeAll() + needsStateReconciliation = false + updateDepthGaugesLocked() + statisticsState.eventQueueDepth = 0 + statisticsState.statusQueueDepth = 0 + lock.unlock() + } + + public func queueStateChanged(queue: Int, ready: Bool, transport: VirtioMMIOTransport) { + guard (0.. Bool { + events.count == 3 + && events[0].type == EventType.absolute + && events[0].code == 0 + && events[1].type == EventType.absolute + && events[1].code == 1 + && events[2] == .synchronize + } + + private static func isValidFrame(_ events: [VirtioInputEvent], profile: Profile) -> Bool { + guard events.last == .synchronize, + !events.dropLast().contains(.synchronize) else { return false } + return events.allSatisfy { event in + switch event.type { + case UInt16(EventType.synchronize): + return event == .synchronize + case UInt16(EventType.key): + let supportsCode: Bool + switch profile { + case .keyboard: + supportsCode = (1...255).contains(event.code) + case .absolutePointer: + supportsCode = (272...276).contains(event.code) + case .combinedCompatibility: + supportsCode = (1...255).contains(event.code) + || (272...276).contains(event.code) + } + return supportsCode && (0...2).contains(event.value) + case UInt16(EventType.relative): + guard profile != .keyboard else { return false } + return [UInt16(6), 8, 11, 12].contains(event.code) + case UInt16(EventType.absolute): + return profile != .keyboard + && (event.code == 0 || event.code == 1) + && (0...32_767).contains(event.value) + default: + return false + } + } + } + + private static func applyPressedState( + events: [VirtioInputEvent], + to state: inout Set + ) { + for event in events where event.type == UInt16(EventType.key) { + if event.value == 0 { + state.remove(event.code) + } else { + state.insert(event.code) + } + } + } + + private func advanceGenerationLocked() { + lifecycleGeneration = lifecycleGeneration == UInt64.max ? 1 : lifecycleGeneration + 1 + } + + private func readyQueueMaskLocked() -> UInt8 { + queueIsReady.enumerated().reduce(into: UInt8(0)) { mask, element in + if element.element { mask |= UInt8(1 << element.offset) } + } + } + + private func scheduleWorker(queueMask: UInt8, transport: VirtioMMIOTransport) { + let request: WorkerRequest? + lock.lock() + guard self.transport === transport else { + lock.unlock() + return + } + request = requestWorkerLocked(queueMask: queueMask) + lock.unlock() + enqueueWorker(request) + } + + private func requestWorkerLocked(queueMask: UInt8) -> WorkerRequest? { + guard !terminal, deviceIsReady, let transport else { return nil } + let admittedMask = queueMask & readyQueueMaskLocked() + guard admittedMask != 0 else { return nil } + requestedQueueMask |= admittedMask + if workerScheduled { + statisticsState.coalescedWorkerRequests &+= 1 + return nil + } + workerScheduled = true + return WorkerRequest(generation: lifecycleGeneration, transport: transport) + } + + private func enqueueWorker(_ request: WorkerRequest?) { + guard let request else { return } + let generation = request.generation + let transport = request.transport + workerQueue.async { [weak self, weak transport] in + guard let self, let transport else { return } + self.runWorker(generation: generation, transport: transport) + } + } + + private func runWorker(generation: UInt64, transport: VirtioMMIOTransport) { + let queueMask: UInt8 + lock.lock() + guard isCurrentWorkerLocked(generation: generation, transport: transport) else { + statisticsState.revokedWorkerTurns &+= 1 + lock.unlock() + return + } + queueMask = requestedQueueMask + requestedQueueMask = 0 + statisticsState.workerTurns &+= 1 + lock.unlock() + + workerHooks.beforeWorkerTurn?() + var continuationMask: UInt8 = 0 + if queueMask & 1 != 0 { + switch drainEventQueueTurn(generation: generation, transport: transport) { + case .more: + continuationMask |= 1 + case .stale: + recordRevokedWorkerTurn() + return + case .drained, .fault: + break + } + } + if queueMask & 2 != 0 { + switch drainStatusQueueTurn(generation: generation, transport: transport) { + case .more: + continuationMask |= 2 + case .stale: + recordRevokedWorkerTurn() + return + case .drained, .fault: + break + } + } + + let continuation: WorkerRequest? + lock.lock() + guard isCurrentWorkerLocked(generation: generation, transport: transport) else { + statisticsState.revokedWorkerTurns &+= 1 + lock.unlock() + return + } + requestedQueueMask |= continuationMask + if requestedQueueMask == 0 { + workerScheduled = false + continuation = nil + } else { + statisticsState.workerYields &+= 1 + continuation = WorkerRequest(generation: generation, transport: transport) + } + lock.unlock() + enqueueWorker(continuation) + } + + private func isCurrentWorkerLocked( + generation: UInt64, + transport: VirtioMMIOTransport + ) -> Bool { + !terminal + && deviceIsReady + && workerScheduled + && lifecycleGeneration == generation + && self.transport === transport + } + + private func recordRevokedWorkerTurn() { + lock.lock() + statisticsState.revokedWorkerTurns &+= 1 + lock.unlock() + } + + private func drainEventQueueTurn( + generation: UInt64, + transport: VirtioMMIOTransport + ) -> WorkerDrainOutcome { + transport.withQueueLock { + lock.lock() + let current = isCurrentWorkerLocked(generation: generation, transport: transport) + && queueIsReady[0] + lock.unlock() + guard current else { return .stale } + + let queue = transport.queues[0] + var wantsInterrupt = false + defer { + if wantsInterrupt { transport.notifyUsed() } + } + var popped = 0 + var queueFault = false + + do { + observeGuestQueueDepth(queue: 0, depth: Int(try queue.pendingCount())) + } catch { + recordQueueFault() + return .fault + } + + while popped < limits.maximumChainsPerWorkerTurn { + let chain: VirtqueueChain + do { + guard let next = try queue.pop() else { break } + chain = next + } catch { + recordQueueFault() + queueFault = true + break + } + popped += 1 + let valid = chain.withLeaseHeld { access in + !chain.containsZeroLengthDescriptor + && access.readableSegmentCount == 0 + && access.writableSegmentCount > 0 + && access.writableByteCount >= 8 + } ?? false + if valid { + lock.lock() + availableEventBuffers.append(chain) + updateDepthGaugesLocked() + lock.unlock() + } else { + lock.lock() + statisticsState.invalidEventBuffers &+= 1 + lock.unlock() + do { + wantsInterrupt = try queue.push(chain, written: 0) || wantsInterrupt + } catch { + recordQueueFault() + queueFault = true + break + } + } + } + + let guestDepth: Int + do { + guestDepth = Int(try queue.pendingCount()) + } catch { + recordQueueFault() + queueFault = true + guestDepth = 0 + } + observeGuestQueueDepth(queue: 0, depth: guestDepth) + guard !queueFault else { return .fault } + + var publishedThisTurn = 0 + var publicationFault = false + while publishedThisTurn < limits.maximumPublishedEventsPerWorkerTurn { + let publication: PublicationFrame? + lock.lock() + publication = selectPublicationLocked( + eventBudget: limits.maximumPublishedEventsPerWorkerTurn - publishedThisTurn + ) + lock.unlock() + guard let publication else { break } + + workerHooks.beforeEventPublication?() + var allWritesSucceeded = true + for (chain, event) in zip(publication.chains, publication.events) { + if chain.writeBytes(event.bytes) != 8 { + allWritesSucceeded = false + break + } + } + if !allWritesSucceeded { + lock.lock() + statisticsState.queueFaults &+= 1 + needsStateReconciliation = true + lock.unlock() + } + + var publishedEntireFrame = allWritesSucceeded + for (chain, event) in zip(publication.chains, publication.events) { + do { + wantsInterrupt = try queue.push( + chain, + written: allWritesSucceeded ? 8 : 0 + ) || wantsInterrupt + } catch { + lock.lock() + statisticsState.queueFaults &+= 1 + needsStateReconciliation = true + lock.unlock() + publicationFault = true + publishedEntireFrame = false + break + } + if allWritesSucceeded { + lock.lock() + Self.applyPressedState(events: [event], to: &publishedPressedCodes) + statisticsState.publishedEvents &+= 1 + if publication.isReconciliation, event != .synchronize { + statisticsState.stateReconciliationEvents &+= 1 + } + lock.unlock() + } + } + publishedThisTurn += publication.events.count + if publishedEntireFrame { + recordPublishedFrame(publication) + } + if publicationFault { break } + } + guard !publicationFault else { return .fault } + + lock.lock() + let publishable = hasPublishableFrameLocked() + lock.unlock() + let more = guestDepth > 0 || publishable + if more { + lock.lock() + statisticsState.boundedDrainStops &+= 1 + lock.unlock() + return .more + } + return .drained + } + } + + private func selectPublicationLocked(eventBudget: Int) -> PublicationFrame? { + if let frame = pendingFrames.first { + guard frame.events.count <= eventBudget, + availableEventBuffers.count >= frame.events.count else { return nil } + pendingFrames.removeFirst() + let chains = Array(availableEventBuffers.prefix(frame.events.count)) + availableEventBuffers.removeFirst(frame.events.count) + updateDepthGaugesLocked() + return PublicationFrame( + chains: chains, + events: frame.events, + submittedAtNanoseconds: frame.submittedAtNanoseconds, + isReconciliation: false + ) + } + guard needsStateReconciliation, + let frame = Self.reconciliationFrames( + from: publishedPressedCodes, + to: desiredPressedCodes, + maximumEventsPerFrame: limits.maximumEventsPerFrame + ).first, + frame.count <= eventBudget, + availableEventBuffers.count >= frame.count else { return nil } + let chains = Array(availableEventBuffers.prefix(frame.count)) + availableEventBuffers.removeFirst(frame.count) + updateDepthGaugesLocked() + return PublicationFrame( + chains: chains, + events: frame, + submittedAtNanoseconds: nil, + isReconciliation: true + ) + } + + private func hasPublishableFrameLocked() -> Bool { + if let frame = pendingFrames.first { + return availableEventBuffers.count >= frame.events.count + } + guard needsStateReconciliation, + let frame = Self.reconciliationFrames( + from: publishedPressedCodes, + to: desiredPressedCodes, + maximumEventsPerFrame: limits.maximumEventsPerFrame + ).first else { return false } + return availableEventBuffers.count >= frame.count + } + + private func recordPublishedFrame(_ publication: PublicationFrame) { + let finishedAt = monotonicNanoseconds() + lock.lock() + statisticsState.publishedFrames &+= 1 + if let startedAt = publication.submittedAtNanoseconds { + let latency = finishedAt >= startedAt ? finishedAt - startedAt : 0 + statisticsState.publicationLatencyNanoseconds &+= latency + statisticsState.maximumPublicationLatencyNanoseconds = max( + statisticsState.maximumPublicationLatencyNanoseconds, + latency + ) + } + if pendingFrames.isEmpty, publishedPressedCodes == desiredPressedCodes { + needsStateReconciliation = false + } + lock.unlock() + } + + private func drainStatusQueueTurn( + generation: UInt64, + transport: VirtioMMIOTransport + ) -> WorkerDrainOutcome { + let result: (outcome: WorkerDrainOutcome, events: [VirtioInputEvent]) = + transport.withQueueLock { + lock.lock() + let current = isCurrentWorkerLocked( + generation: generation, + transport: transport + ) && queueIsReady[1] + lock.unlock() + guard current else { return (.stale, []) } + + let queue = transport.queues[1] + var wantsInterrupt = false + defer { + if wantsInterrupt { transport.notifyUsed() } + } + var acceptedEvents = [VirtioInputEvent]() + var popped = 0 + var queueFault = false + do { + observeGuestQueueDepth(queue: 1, depth: Int(try queue.pendingCount())) + } catch { + recordQueueFault() + return (.fault, []) + } + while popped < limits.maximumChainsPerWorkerTurn { + let chain: VirtqueueChain + do { + guard let next = try queue.pop() else { break } + chain = next + } catch { + recordQueueFault() + queueFault = true + break + } + popped += 1 + let bytes = chain.withLeaseHeld { access -> [UInt8]? in + guard !chain.containsZeroLengthDescriptor, + access.readableSegmentCount > 0, + access.writableSegmentCount == 0, + access.readableByteCount >= 8 else { return nil } + return access.readBytes(maximum: 8) + } ?? nil + var event: VirtioInputEvent? + if let bytes, bytes.count == 8 { + let candidate = VirtioInputEvent( + type: bytes.leUInt16(at: 0), + code: bytes.leUInt16(at: 2), + value: Int32(bitPattern: bytes.leUInt32(at: 4)) + ) + if isSupportedStatusEvent(candidate) { event = candidate } + } + if event == nil { + lock.lock() + statisticsState.invalidStatusBuffers &+= 1 + lock.unlock() + } + do { + wantsInterrupt = try queue.push(chain, written: 0) || wantsInterrupt + } catch { + recordQueueFault() + queueFault = true + break + } + if let event { acceptedEvents.append(event) } + } + + let guestDepth: Int + do { + guestDepth = Int(try queue.pendingCount()) + } catch { + recordQueueFault() + queueFault = true + guestDepth = 0 + } + observeGuestQueueDepth(queue: 1, depth: guestDepth) + if queueFault { return (.fault, acceptedEvents) } + if guestDepth > 0 { + lock.lock() + statisticsState.boundedDrainStops &+= 1 + lock.unlock() + return (.more, acceptedEvents) + } + return (.drained, acceptedEvents) + } + + var delivered = 0 + for event in result.events { + lock.lock() + let current = lifecycleGeneration == generation + && self.transport === transport + && deviceIsReady + && !terminal + lock.unlock() + guard current else { break } + statusHandler?(event) + delivered += 1 + } + if delivered > 0 { + lock.lock() + statisticsState.statusEvents &+= UInt64(delivered) + lock.unlock() + } + return result.outcome + } + + private func observeGuestQueueDepth(queue: Int, depth: Int) { + lock.lock() + let value = UInt64(max(0, depth)) + if queue == 0 { + statisticsState.eventQueueDepth = value + statisticsState.eventQueueHighWatermark = max( + statisticsState.eventQueueHighWatermark, + value + ) + } else { + statisticsState.statusQueueDepth = value + statisticsState.statusQueueHighWatermark = max( + statisticsState.statusQueueHighWatermark, + value + ) + } + lock.unlock() + } + + private func updateDepthGaugesLocked() { + statisticsState.pendingFrameDepth = UInt64(pendingFrames.count) + statisticsState.pendingFrameHighWatermark = max( + statisticsState.pendingFrameHighWatermark, + statisticsState.pendingFrameDepth + ) + statisticsState.availableEventBufferDepth = UInt64(availableEventBuffers.count) + statisticsState.availableEventBufferHighWatermark = max( + statisticsState.availableEventBufferHighWatermark, + statisticsState.availableEventBufferDepth + ) + } + + private func isSupportedStatusEvent(_ event: VirtioInputEvent) -> Bool { + profile != .absolutePointer + && event.type == UInt16(EventType.led) + && (0...2).contains(event.code) + && (0...1).contains(event.value) + } + + private static func reconciliationFrames( + from published: Set, + to desired: Set, + maximumEventsPerFrame: Int + ) -> [[VirtioInputEvent]] { + let releases = published.subtracting(desired).sorted().map { + VirtioInputEvent(type: UInt16(EventType.key), code: $0, value: 0) + } + let presses = desired.subtracting(published).sorted().map { + VirtioInputEvent(type: UInt16(EventType.key), code: $0, value: 1) + } + let changes = releases + presses + guard !changes.isEmpty else { return [] } + let payloadLimit = maximumEventsPerFrame - 1 + return stride(from: 0, to: changes.count, by: payloadLimit).map { offset in + var frame = Array(changes[offset.. [UInt8] { + Self.bitmap(type: type, profile: profile) + } + + private static func bitmap(type: UInt8, profile: Profile) -> [UInt8] { + switch type { + case EventType.synchronize: + return bitmap(codes: [0]) + case EventType.key: + switch profile { + case .keyboard: + return bitmap(codes: Array(1...255)) + case .absolutePointer: + return bitmap(codes: Array(272...276)) + case .combinedCompatibility: + return bitmap(codes: Array(1...255) + Array(272...276)) + } + case EventType.relative: + switch profile { + case .keyboard: + return [] + case .absolutePointer: + // Dory emits both discrete and high-resolution wheel events in each axis. The + // capability bitmap must describe the stream Linux actually receives. + return bitmap(codes: [6, 8, 11, 12]) + case .combinedCompatibility: + return bitmap(codes: [6, 8, 11, 12]) + } + case EventType.absolute: + return profile == .keyboard ? [] : bitmap(codes: [0, 1]) + case EventType.led: + return profile == .absolutePointer ? [] : bitmap(codes: [0, 1, 2]) + default: + return [] + } + } + + private var deviceName: String { + switch profile { + case .keyboard: "Dory Virtio Keyboard" + case .absolutePointer: "Dory Virtio Tablet" + case .combinedCompatibility: "Dory keyboard and pointer" + } + } + + private var deviceSerial: String { + switch profile { + case .keyboard: "dory-keyboard-0" + case .absolutePointer: "dory-tablet-0" + case .combinedCompatibility: "dory-input-0" + } + } + + private var deviceProductID: UInt16 { + switch profile { + case .keyboard: 0x0001 + case .absolutePointer: 0x0003 + case .combinedCompatibility: 0x0001 + } + } + + private static func bitmap(codes: [Int]) -> [UInt8] { + guard let maximum = codes.max(), maximum >= 0 else { return [] } + var bytes = [UInt8](repeating: 0, count: maximum / 8 + 1) + for code in codes where code >= 0 { + bytes[code / 8] |= UInt8(1 << (code % 8)) + } + while bytes.last == 0 { bytes.removeLast() } + return bytes + } + + public var statistics: VirtioInputStatistics { + lock.lock() + defer { lock.unlock() } + return statisticsState + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift index ea526507..5e0ab66a 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift @@ -1,4 +1,32 @@ import Foundation +import Synchronization + +public struct VirtioMMIOTransportStatistics: Equatable, Sendable { + public var queueNotifications: UInt64 + public var queueStateChanges: UInt64 + public var usedInterrupts: UInt64 + public var configurationInterrupts: UInt64 + /// Calls that actually asserted the transport interrupt after coalescing an already-pending + /// status bit. Request counters above remain the stable record of backend notification demand. + public var emittedInterruptSignals: UInt64 + public var deviceResets: UInt64 + + public init( + queueNotifications: UInt64, + queueStateChanges: UInt64, + usedInterrupts: UInt64, + configurationInterrupts: UInt64, + emittedInterruptSignals: UInt64, + deviceResets: UInt64 + ) { + self.queueNotifications = queueNotifications + self.queueStateChanges = queueStateChanges + self.usedInterrupts = usedInterrupts + self.configurationInterrupts = configurationInterrupts + self.emittedInterruptSignals = emittedInterruptSignals + self.deviceResets = deviceResets + } +} /// Defines which layer serializes queue notifications with device lifecycle changes. public enum VirtioKickSynchronization: Equatable, Sendable { @@ -68,44 +96,101 @@ public final class VirtioMMIOTransport: MMIODevice { private var deviceFeatureSelect: UInt32 = 0 private var driverFeatureSelect: UInt32 = 0 private var driverFeatures: UInt64 = 0 - private var queueSelect: Int = 0 + private var queueSelect: UInt64 = 0 private var sharedMemorySelect: UInt32 = 0 private var status: UInt32 = 0 private var interruptStatus: UInt32 = 0 + private var configGeneration: UInt32 = 0 private let interruptLock = NSLock() // device backends may complete buffers off the vCPU thread private let registerLock = NSRecursiveLock() // SMP: register access and kicks arrive from any vCPU thread - private var pendingQueueLayout: [(descriptor: UInt64, avail: UInt64, used: UInt64, count: UInt16)] + private var pendingQueueLayout: [(descriptor: UInt64, avail: UInt64, used: UInt64, count: UInt64)] + private let queueNotificationCount = Atomic(0) + private let queueStateChangeCount = Atomic(0) + private let usedInterruptCount = Atomic(0) + private let configurationInterruptCount = Atomic(0) + private let emittedInterruptSignalCount = Atomic(0) + private let deviceResetCount = Atomic(0) private static let magic: UInt64 = 0x7472_6976 // "virt" private static let vendor: UInt64 = 0x792D_726F_64 // "dor-y" - private static let version1Feature: UInt64 = 1 << 32 + + private enum DeviceStatus { + static let driverOK: UInt32 = 1 << 2 + static let featuresOK: UInt32 = 1 << 3 + } + + private var offeredFeatures: UInt64 { + backend.deviceFeatures + | VirtqueueFeature.version1 + | VirtqueueFeature.indirectDescriptors + } + + private var driverFeaturesAreValid: Bool { + driverFeatures & VirtqueueFeature.version1 != 0 + && driverFeatures & ~offeredFeatures == 0 + } public init( baseAddress: UInt64, backend: VirtioDeviceBackend, memory: GuestMemory, + queueLimits: VirtqueueLimits = .hardenedDefault, interrupt: @escaping () -> Void ) { self.baseAddress = baseAddress self.backend = backend self.memory = memory self.interrupt = interrupt - self.queues = (0.. UnsafeMutableRawPointer { try memory.hostPointer(at: guestAddress, count: count) } + /// Returns an independently owned, path-free descriptor slice over guest RAM for an isolated + /// device worker. The transport remains the address-to-backing authority; backends never infer + /// descriptor offsets from raw host pointers. + func duplicateGuestMemoryRegion( + at guestAddress: UInt64, + count: UInt64 + ) throws -> GuestMemorySharedRegion { + try memory.duplicateSharedRegion(at: guestAddress, count: count) + } + + func guestMemoryRegionBounds( + at guestAddress: UInt64, + count: UInt64 + ) throws -> (offset: UInt64, length: UInt64, declaredFileSize: UInt64) { + try memory.sharedRegionBounds(at: guestAddress, count: count) + } + + func duplicateGuestMemoryBackingDescriptor() throws -> FileHandle { + try memory.duplicateSharedBackingDescriptor() + } + /// Runs `body` holding the register lock, so a device backend draining a queue off the vCPU /// thread (virtio-net RX) is serialized against guest MMIO that reconfigures or resets the same /// queue. Recursive: safe to call from inside handleKick, which already holds the lock. @@ -124,12 +209,16 @@ public final class VirtioMMIOTransport: MMIODevice { case 0x008: return UInt64(backend.deviceID) case 0x00C: return Self.vendor case 0x010: - let features = backend.deviceFeatures | Self.version1Feature - return deviceFeatureSelect == 0 ? features & 0xFFFF_FFFF : features >> 32 - case 0x034: return 256 // QueueNumMax + let features = offeredFeatures + switch deviceFeatureSelect { + case 0: return features & 0xFFFF_FFFF + case 1: return (features >> 32) & 0xFFFF_FFFF + default: return 0 + } + case 0x034: return Virtqueue.maximumSize // QueueNumMax case 0x044: - guard queueSelect < queues.count else { return 0 } - return queues[queueSelect].ready ? 1 : 0 + guard let index = selectedQueueIndex else { return 0 } + return queues[index].ready ? 1 : 0 case 0x060: interruptLock.lock() defer { interruptLock.unlock() } @@ -139,7 +228,7 @@ public final class VirtioMMIOTransport: MMIODevice { case 0x0B4: return selectedSharedMemoryRegion?.length.highUInt32 ?? UInt64(UInt32.max) case 0x0B8: return selectedSharedMemoryRegion?.guestBase.lowUInt32 ?? 0 case 0x0BC: return selectedSharedMemoryRegion?.guestBase.highUInt32 ?? 0 - case 0x0FC: return 0 // ConfigGeneration + case 0x0FC: return UInt64(configGeneration) case 0x100...: return readConfig(offset: offset - 0x100, width: width) default: @@ -154,10 +243,11 @@ public final class VirtioMMIOTransport: MMIODevice { // filesystem request. Every other backend retains the historical lock boundary below. if offset == 0x050, backend.kickSynchronization == .backendManaged { registerLock.lock() - let queue = Int(value) - let shouldKick = queue >= 0 && queue < queues.count + let queue = Int(exactly: value) + let shouldKick = queue.map(queues.indices.contains) ?? false registerLock.unlock() - if shouldKick { + if shouldKick, let queue { + queueNotificationCount.wrappingAdd(1, ordering: .relaxed) backend.handleKick(queue: queue, transport: self) } return @@ -168,31 +258,38 @@ public final class VirtioMMIOTransport: MMIODevice { switch offset { case 0x014: deviceFeatureSelect = UInt32(truncatingIfNeeded: value) case 0x020: - if driverFeatureSelect == 0 { + switch driverFeatureSelect { + case 0: driverFeatures = (driverFeatures & ~0xFFFF_FFFF) | (value & 0xFFFF_FFFF) - } else { - driverFeatures = (driverFeatures & 0xFFFF_FFFF) | (value << 32) + case 1: + let highWord = value & 0xFFFF_FFFF + driverFeatures = (driverFeatures & 0xFFFF_FFFF) | (highWord << 32) + default: + break } case 0x024: driverFeatureSelect = UInt32(truncatingIfNeeded: value) - case 0x030: queueSelect = Int(value) + case 0x030: queueSelect = value case 0x038: withSelectedQueue { index in - pendingQueueLayout[index].count = UInt16(clamping: value) + pendingQueueLayout[index].count = value } case 0x044: withSelectedQueue { index in - let ready = value & 1 == 1 - if ready { + queueStateChangeCount.wrappingAdd(1, ordering: .relaxed) + let requestedReady = value == 1 + let ready: Bool + if requestedReady { let layout = pendingQueueLayout[index] - queues[index].configure( - size: layout.count, + ready = queues[index].configure( + untrustedSize: layout.count, descriptorTable: layout.descriptor, availRing: layout.avail, usedRing: layout.used ) - queues[index].setReady(true) + && queues[index].setReady(true) } else { queues[index].setReady(false) + ready = false } // QueueReady=1 is also a reconfiguration event when the queue was already ready. // Notify on every write so a backend can synchronously revoke retained descriptors @@ -200,8 +297,8 @@ public final class VirtioMMIOTransport: MMIODevice { backend.queueStateChanged(queue: index, ready: ready, transport: self) } case 0x050: - let queue = Int(value) - if queue < queues.count { + if let queue = Int(exactly: value), queues.indices.contains(queue) { + queueNotificationCount.wrappingAdd(1, ordering: .relaxed) backend.handleKick(queue: queue, transport: self) } case 0x064: @@ -209,11 +306,36 @@ public final class VirtioMMIOTransport: MMIODevice { interruptStatus &= ~UInt32(truncatingIfNeeded: value) interruptLock.unlock() case 0x070: + let previousStatus = status status = UInt32(truncatingIfNeeded: value) if status == 0 { resetDevice() - } else if status & 0x4 != 0 { // DRIVER_OK - negotiatedFeatures = driverFeatures + break + } + + if status & DeviceStatus.featuresOK != 0 { + if driverFeaturesAreValid { + negotiatedFeatures = driverFeatures + for queue in queues { + queue.setNegotiatedFeatures(negotiatedFeatures) + } + } else { + // Virtio 1.2 section 3.1.1: the device clears FEATURES_OK when it cannot accept + // the complete feature set. Never silently mask unsupported driver bits. + status &= ~DeviceStatus.featuresOK + negotiatedFeatures = 0 + for queue in queues { queue.setNegotiatedFeatures(0) } + } + } + + // DRIVER_OK is meaningful only after the driver observes FEATURES_OK still set. Reject + // out-of-order readiness and call the backend once on the accepted rising edge. + if status & DeviceStatus.driverOK != 0, + status & DeviceStatus.featuresOK == 0 { + status &= ~DeviceStatus.driverOK + } + if status & DeviceStatus.driverOK != 0, + previousStatus & DeviceStatus.driverOK == 0 { backend.deviceReady(transport: self) } case 0x0AC: sharedMemorySelect = UInt32(truncatingIfNeeded: value) @@ -231,17 +353,58 @@ public final class VirtioMMIOTransport: MMIODevice { } private func resetDevice() { + deviceResetCount.wrappingAdd(1, ordering: .relaxed) backend.deviceReset(transport: self) for queue in queues { queue.reset() } - pendingQueueLayout = Array(repeating: (0, 0, 0, 0), count: backend.queueCount) + pendingQueueLayout = Array(repeating: (0, 0, 0, 0), count: queues.count) + interruptLock.lock() interruptStatus = 0 + interruptLock.unlock() negotiatedFeatures = 0 driverFeatures = 0 } + public var statistics: VirtioMMIOTransportStatistics { + VirtioMMIOTransportStatistics( + queueNotifications: queueNotificationCount.load(ordering: .relaxed), + queueStateChanges: queueStateChangeCount.load(ordering: .relaxed), + usedInterrupts: usedInterruptCount.load(ordering: .relaxed), + configurationInterrupts: configurationInterruptCount.load(ordering: .relaxed), + emittedInterruptSignals: emittedInterruptSignalCount.load(ordering: .relaxed), + deviceResets: deviceResetCount.load(ordering: .relaxed) + ) + } + + /// Virtio-MMIO exposes one status bit per event class, not an event count. Keep the bit set + /// until InterruptACK and emit only when this event class transitions from not-pending to + /// pending. This suppresses redundant host IRQ injections without losing a distinct event class + /// that becomes pending while another class is already set. + private func markInterruptPending(_ bits: UInt32) -> Bool { + interruptLock.lock() + let newlyPending = bits & ~interruptStatus + interruptStatus |= bits + interruptLock.unlock() + return newlyPending != 0 + } + + private func publishInterruptStatus(_ bits: UInt32) { + guard markInterruptPending(bits) else { return } + emitInterruptSignal() + } + + private func emitInterruptSignal() { + emittedInterruptSignalCount.wrappingAdd(1, ordering: .relaxed) + interrupt() + } + private func withSelectedQueue(_ body: (Int) -> Void) { - guard queueSelect < queues.count else { return } - body(queueSelect) + guard let index = selectedQueueIndex else { return } + body(index) + } + + private var selectedQueueIndex: Int? { + guard queueSelect < UInt64(queues.count) else { return nil } + return Int(queueSelect) } private func merge(_ current: UInt64, low: UInt64) -> UInt64 { @@ -254,10 +417,11 @@ public final class VirtioMMIOTransport: MMIODevice { private func readConfig(offset: UInt64, width: Int) -> UInt64 { let config = backend.configSpace + guard width > 0, let start = Int(exactly: offset), start < config.count else { return 0 } var value: UInt64 = 0 - for byteIndex in 0...size) { + let (position, overflow) = start.addingReportingOverflow(byteIndex) + guard !overflow, position < config.count else { break } value |= UInt64(config[position]) << (8 * byteIndex) } return value diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift index eff15e69..c81f892b 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift @@ -2,266 +2,2087 @@ import Darwin import Foundation import Synchronization +/// Event totals and bounded-resource gauges for one virtio-net backend. Event totals wrap modulo +/// 2^64. Queue depth, high-watermark, and latency maxima are point-in-time/high-water gauges. public struct VirtioNetStatistics: Equatable, Sendable { public var transmitPackets: UInt64 public var transmitBytes: UInt64 public var transmitDrops: UInt64 + public var transmitMalformed: UInt64 + public var transmitOversized: UInt64 + public var transmitInvalidDescriptors: UInt64 + public var transmitBackpressure: UInt64 + public var transmitSocketErrors: UInt64 = 0 + public var transmitRetryWakeups: UInt64 = 0 + public var transmitBoundedDrainStops: UInt64 = 0 + public var transmitCompletions: UInt64 = 0 + public var transmitCompletionLatencyNanoseconds: UInt64 = 0 + public var transmitMaximumCompletionLatencyNanoseconds: UInt64 = 0 + public var transmitOldestPendingLatencyNanoseconds: UInt64 = 0 + public var transmitQueueDepth: UInt64 = 0 + public var transmitQueueHighWatermark: UInt64 = 0 public var receivePackets: UInt64 public var receiveBytes: UInt64 public var receiveDeferred: UInt64 public var receiveDrops: UInt64 + /// Datagram payloads larger than the configured Ethernet ceiling. Kept under the established + /// telemetry name because the bounded recv buffer necessarily truncates their discarded tail. public var receiveTruncations: UInt64 + public var receiveMalformed: UInt64 + public var receiveInvalidDescriptors: UInt64 + public var receiveInsufficientCapacity: UInt64 + public var receiveBacklogDrops: UInt64 + public var receiveInactiveDrops: UInt64 + public var receiveSocketErrors: UInt64 + public var receiveActivationFailures: UInt64 } -/// virtio-net wired to a userspace network stack (gvproxy) over a unix datagram socket, one -/// ethernet frame per datagram (the vfkit protocol). No offloads: VERSION_1 + MAC only, so the -/// 12-byte header is constant and the stack stays trivially small. No host entitlement needed. +/// Immutable per-device work and memory limits. They are resolved when the backend is constructed; +/// the datapath never derives scheduling or capacity from ambient host CPU/global state. +struct VirtioNetLimits: Equatable, Sendable { + static let production = VirtioNetLimits( + maximumDeferredReceiveFrames: 256, + maximumDeferredReceiveBytes: 4 * 1_024 * 1_024, + maximumSocketReceiveOperationsPerTurn: 128, + maximumSocketReceiveBytesPerTurn: 1 * 1_024 * 1_024, + maximumActivationPurgeTurns: 64, + maximumTransmitOperationsPerTurn: 64, + maximumTransmitBytesPerTurn: 256 * 1_024, + minimumTransmitRetryDelayNanoseconds: 250_000, + maximumTransmitRetryDelayNanoseconds: 8_000_000 + ) + + let maximumDeferredReceiveFrames: Int + let maximumDeferredReceiveBytes: Int + let maximumSocketReceiveOperationsPerTurn: Int + let maximumSocketReceiveBytesPerTurn: Int + let maximumActivationPurgeTurns: Int + let maximumTransmitOperationsPerTurn: Int + let maximumTransmitBytesPerTurn: Int + let minimumTransmitRetryDelayNanoseconds: Int + let maximumTransmitRetryDelayNanoseconds: Int + + init( + maximumDeferredReceiveFrames: Int, + maximumDeferredReceiveBytes: Int, + maximumSocketReceiveOperationsPerTurn: Int = 128, + maximumSocketReceiveBytesPerTurn: Int = 1 * 1_024 * 1_024, + maximumActivationPurgeTurns: Int = 64, + maximumTransmitOperationsPerTurn: Int = 64, + maximumTransmitBytesPerTurn: Int = 256 * 1_024, + minimumTransmitRetryDelayNanoseconds: Int = 250_000, + maximumTransmitRetryDelayNanoseconds: Int = 8_000_000 + ) { + self.maximumDeferredReceiveFrames = maximumDeferredReceiveFrames + self.maximumDeferredReceiveBytes = maximumDeferredReceiveBytes + self.maximumSocketReceiveOperationsPerTurn = maximumSocketReceiveOperationsPerTurn + self.maximumSocketReceiveBytesPerTurn = maximumSocketReceiveBytesPerTurn + self.maximumActivationPurgeTurns = maximumActivationPurgeTurns + self.maximumTransmitOperationsPerTurn = maximumTransmitOperationsPerTurn + self.maximumTransmitBytesPerTurn = maximumTransmitBytesPerTurn + self.minimumTransmitRetryDelayNanoseconds = minimumTransmitRetryDelayNanoseconds + self.maximumTransmitRetryDelayNanoseconds = maximumTransmitRetryDelayNanoseconds + } +} + +/// virtio-net wired to a userspace network stack (gvproxy) over a Unix datagram socket, one +/// Ethernet frame per datagram (the vfkit protocol). Dory offers MAC and MTU only: it does not +/// negotiate checksum, segmentation, mergeable-buffer, or guest-offload features. public final class VirtioNet: VirtioDeviceBackend, @unchecked Sendable { public let deviceID: UInt32 = 1 public let queueCount = 2 // 0 = receive, 1 = transmit - public var deviceFeatures: UInt64 { 1 << 5 } // VIRTIO_NET_F_MAC + public let deviceFeatures: UInt64 + public let kickSynchronization: VirtioKickSynchronization = .backendManaged /// gvproxy's canonical vfkit guest MAC; its DHCP hands this MAC 192.168.127.2. public static let guestMAC: [UInt8] = [0x5A, 0x94, 0xEF, 0xE4, 0x0C, 0xEE] private static let headerLength = 12 + private static let ethernetHeaderLength = 14 + private static let minimumSupportedMTU = 1_280 + private static let maximumSupportedMTU = 9_000 private static let vfkitMagic: [UInt8] = Array("VFKT".utf8) - /// One full virtqueue of frames absorbs the normal refill gap without letting an otherwise - /// transient RX-buffer shortage turn into TCP loss. Beyond this bound we account and drop, - /// rather than allowing an unbounded host allocation under a hostile or wedged guest. - private static let maximumDeferredReceiveFrames = 256 - private let socketFD: Int32 - private let localSocketPath: String - private var receiveSource: (any DispatchSourceRead)? - private weak var transport: VirtioMMIOTransport? - private let receiveQueue = DispatchQueue(label: "dory-hv.net.rx") - private var deferredReceiveFrames = [[UInt8]]() - private var deferredReceiveHead = 0 + private static let knownHeaderFlagsMask: UInt8 = 0x07 + private static let socketPathMutationLock = NSLock() + + private struct SocketPathIdentity: Equatable, Sendable { + let device: dev_t + let inode: ino_t + let generation: UInt32 + let birthTimeSeconds: Int64 + let birthTimeNanoseconds: Int64 + let owner: uid_t + } + + private struct DirectoryIdentity: Equatable, Sendable { + let device: dev_t + let inode: ino_t + let generation: UInt32 + let birthTimeSeconds: Int64 + let birthTimeNanoseconds: Int64 + let owner: uid_t + let permissions: mode_t + } + + private enum ExistingEndpointProbe { + case live + case stale + case indeterminate(Int32) + } + + private final class SocketOwner: @unchecked Sendable { + let descriptor: Int32 + let localPath: String + let localIdentity: SocketPathIdentity + let parentPath: String + let parentIdentity: DirectoryIdentity + let usedPathnamePeerAuthentication: Bool + private let lock = NSLock() + private var acceptsOperations = true + private var hasRetired = false + + init( + descriptor: Int32, + localPath: String, + localIdentity: SocketPathIdentity, + parentPath: String, + parentIdentity: DirectoryIdentity, + usedPathnamePeerAuthentication: Bool + ) { + self.descriptor = descriptor + self.localPath = localPath + self.localIdentity = localIdentity + self.parentPath = parentPath + self.parentIdentity = parentIdentity + self.usedPathnamePeerAuthentication = usedPathnamePeerAuthentication + } + + func withDescriptor(_ body: (Int32) -> Result) -> Result? { + lock.lock() + defer { lock.unlock() } + guard acceptsOperations, !hasRetired else { return nil } + return body(descriptor) + } + + func disableOperations() { + lock.lock() + acceptsOperations = false + lock.unlock() + } + + func retire() { + lock.lock() + acceptsOperations = false + guard !hasRetired else { + lock.unlock() + return + } + hasRetired = true + lock.unlock() + VirtioNet.retireOwnedSocket( + descriptor: descriptor, + path: localPath, + identity: localIdentity, + parentPath: parentPath, + parentIdentity: parentIdentity + ) + } + } + + private final class WeakTransportReference: @unchecked Sendable { + weak var value: VirtioMMIOTransport? + + init(_ value: VirtioMMIOTransport) { + self.value = value + } + } + + private struct ReadyTransport { + let generation: UInt64 + let transport: VirtioMMIOTransport + } + + private struct PendingReceiveEpoch: Sendable { + let generation: UInt64 + let transport: WeakTransportReference + let completedPurgeTurns: Int + } + + private struct ReceiveSourceRegistration { + let source: any DispatchSourceRead + let cancellation: DispatchSemaphore + } + + private final class TransmitRetryRegistration: @unchecked Sendable { + let generation: UInt64 + let source: any DispatchSourceTimer + let cancellation = DispatchSemaphore(value: 0) + + init(generation: UInt64, source: any DispatchSourceTimer) { + self.generation = generation + self.source = source + } + } + + private struct TransmitHeadObservation: Sendable { + let lease: VirtqueueLease + let head: UInt16 + let firstObservedNanoseconds: UInt64 + + func matches(_ chain: VirtqueueChain) -> Bool { + lease == chain.lease && head == chain.head + } + } + + private struct TransmitState: Sendable { + var generation: UInt64 = 1 + var transport: WeakTransportReference? + var terminal = false + var drainScheduled = false + var kickPending = false + var retryRegistration: TransmitRetryRegistration? + var consecutiveTransientFailures = 0 + var headObservation: TransmitHeadObservation? + var queueDepth = 0 + var queueHighWatermark = 0 + var maximumCompletionLatencyNanoseconds: UInt64 = 0 + + mutating func advanceGeneration() { + generation &+= 1 + if generation == 0 { generation = 1 } + } + + mutating func observeQueueDepth(_ depth: Int) { + queueDepth = max(0, depth) + queueHighWatermark = max(queueHighWatermark, queueDepth) + } + + mutating func clearLifecycleState() -> TransmitRetryRegistration? { + let retry = retryRegistration + retryRegistration = nil + drainScheduled = false + kickPending = false + consecutiveTransientFailures = 0 + headObservation = nil + queueDepth = 0 + return retry + } + } + + private enum TransmitRejection: Error { + case invalidDescriptor + case malformed + case oversized + } + + private enum TransmitPreparation { + case empty + case frame(chain: VirtqueueChain, bytes: [UInt8], depth: Int) + case rejected(wantsInterrupt: Bool, depth: Int, observedAt: UInt64) + case queueFault(depth: Int) + case stale + } + + private enum TransmitFinalization { + case published(wantsInterrupt: Bool, depth: Int) + case queueFault(depth: Int) + case stale + } + + private struct DeferredFrame { + let generation: UInt64 + let bytes: [UInt8] + } + + private struct ReceiveState { + var generation: UInt64 = 0 + var transport: WeakTransportReference? + var deviceIsReady = false + var receiveQueueIsReady = false + var terminal = false + var deferredFrames = [DeferredFrame]() + var deferredHead = 0 + var deferredBytes = 0 + + var deferredCount: Int { deferredFrames.count - deferredHead } + + mutating func advanceGeneration() -> UInt64 { + generation &+= 1 + // Keep zero as the never-ready sentinel even after the practically unreachable wrap. + if generation == 0 { generation = 1 } + return generation + } + + mutating func clearDeferredFrames() -> Int { + let removed = deferredCount + deferredFrames.removeAll(keepingCapacity: true) + deferredHead = 0 + deferredBytes = 0 + return removed + } + + mutating func dequeueDeferredFrame(generation expectedGeneration: UInt64) { + guard deferredHead < deferredFrames.count, + deferredFrames[deferredHead].generation == expectedGeneration else { return } + let removedBytes = deferredFrames[deferredHead].bytes.count + deferredHead += 1 + deferredBytes -= removedBytes + if deferredHead == deferredFrames.count { + deferredFrames.removeAll(keepingCapacity: true) + deferredHead = 0 + } else if deferredHead >= 64 { + deferredFrames.removeFirst(deferredHead) + deferredHead = 0 + } + } + } + + private enum DescriptorDisposition { + case writable + case wrongDirection + case insufficientCapacity + } + + private enum DeliveryResult { + case delivered(wantsInterrupt: Bool) + case awaitingBuffer(wantsInterrupt: Bool) + case stale + case queueFault(wantsInterrupt: Bool) + } + + private enum DeferredDrainResult: Equatable { + case drained + case waiting + case queueFault + } + + private enum DeferredAdmission { + case accepted + case atCapacity + case stale + } + + private enum ReceivedDatagram { + case frame([UInt8], ReadyTransport?) + case empty(ReadyTransport?) + case unavailable + case retry + case failed(Int32) + } + + private enum InactivePurgeResult { + case drained + case budgetExhausted + case failed(Int32) + } + + private let socketOwner: SocketOwner + private let macAddress: [UInt8] + private let maximumTransmissionUnit: UInt16 + private let maximumEthernetFrameLength: Int + private let limits: VirtioNetLimits + private let transmitQueue = DispatchQueue( + label: "dory-hv.net.tx", + qos: .userInitiated + ) + private let transmitQueueKey = DispatchSpecificKey() + private let transmitState = Mutex(TransmitState()) + private let transmitOperationForTesting: (@Sendable ([UInt8]) -> (count: Int, code: Int32))? + private let receiveQueue = DispatchQueue( + label: "dory-hv.net.rx", + qos: RawHVSchedulingPolicy.networkIOWorkerDispatchQoS + ) + private let receiveQueueKey = DispatchSpecificKey() + /// Serializes recv() with the pre-ready purge. It is never held while taking the transport lock. + private let socketReceiveLock = NSLock() + private let receiveState = Mutex(ReceiveState()) + /// Publishes the source and its cancellation fence as one lifecycle unit. A stale activation + /// callback can race a newer queue epoch, so source creation must be terminal-aware and + /// single-winner independently of transport serialization. + private let receiveSourceLock = NSLock() + private var receiveSourceRegistration: ReceiveSourceRegistration? private let transmitPackets = Atomic(0) private let transmitBytes = Atomic(0) private let transmitDrops = Atomic(0) + private let transmitMalformed = Atomic(0) + private let transmitOversized = Atomic(0) + private let transmitInvalidDescriptors = Atomic(0) + private let transmitBackpressure = Atomic(0) + private let transmitSocketErrors = Atomic(0) + private let transmitRetryWakeups = Atomic(0) + private let transmitBoundedDrainStops = Atomic(0) + private let transmitCompletions = Atomic(0) + private let transmitCompletionLatencyNanoseconds = Atomic(0) private let receivePackets = Atomic(0) private let receiveBytes = Atomic(0) private let receiveDeferred = Atomic(0) private let receiveDrops = Atomic(0) private let receiveTruncations = Atomic(0) + private let receiveMalformed = Atomic(0) + private let receiveInvalidDescriptors = Atomic(0) + private let receiveInsufficientCapacity = Atomic(0) + private let receiveBacklogDrops = Atomic(0) + private let receiveInactiveDrops = Atomic(0) + private let receiveSocketErrors = Atomic(0) + private let receiveActivationFailures = Atomic(0) - public init(socketPath: String, remotePath: String) throws { - try Self.validateSocketPath(socketPath) - try Self.validateSocketPath(remotePath) - let descriptor = socket(AF_UNIX, SOCK_DGRAM, 0) - guard descriptor >= 0 else { - throw VMError.invalidConfiguration("cannot create datagram socket: errno \(errno)") - } - var didBind = false - do { - unlink(socketPath) - var local = sockaddr_un() - local.sun_family = sa_family_t(AF_UNIX) - Self.copyPath(socketPath, into: &local) - let bindResult = withUnsafePointer(to: &local) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { address in - bind(descriptor, address, socklen_t(MemoryLayout.size)) - } - } - guard bindResult == 0 else { - throw VMError.invalidConfiguration("cannot bind \(socketPath): errno \(errno)") - } - didBind = true + public convenience init( + socketPath: String, + remotePath: String, + macAddress: [UInt8] = VirtioNet.guestMAC, + maximumTransmissionUnit: UInt16 + ) throws { + try self.init( + socketPath: socketPath, + remotePath: remotePath, + macAddress: macAddress, + maximumTransmissionUnit: maximumTransmissionUnit, + limits: .production + ) + } - var remote = sockaddr_un() - remote.sun_family = sa_family_t(AF_UNIX) - Self.copyPath(remotePath, into: &remote) - let connectResult = withUnsafePointer(to: &remote) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { address in - connect(descriptor, address, socklen_t(MemoryLayout.size)) - } - } - guard connectResult == 0 else { - throw VMError.invalidConfiguration("cannot connect \(remotePath): errno \(errno)") - } + init( + socketPath: String, + remotePath: String, + macAddress: [UInt8] = VirtioNet.guestMAC, + maximumTransmissionUnit: UInt16, + limits: VirtioNetLimits, + transmitOperationForTesting: (@Sendable ([UInt8]) -> (count: Int, code: Int32))? = nil + ) throws { + guard macAddress.count == 6 else { + throw VMError.invalidConfiguration("a virtio-net MAC address must contain six bytes") + } + guard (Self.minimumSupportedMTU...Self.maximumSupportedMTU) + .contains(Int(maximumTransmissionUnit)) else { + throw VMError.invalidConfiguration( + "virtio-net MTU must be \(Self.minimumSupportedMTU)...\(Self.maximumSupportedMTU) bytes" + ) + } + guard limits.maximumDeferredReceiveFrames > 0, + limits.maximumDeferredReceiveBytes > 0, + limits.maximumSocketReceiveOperationsPerTurn > 0, + limits.maximumActivationPurgeTurns > 0, + limits.maximumTransmitOperationsPerTurn > 0, + limits.maximumTransmitBytesPerTurn > 0, + limits.minimumTransmitRetryDelayNanoseconds > 0, + limits.maximumTransmitRetryDelayNanoseconds + >= limits.minimumTransmitRetryDelayNanoseconds else { + throw VMError.invalidConfiguration("virtio-net work and retry limits are invalid") + } - // Match vfkit's reference socket sizing. The proxy side has a 4 MiB receive buffer; - // giving Dory 4 MiB on RX keeps short guest scheduling gaps from becoming datagram loss. - try Self.setSocketBuffer(descriptor, option: SO_SNDBUF, bytes: 1 << 20) - try Self.setSocketBuffer(descriptor, option: SO_RCVBUF, bytes: 4 << 20) + let maximumFrameLength = Int(maximumTransmissionUnit) + Self.ethernetHeaderLength + guard limits.maximumSocketReceiveBytesPerTurn >= maximumFrameLength + 1 else { + throw VMError.invalidConfiguration( + "virtio-net socket byte budget must hold one maximum-size datagram" + ) + } + guard limits.maximumTransmitBytesPerTurn >= maximumFrameLength else { + throw VMError.invalidConfiguration( + "virtio-net transmit byte budget must hold one maximum-size frame" + ) + } + let ownedSocket = try Self.makeOwnedConnectedSocket( + socketPath: socketPath, + remotePath: remotePath + ) - // Queue notifications run under VirtioMMIOTransport's register lock. A blocking send - // here could therefore stop every MMIO access and queue transition for this device if - // gvproxy stopped draining its datagram socket. Keep the descriptor nonblocking for - // its entire operational lifetime; individual TX calls also use MSG_DONTWAIT below as - // defense in depth against an accidental future flags change. - try Self.setNonBlocking(descriptor) + self.socketOwner = ownedSocket + self.macAddress = macAddress + self.maximumTransmissionUnit = maximumTransmissionUnit + self.maximumEthernetFrameLength = maximumFrameLength + self.limits = limits + self.transmitOperationForTesting = transmitOperationForTesting + self.deviceFeatures = (1 << 5) | (1 << 3) + self.transmitQueue.setSpecific( + key: self.transmitQueueKey, + value: 1 + ) + self.receiveQueue.setSpecific( + key: self.receiveQueueKey, + value: 1 + ) + } - // gvproxy <= 0.8.6 requires this handshake; newer releases deliberately retain support - // for it while also accepting the first Ethernet frame directly. - let magicBytes = Self.vfkitMagic.withUnsafeBytes { - send(descriptor, $0.baseAddress, $0.count, MSG_DONTWAIT) + deinit { + let retry = transmitState.withLock { state -> TransmitRetryRegistration? in + state.terminal = true + state.advanceGeneration() + state.transport = nil + return state.clearLifecycleState() + } + retry?.source.cancel() + if DispatchQueue.getSpecific(key: transmitQueueKey) == nil { + if let retry { + _ = retry.cancellation.wait(timeout: .now() + 2) } - guard magicBytes == Self.vfkitMagic.count else { - throw VMError.invalidConfiguration("cannot register vfkit peer: errno \(errno)") + // Joins an active bounded drain and every cancellation handler already queued by a + // lifecycle transition before the connected socket can be retired below. + transmitQueue.sync {} + } + receiveState.withLock { + _ = $0.advanceGeneration() + $0.transport = nil + $0.deviceIsReady = false + $0.receiveQueueIsReady = false + $0.terminal = true + _ = $0.clearDeferredFrames() + } + if let registration = receiveSourceRegistrationSnapshot() { + registration.source.cancel() + // Dispatch guarantees the cancel handler follows all source handlers. Bounded waiting + // here gives normal VM teardown deterministic pathname retirement without ever closing + // a descriptor underneath a live handler. If deinit itself runs on the receive queue, + // the handler completes asynchronously to avoid self-deadlock. + if DispatchQueue.getSpecific(key: receiveQueueKey) == nil { + _ = registration.cancellation.wait(timeout: .now() + 2) } - } catch { - close(descriptor) - if didBind { unlink(socketPath) } - throw error + } else { + socketOwner.retire() } - self.socketFD = descriptor - self.localSocketPath = socketPath } - deinit { - if let receiveSource { - receiveSource.cancel() + public var configSpace: [UInt8] { + // virtio_net_config uses fixed offsets: MAC[0...5], status[6...7], max queue pairs + // [8...9], and MTU[10...11]. Only MAC and MTU are negotiated here. + return macAddress + [0, 0, 0, 0] + + [UInt8(truncatingIfNeeded: maximumTransmissionUnit), + UInt8(truncatingIfNeeded: maximumTransmissionUnit >> 8)] + } + + public func deviceReady(transport: VirtioMMIOTransport) { + bindTransmitTransport(transport) + // Frames queued before DRIVER_OK belong to no live device generation. Publish no transport + // until a bounded purge reaches EAGAIN; a burst larger than one work turn continues on the + // receive queue, while a continuously sending peer leaves activation fail-closed. + let transition = receiveState.withLock { + state -> (discarded: Int, accepted: Bool, epoch: PendingReceiveEpoch?) in + guard !state.terminal else { return (0, false, nil) } + let discarded = state.clearDeferredFrames() + state.deviceIsReady = true + state.receiveQueueIsReady = transport.queues[0].ready + state.transport = nil + let generation = state.advanceGeneration() + let epoch = state.receiveQueueIsReady + ? PendingReceiveEpoch( + generation: generation, + transport: WeakTransportReference(transport), + completedPurgeTurns: 0 + ) + : nil + return (discarded, true, epoch) + } + accountInactiveDrops(transition.discarded) + guard transition.accepted else { return } + if let epoch = transition.epoch { + continueReceiveEpochActivation(epoch) } else { - close(socketFD) - unlink(localSocketPath) + startReceiveSourceIfNeeded() } } - public var configSpace: [UInt8] { Self.guestMAC } - - public func deviceReady(transport: VirtioMMIOTransport) { - self.transport = transport - guard receiveSource == nil else { return } - let source = DispatchSource.makeReadSource(fileDescriptor: socketFD, queue: receiveQueue) + private func startReceiveSourceIfNeeded() { + receiveSourceLock.lock() + guard receiveSourceRegistration == nil, + receiveState.withLock({ !$0.terminal }) else { + receiveSourceLock.unlock() + return + } + let source = DispatchSource.makeReadSource( + fileDescriptor: socketOwner.descriptor, + queue: receiveQueue + ) source.setEventHandler { [weak self] in self?.drainSocket() } - source.setCancelHandler { [socketFD, localSocketPath] in - close(socketFD) - unlink(localSocketPath) + let socketOwner = socketOwner + let cancellation = DispatchSemaphore(value: 0) + source.setCancelHandler { + socketOwner.retire() + cancellation.signal() } + receiveSourceRegistration = ReceiveSourceRegistration( + source: source, + cancellation: cancellation + ) source.resume() - receiveSource = source - receiveQueue.async { [weak self] in self?.drainSocket() } + receiveSourceLock.unlock() + receiveQueue.async { [weak self] in + self?.drainSocket() + } + } + + private func receiveSourceRegistrationSnapshot() -> ReceiveSourceRegistration? { + receiveSourceLock.lock() + defer { receiveSourceLock.unlock() } + return receiveSourceRegistration + } + + public func deviceReset(transport: VirtioMMIOTransport) { + revokeTransmitLifecycle(replacementTransport: nil) + let discarded = receiveState.withLock { state -> Int in + _ = state.advanceGeneration() + state.transport = nil + state.deviceIsReady = false + state.receiveQueueIsReady = false + return state.clearDeferredFrames() + } + accountInactiveDrops(discarded) + } + + public func queueStateChanged(queue: Int, ready: Bool, transport: VirtioMMIOTransport) { + if queue == 1 { + revokeTransmitLifecycle( + replacementTransport: ready ? WeakTransportReference(transport) : nil + ) + return + } + guard queue == 0 else { return } + // Every QueueReady write is a queue-epoch boundary. Revoke pending frames and late + // callbacks from the previous epoch. A newly enabled queue stays fail-closed until a + // socket-serialized, bounded purge proves no disabled-epoch datagrams remain. + let transition = receiveState.withLock { + state -> (discarded: Int, epoch: PendingReceiveEpoch?) in + guard !state.terminal else { return (0, nil) } + let generation = state.advanceGeneration() + state.receiveQueueIsReady = ready + state.transport = nil + let epoch = state.deviceIsReady && ready + ? PendingReceiveEpoch( + generation: generation, + transport: WeakTransportReference(transport), + completedPurgeTurns: 0 + ) + : nil + return (state.clearDeferredFrames(), epoch) + } + accountInactiveDrops(transition.discarded) + if let epoch = transition.epoch { + continueReceiveEpochActivation(epoch) + } + } + + private func continueReceiveEpochActivation(_ epoch: PendingReceiveEpoch) { + socketReceiveLock.lock() + guard isPending(epoch) else { + socketReceiveLock.unlock() + return + } + switch discardQueuedDatagramsAsInactive() { + case .drained: + let activated = receiveState.withLock { state -> Bool in + guard state.generation == epoch.generation, + state.deviceIsReady, + state.receiveQueueIsReady, + !state.terminal, + epoch.transport.value != nil else { return false } + state.transport = epoch.transport + return true + } + socketReceiveLock.unlock() + if activated { startReceiveSourceIfNeeded() } + case .budgetExhausted: + socketReceiveLock.unlock() + let completedTurns = epoch.completedPurgeTurns + 1 + guard completedTurns < limits.maximumActivationPurgeTurns else { + failReceiveActivation() + return + } + let continuation = PendingReceiveEpoch( + generation: epoch.generation, + transport: epoch.transport, + completedPurgeTurns: completedTurns + ) + receiveQueue.async { [weak self] in + self?.continueReceiveEpochActivation(continuation) + } + case let .failed(code): + socketReceiveLock.unlock() + handleSocketReceiveFailure(code) + } + } + + private func isPending(_ epoch: PendingReceiveEpoch) -> Bool { + receiveState.withLock { state in + state.generation == epoch.generation + && state.deviceIsReady + && state.receiveQueueIsReady + && !state.terminal + && epoch.transport.value != nil + } } public func handleKick(queue: Int, transport: VirtioMMIOTransport) { if queue == 0 { - // Linux notifies the receive queue when it replenishes buffers. Drain on the same serial - // queue as the socket source so the saved frame order and socket reads cannot race. - receiveQueue.async { [weak self] in self?.drainSocket() } + // Linux notifies the receive queue when it replenishes buffers. Drain on the same + // serial queue as socket events so deferred and newly received frame order is stable. + receiveQueue.async { [weak self] in + self?.drainSocket() + } return } guard queue == 1 else { return } - let virtqueue = transport.queues[1] - var interrupt = false - while let chain = (try? virtqueue.pop()) ?? nil { - let frame = chain.readBytes() - if frame.count > Self.headerLength { - let payloadCount = frame.count - Self.headerLength - let sent = frame[Self.headerLength...].withUnsafeBytes { buffer in - send(socketFD, buffer.baseAddress, buffer.count, MSG_DONTWAIT) + scheduleTransmitDrain(for: transport) + } + + /// A kick only publishes work to the serial TX executor. VirtioMMIO invokes this backend after + /// releasing its register lock, so a vCPU never performs descriptor walks, copies, send(), or a + /// whole-ring drain on the MMIO write path. + private func scheduleTransmitDrain(for transport: VirtioMMIOTransport) { + let transition = transmitState.withLock { + state -> (generation: UInt64?, cancelled: TransmitRetryRegistration?) in + guard !state.terminal else { return (nil, nil) } + + var cancelled: TransmitRetryRegistration? + if state.transport?.value !== transport { + state.advanceGeneration() + cancelled = state.clearLifecycleState() + state.transport = WeakTransportReference(transport) + } + state.kickPending = true + guard !state.drainScheduled, state.retryRegistration == nil else { + return (nil, cancelled) + } + state.kickPending = false + state.drainScheduled = true + return (state.generation, cancelled) + } + transition.cancelled?.source.cancel() + guard let generation = transition.generation else { return } + enqueueTransmitDrain(generation: generation, transport: transport) + } + + private func enqueueTransmitDrain( + generation: UInt64, + transport: VirtioMMIOTransport + ) { + transmitQueue.async { [weak self, weak transport] in + guard let self, let transport else { return } + self.drainTransmitQueue(generation: generation, transport: transport) + } + } + + /// Processes a bounded number of packets and bytes, then yields back to the serial executor. + /// The descriptor publication order is intentionally `peek -> copy -> send -> pop -> push`. + /// A transient send result therefore leaves both lastAvailIndex and the used ring unchanged. + private func drainTransmitQueue( + generation: UInt64, + transport: VirtioMMIOTransport + ) { + guard isCurrentTransmitEpoch(generation, transport: transport) else { return } + var operations = 0 + var processedBytes = 0 + var observedDepth = 0 + var wantsInterrupt = false + var stoppedOnQueueFault = false + defer { + if wantsInterrupt { transport.notifyUsed() } + } + + while operations < limits.maximumTransmitOperationsPerTurn { + let observedAt = Self.monotonicNanoseconds() + switch prepareTransmitHead( + generation: generation, + transport: transport, + observedAt: observedAt + ) { + case .empty: + observedDepth = 0 + observeTransmitQueueDepth(0, generation: generation, transport: transport) + finishTransmitTurn( + generation: generation, + transport: transport, + knownPendingWork: false + ) + return + case .stale: + return + case let .queueFault(depth): + observedDepth = depth + observeTransmitQueueDepth(depth, generation: generation, transport: transport) + stoppedOnQueueFault = true + case let .rejected(interrupt, depth, firstObserved): + operations += 1 + observedDepth = depth + wantsInterrupt = wantsInterrupt || interrupt + observeTransmitQueueDepth(depth, generation: generation, transport: transport) + recordTransmitCompletionLatency(from: firstObserved) + continue + case let .frame(chain, frame, depth): + observedDepth = depth + observeTransmitQueueDepth(depth, generation: generation, transport: transport) + if operations > 0, + processedBytes > limits.maximumTransmitBytesPerTurn - frame.count { + transmitBoundedDrainStops.wrappingAdd(1, ordering: .relaxed) + finishTransmitTurn( + generation: generation, + transport: transport, + knownPendingWork: true + ) + return + } + + guard let attempted = attemptTransmit( + frame, + chain: chain, + generation: generation, + transport: transport, + observedAt: observedAt + ) else { return } + + if Self.isTransientTransmitError(attempted.result) { + if Self.isSocketBackpressure(attempted.result.code) { + transmitBackpressure.wrappingAdd(1, ordering: .relaxed) + } + armTransmitRetry(generation: generation, transport: transport) + return } - if sent == payloadCount { + + let transmitted = attempted.result.count == frame.count + if transmitted { transmitPackets.wrappingAdd(1, ordering: .relaxed) - transmitBytes.wrappingAdd(UInt64(payloadCount), ordering: .relaxed) + transmitBytes.wrappingAdd(UInt64(frame.count), ordering: .relaxed) } else { - // Datagram writes are atomic. EAGAIN/EWOULDBLOCK means gvproxy is applying - // backpressure, while every other short/error result is equally undelivered; - // consume the guest descriptor and account one deterministic packet drop. - transmitDrops.wrappingAdd(1, ordering: .relaxed) + // Datagram sends are atomic. A short nonnegative result is an invariant/socket + // failure and receives the same terminal-per-descriptor treatment as errno. + transmitSocketErrors.wrappingAdd(1, ordering: .relaxed) + } + + switch finalizeTransmitHead( + chain, + generation: generation, + transport: transport + ) { + case .stale: + return + case let .queueFault(depth): + observedDepth = depth + observeTransmitQueueDepth(depth, generation: generation, transport: transport) + stoppedOnQueueFault = true + case let .published(interrupt, depth): + operations += 1 + processedBytes += frame.count + observedDepth = depth + wantsInterrupt = wantsInterrupt || interrupt + observeTransmitQueueDepth(depth, generation: generation, transport: transport) + if !transmitted { + transmitDrops.wrappingAdd(1, ordering: .relaxed) + } + completeTransmitHead( + chain, + generation: generation, + transport: transport, + firstObserved: attempted.firstObserved + ) } - } else { - transmitDrops.wrappingAdd(1, ordering: .relaxed) } - let wants = (try? virtqueue.push(chain, written: 0)) ?? false - interrupt = interrupt || wants + + if stoppedOnQueueFault { break } } - if interrupt { - transport.notifyUsed() + + if stoppedOnQueueFault { + finishTransmitTurn( + generation: generation, + transport: transport, + knownPendingWork: false + ) + return + } + if observedDepth > 0 { + transmitBoundedDrainStops.wrappingAdd(1, ordering: .relaxed) } + finishTransmitTurn( + generation: generation, + transport: transport, + knownPendingWork: observedDepth > 0 + ) } - private func drainSocket() { - guard let transport else { return } + private func prepareTransmitHead( + generation: UInt64, + transport: VirtioMMIOTransport, + observedAt: UInt64 + ) -> TransmitPreparation { + transport.withQueueLock { + guard isCurrentTransmitEpoch(generation, transport: transport) else { return .stale } + let virtqueue = transport.queues[1] + let depth: Int + do { + depth = Int(try virtqueue.pendingCount()) + } catch { + transmitInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + transmitDrops.wrappingAdd(1, ordering: .relaxed) + return .queueFault(depth: 0) + } + guard depth > 0 else { return .empty } - var interrupt = false - while deferredReceiveHead < deferredReceiveFrames.count { - let frame = deferredReceiveFrames[deferredReceiveHead] - guard let wantsInterrupt = deliver(frame, transport: transport) else { break } - deferredReceiveHead += 1 - interrupt = interrupt || wantsInterrupt + let chain: VirtqueueChain + do { + guard let candidate = try virtqueue.peek() else { return .empty } + chain = candidate + } catch { + // peek() is non-consuming. pop() advances lastAvailIndex before walking the same + // malformed descriptor, preventing a hostile head from spinning every work turn. + _ = try? virtqueue.pop() + transmitInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + transmitDrops.wrappingAdd(1, ordering: .relaxed) + let remaining = (try? virtqueue.pendingCount()).map(Int.init) ?? max(0, depth - 1) + return .queueFault(depth: remaining) + } + + let maximumChainBytes = Self.headerLength + maximumEthernetFrameLength + let verdict = chain.withLeaseHeld { access -> Result<[UInt8], TransmitRejection> in + guard access.readableSegmentCount > 0, + access.writableSegmentCount == 0 else { + return .failure(.invalidDescriptor) + } + let readableCount = access.readableByteCount + guard readableCount <= maximumChainBytes else { + return .failure(.oversized) + } + guard readableCount >= Self.headerLength + Self.ethernetHeaderLength else { + return .failure(.malformed) + } + let packet = access.readBytes(maximum: maximumChainBytes) + guard packet.count == readableCount, + Self.isSupportedTransmitHeader(packet) else { + return .failure(.malformed) + } + let frame = Array(packet.dropFirst(Self.headerLength)) + guard frame.count >= Self.ethernetHeaderLength else { + return .failure(.malformed) + } + guard frame.count <= maximumEthernetFrameLength else { + return .failure(.oversized) + } + return .success(frame) + } + guard let verdict else { + transmitInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + transmitDrops.wrappingAdd(1, ordering: .relaxed) + return .queueFault(depth: depth) + } + + switch verdict { + case let .success(frame): + return .frame(chain: chain, bytes: frame, depth: depth) + case let .failure(reason): + accountTransmitRejection(reason) + do { + guard let popped = try virtqueue.pop(), Self.sameChain(popped, chain) else { + transmitInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + return .queueFault(depth: depth) + } + switch try virtqueue.pushOutcome(popped, written: 0) { + case .revoked: + return .stale + case let .published(wantsInterrupt): + let remaining = Int(try virtqueue.pendingCount()) + transmitCompletions.wrappingAdd(1, ordering: .relaxed) + return .rejected( + wantsInterrupt: wantsInterrupt, + depth: remaining, + observedAt: observedAt + ) + } + } catch { + transmitInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + return .queueFault(depth: max(0, depth - 1)) + } + } } - compactDeferredFramesIfNeeded() + } - var frame = [UInt8](repeating: 0, count: 65536) - while true { - let received = recv(socketFD, &frame, frame.count, MSG_DONTWAIT) - guard received > 0 else { break } - receivePackets.wrappingAdd(1, ordering: .relaxed) - receiveBytes.wrappingAdd(UInt64(received), ordering: .relaxed) - let receivedFrame = Array(frame[0.. (result: (count: Int, code: Int32), firstObserved: UInt64)? { + transmitState.withLock { state in + guard state.generation == generation, + state.transport?.value === transport, + !state.terminal, + state.drainScheduled else { return nil } + let firstObserved: UInt64 + if let observation = state.headObservation, observation.matches(chain) { + firstObserved = observation.firstObservedNanoseconds + } else { + firstObserved = observedAt + state.headObservation = TransmitHeadObservation( + lease: chain.lease, + head: chain.head, + firstObservedNanoseconds: observedAt + ) } - if let wantsInterrupt = deliver(receivedFrame, transport: transport) { - interrupt = interrupt || wantsInterrupt + + // The state lock is deliberately held through the nonblocking syscall. Reset and queue + // reconfiguration take registerLock -> transmitState, so they wait for this bounded + // attempt and can then revoke the epoch without a send beginning after the callback. + let result: (count: Int, code: Int32) + if let transmitOperationForTesting { + result = transmitOperationForTesting(frame) } else { - deferOrDrop(receivedFrame) + result = socketOwner.withDescriptor { descriptor in + frame.withUnsafeBytes { buffer in + let count = send(descriptor, buffer.baseAddress, buffer.count, MSG_DONTWAIT) + return (count, count < 0 ? errno : 0) + } + } ?? (-1, EBADF) + } + return (result, firstObserved) + } + } + + private func finalizeTransmitHead( + _ expected: VirtqueueChain, + generation: UInt64, + transport: VirtioMMIOTransport + ) -> TransmitFinalization { + transport.withQueueLock { + guard isCurrentTransmitEpoch(generation, transport: transport) else { return .stale } + let virtqueue = transport.queues[1] + do { + guard let popped = try virtqueue.pop(), Self.sameChain(popped, expected) else { + transmitInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + let depth = (try? virtqueue.pendingCount()).map(Int.init) ?? 0 + return .queueFault(depth: depth) + } + switch try virtqueue.pushOutcome(popped, written: 0) { + case .revoked: + return .stale + case let .published(wantsInterrupt): + let depth = Int(try virtqueue.pendingCount()) + transmitCompletions.wrappingAdd(1, ordering: .relaxed) + return .published(wantsInterrupt: wantsInterrupt, depth: depth) + } + } catch { + transmitInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + let depth = (try? virtqueue.pendingCount()).map(Int.init) ?? 0 + return .queueFault(depth: depth) + } + } + } + + private func accountTransmitRejection(_ rejection: TransmitRejection) { + switch rejection { + case .invalidDescriptor: + transmitInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + case .malformed: + transmitMalformed.wrappingAdd(1, ordering: .relaxed) + case .oversized: + transmitOversized.wrappingAdd(1, ordering: .relaxed) + } + transmitDrops.wrappingAdd(1, ordering: .relaxed) + } + + private static func sameChain(_ lhs: VirtqueueChain, _ rhs: VirtqueueChain) -> Bool { + lhs.head == rhs.head && lhs.lease == rhs.lease + } + + private static func isSocketBackpressure(_ code: Int32) -> Bool { + code == EAGAIN || code == EWOULDBLOCK || code == ENOBUFS + } + + private static func isTransientTransmitError( + _ result: (count: Int, code: Int32) + ) -> Bool { + result.count < 0 && (isSocketBackpressure(result.code) || result.code == EINTR) + } + + private func bindTransmitTransport(_ transport: VirtioMMIOTransport) { + let retry = transmitState.withLock { state -> TransmitRetryRegistration? in + guard !state.terminal, state.transport?.value !== transport else { return nil } + state.advanceGeneration() + let retry = state.clearLifecycleState() + state.transport = WeakTransportReference(transport) + return retry + } + retry?.source.cancel() + } + + private func revokeTransmitLifecycle(replacementTransport: WeakTransportReference?) { + let retry = transmitState.withLock { state -> TransmitRetryRegistration? in + guard !state.terminal else { return nil } + state.advanceGeneration() + let retry = state.clearLifecycleState() + state.transport = replacementTransport + return retry + } + retry?.source.cancel() + } + + private func terminateTransmitLifecycle() { + let retry = transmitState.withLock { state -> TransmitRetryRegistration? in + guard !state.terminal else { return nil } + state.terminal = true + state.advanceGeneration() + state.transport = nil + return state.clearLifecycleState() + } + retry?.source.cancel() + } + + private func isCurrentTransmitEpoch( + _ generation: UInt64, + transport: VirtioMMIOTransport + ) -> Bool { + transmitState.withLock { + $0.generation == generation + && $0.transport?.value === transport + && !$0.terminal + && $0.drainScheduled + } + } + + private func observeTransmitQueueDepth( + _ depth: Int, + generation: UInt64, + transport: VirtioMMIOTransport + ) { + transmitState.withLock { state in + guard state.generation == generation, + state.transport?.value === transport else { return } + state.observeQueueDepth(depth) + } + } + + private func completeTransmitHead( + _ chain: VirtqueueChain, + generation: UInt64, + transport: VirtioMMIOTransport, + firstObserved: UInt64 + ) { + transmitState.withLock { state in + guard state.generation == generation, + state.transport?.value === transport else { return } + if state.headObservation?.matches(chain) == true { + state.headObservation = nil + } + state.consecutiveTransientFailures = 0 + } + recordTransmitCompletionLatency(from: firstObserved) + } + + private func recordTransmitCompletionLatency(from firstObserved: UInt64) { + let elapsed = Self.monotonicNanoseconds() &- firstObserved + transmitCompletionLatencyNanoseconds.wrappingAdd(elapsed, ordering: .relaxed) + transmitState.withLock { state in + state.maximumCompletionLatencyNanoseconds = max( + state.maximumCompletionLatencyNanoseconds, + elapsed + ) + } + } + + private func armTransmitRetry( + generation: UInt64, + transport: VirtioMMIOTransport + ) { + let failureCount = transmitState.withLock { state -> Int? in + guard state.generation == generation, + state.transport?.value === transport, + !state.terminal, + state.drainScheduled, + state.retryRegistration == nil else { return nil } + state.consecutiveTransientFailures += 1 + return state.consecutiveTransientFailures + } + guard let failureCount else { return } + + let delay = transmitRetryDelayNanoseconds(failureCount: failureCount) + let source = DispatchSource.makeTimerSource(queue: transmitQueue) + let registration = TransmitRetryRegistration(generation: generation, source: source) + source.setEventHandler { [weak self, weak transport] in + guard let self, let transport else { return } + self.wakeTransmitRetry(generation: generation, transport: transport) + } + let cancellation = registration.cancellation + source.setCancelHandler { + cancellation.signal() + } + source.schedule( + deadline: .now() + .nanoseconds(delay), + leeway: .nanoseconds(max(1, min(delay / 4, 1_000_000))) + ) + + let installed = transmitState.withLock { state -> Bool in + guard state.generation == generation, + state.transport?.value === transport, + !state.terminal, + state.drainScheduled, + state.retryRegistration == nil else { return false } + state.retryRegistration = registration + state.drainScheduled = false + return true + } + source.activate() + if !installed { source.cancel() } + } + + private func transmitRetryDelayNanoseconds(failureCount: Int) -> Int { + var delay = limits.minimumTransmitRetryDelayNanoseconds + for _ in 1..= limits.maximumTransmitRetryDelayNanoseconds { break } + let (doubled, overflow) = delay.multipliedReportingOverflow(by: 2) + delay = overflow + ? limits.maximumTransmitRetryDelayNanoseconds + : min(limits.maximumTransmitRetryDelayNanoseconds, doubled) + } + return delay + } + + private func wakeTransmitRetry( + generation: UInt64, + transport: VirtioMMIOTransport + ) { + let registration = transmitState.withLock { state -> TransmitRetryRegistration? in + guard state.generation == generation, + state.transport?.value === transport, + !state.terminal, + !state.drainScheduled, + let registration = state.retryRegistration, + registration.generation == generation else { return nil } + state.retryRegistration = nil + state.kickPending = false + state.drainScheduled = true + return registration + } + guard let registration else { return } + registration.source.cancel() + transmitRetryWakeups.wrappingAdd(1, ordering: .relaxed) + enqueueTransmitDrain(generation: generation, transport: transport) + } + + private func finishTransmitTurn( + generation: UInt64, + transport: VirtioMMIOTransport, + knownPendingWork: Bool + ) { + let continueDraining = transmitState.withLock { state -> Bool in + guard state.generation == generation, + state.transport?.value === transport, + !state.terminal, + state.drainScheduled else { return false } + if knownPendingWork || state.kickPending { + state.kickPending = false + return true } + state.drainScheduled = false + return false } - if interrupt { - transport.notifyUsed() + if continueDraining { + enqueueTransmitDrain(generation: generation, transport: transport) } } - /// Returns nil when the guest has not supplied an RX descriptor yet. - private func deliver(_ frame: [UInt8], transport: VirtioMMIOTransport) -> Bool? { - let virtqueue = transport.queues[0] - return transport.withQueueLock { () -> Bool? in - guard let chain = (try? virtqueue.pop()) ?? nil else { return nil } - var header = [UInt8](repeating: 0, count: Self.headerLength) - header[10] = 1 // num_buffers = 1 - header.append(contentsOf: frame) - let written = chain.writeBytes(header) - if written != Self.headerLength + frame.count { - receiveTruncations.wrappingAdd(1, ordering: .relaxed) + private static func monotonicNanoseconds() -> UInt64 { + DispatchTime.now().uptimeNanoseconds + } + + private static func isSupportedTransmitHeader(_ packet: [UInt8]) -> Bool { + guard packet.count >= headerLength else { return false } + let flags = packet[0] + let gsoType = packet[1] + // Unknown flag bits are ignored as required for extensibility. Known checksum/data-valid/ + // RSC semantics and every GSO type require features Dory does not offer. hdr_len, gso_size, + // csum_start, and csum_offset are deliberately not trusted or interpreted. num_buffers is + // an RX result field and is explicitly unused on transmitted packets, so a device must not + // turn its contents into a TX admission condition. In particular, Linux uses the 12-byte + // VERSION_1 header without initializing those two bytes unless MRG_RXBUF was negotiated. + return flags & knownHeaderFlagsMask == 0 + && gsoType == 0 // VIRTIO_NET_HDR_GSO_NONE + } + + private func drainSocket() { + let deferredResult = drainDeferredFrames() + guard deferredResult != .queueFault else { return } + var backlogIsWaiting = deferredResult == .waiting + var receiveBuffer = [UInt8](repeating: 0, count: maximumEthernetFrameLength + 1) + var receiveOperations = 0 + var receivedBytes = 0 + let maximumBytesBeforeAnotherReceive = + limits.maximumSocketReceiveBytesPerTurn - receiveBuffer.count + + while receiveOperations < limits.maximumSocketReceiveOperationsPerTurn, + receivedBytes <= maximumBytesBeforeAnotherReceive { + receiveOperations += 1 + switch receiveOneDatagram(into: &receiveBuffer) { + case .unavailable: + return + case .retry: + continue + case let .failed(code): + handleSocketReceiveFailure(code) + return + case let .empty(ready): + accountReceivedDatagram(byteCount: 0) + receiveMalformed.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + if ready == nil { + receiveInactiveDrops.wrappingAdd(1, ordering: .relaxed) + } + case let .frame(frame, ready): + receivedBytes += frame.count + accountReceivedDatagram(byteCount: frame.count) + guard frame.count <= maximumEthernetFrameLength else { + receiveTruncations.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + continue + } + guard frame.count >= Self.ethernetHeaderLength else { + receiveMalformed.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + continue + } + guard let ready else { + receiveInactiveDrops.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + continue + } + + // Once an older frame is waiting, every later frame joins the bounded backlog and + // cannot overtake it. Invalid guest buffers are consumed with used length zero. + if backlogIsWaiting || hasDeferredFrame(generation: ready.generation) { + backlogIsWaiting = true + deferOrDrop(frame, generation: ready.generation) + continue + } + switch deliver(frame, ready: ready) { + case let .delivered(wantsInterrupt): + notifyUsedIfCurrent(wantsInterrupt, ready: ready) + case let .awaitingBuffer(wantsInterrupt): + notifyUsedIfCurrent(wantsInterrupt, ready: ready) + deferOrDrop(frame, generation: ready.generation) + backlogIsWaiting = true + case .stale: + receiveInactiveDrops.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + case let .queueFault(wantsInterrupt): + notifyUsedIfCurrent(wantsInterrupt, ready: ready) + return + } } - return (try? virtqueue.push(chain, written: written)) ?? false + } + scheduleReceiveDrainContinuation() + } + + private func scheduleReceiveDrainContinuation() { + guard receiveState.withLock({ !$0.terminal }) else { return } + receiveQueue.async { [weak self] in + self?.drainSocket() } } - private func deferOrDrop(_ frame: [UInt8]) { - let pendingCount = deferredReceiveFrames.count - deferredReceiveHead - guard pendingCount < Self.maximumDeferredReceiveFrames else { + /// Drains the bounded host backlog before reading another socket frame, preserving arrival + /// order across yielded socket work turns. + private func drainDeferredFrames() -> DeferredDrainResult { + while let pending = receiveState.withLock({ state -> (DeferredFrame, ReadyTransport)? in + guard state.deferredHead < state.deferredFrames.count, + let transport = state.transport?.value else { return nil } + let frame = state.deferredFrames[state.deferredHead] + guard frame.generation == state.generation else { return nil } + return (frame, ReadyTransport(generation: state.generation, transport: transport)) + }) { + let frame = pending.0 + let ready = pending.1 + switch deliver(frame.bytes, ready: ready) { + case let .delivered(wantsInterrupt): + receiveState.withLock { + $0.dequeueDeferredFrame(generation: ready.generation) + } + notifyUsedIfCurrent(wantsInterrupt, ready: ready) + case let .awaitingBuffer(wantsInterrupt): + notifyUsedIfCurrent(wantsInterrupt, ready: ready) + return .waiting + case .stale: + return .drained + case let .queueFault(wantsInterrupt): + receiveState.withLock { + $0.dequeueDeferredFrame(generation: ready.generation) + } + // A malformed later descriptor must not hide valid zero-length completions that + // were already published while searching for a usable RX buffer. + notifyUsedIfCurrent(wantsInterrupt, ready: ready) + return .queueFault + } + } + return .drained + } + + private func deliver(_ frame: [UInt8], ready: ReadyTransport) -> DeliveryResult { + ready.transport.withQueueLock { + guard isCurrent(ready) else { return .stale } + let virtqueue = ready.transport.queues[0] + var wantsInterrupt = false + + while isCurrent(ready) { + let chain: VirtqueueChain + do { + guard let next = try virtqueue.pop() else { + return .awaitingBuffer(wantsInterrupt: wantsInterrupt) + } + chain = next + } catch { + // pop() has consumed this malformed available-ring entry before descriptor + // resolution. Do not spin over subsequent entries in the same drain turn. + receiveInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + return .queueFault(wantsInterrupt: wantsInterrupt) + } + let requiredCapacity = Self.headerLength + frame.count + let disposition = chain.withLeaseHeld { access -> DescriptorDisposition in + guard access.writableSegmentCount > 0, + access.readableSegmentCount == 0 else { return .wrongDirection } + guard access.writableByteCount >= requiredCapacity else { + return .insufficientCapacity + } + return .writable + } ?? .wrongDirection + + switch disposition { + case .wrongDirection: + receiveInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) + guard let wants = pushReceiveCompletion( + chain, + written: 0, + to: virtqueue + ) else { return .queueFault(wantsInterrupt: wantsInterrupt) } + wantsInterrupt = wantsInterrupt || wants + case .insufficientCapacity: + receiveInsufficientCapacity.wrappingAdd(1, ordering: .relaxed) + guard let wants = pushReceiveCompletion( + chain, + written: 0, + to: virtqueue + ) else { return .queueFault(wantsInterrupt: wantsInterrupt) } + wantsInterrupt = wantsInterrupt || wants + case .writable: + var packet = [UInt8](repeating: 0, count: Self.headerLength) + packet[10] = 1 // num_buffers = 1; MRG_RXBUF is not offered. + packet.append(contentsOf: frame) + let written = chain.withLeaseHeld { $0.writeBytes(packet) } ?? 0 + // Capacity and every segment were validated under the same queue lifecycle + // lease. A short result can only mean reset revoked publication; used length + // zero prevents the guest from observing a partial packet as delivered. + guard written == packet.count else { + guard let wants = pushReceiveCompletion( + chain, + written: 0, + to: virtqueue + ) else { return .queueFault(wantsInterrupt: wantsInterrupt) } + return isCurrent(ready) + ? .awaitingBuffer(wantsInterrupt: wantsInterrupt || wants) + : .stale + } + guard let wants = pushReceiveCompletion( + chain, + written: written, + to: virtqueue + ) else { return .queueFault(wantsInterrupt: wantsInterrupt) } + return .delivered(wantsInterrupt: wantsInterrupt || wants) + } + } + return .stale + } + } + + private func pushReceiveCompletion( + _ chain: VirtqueueChain, + written: Int, + to virtqueue: Virtqueue + ) -> Bool? { + do { + return try virtqueue.push(chain, written: written) + } catch { + receiveInvalidDescriptors.wrappingAdd(1, ordering: .relaxed) receiveDrops.wrappingAdd(1, ordering: .relaxed) - return + return nil + } + } + + private func notifyUsedIfCurrent(_ wantsInterrupt: Bool, ready: ReadyTransport) { + guard wantsInterrupt else { return } + ready.transport.withQueueLock { + guard isCurrent(ready) else { return } + ready.transport.notifyUsed() + } + } + + private func isCurrent(_ ready: ReadyTransport) -> Bool { + receiveState.withLock { state in + state.generation == ready.generation + && state.transport?.value === ready.transport + } + } + + private func hasDeferredFrame(generation: UInt64) -> Bool { + receiveState.withLock { state in + state.generation == generation && state.deferredCount > 0 + } + } + + private func deferOrDrop(_ frame: [UInt8], generation: UInt64) { + let admission = receiveState.withLock { state -> DeferredAdmission in + guard state.generation == generation, + state.transport?.value != nil else { return .stale } + guard state.deferredCount < limits.maximumDeferredReceiveFrames else { + return .atCapacity + } + let (newByteCount, overflow) = state.deferredBytes.addingReportingOverflow(frame.count) + guard !overflow, newByteCount <= limits.maximumDeferredReceiveBytes else { + return .atCapacity + } + state.deferredFrames.append(DeferredFrame(generation: generation, bytes: frame)) + state.deferredBytes = newByteCount + return .accepted + } + switch admission { + case .accepted: + receiveDeferred.wrappingAdd(1, ordering: .relaxed) + case .atCapacity: + receiveBacklogDrops.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + case .stale: + receiveInactiveDrops.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + } + } + + private func receiveOneDatagram(into buffer: inout [UInt8]) -> ReceivedDatagram { + socketReceiveLock.lock() + let ready = receiveState.withLock { state -> ReadyTransport? in + guard let transport = state.transport?.value else { return nil } + return ReadyTransport(generation: state.generation, transport: transport) + } + let receiveResult: (count: Int, code: Int32)? = socketOwner.withDescriptor { descriptor in + buffer.withUnsafeMutableBytes { + let count = recv(descriptor, $0.baseAddress, $0.count, MSG_DONTWAIT) + return (count, count < 0 ? errno : 0) + } + } + socketReceiveLock.unlock() + + guard let receiveResult else { return .unavailable } + let received = receiveResult.count + let code = receiveResult.code + + if received > 0 { + return .frame(Array(buffer[0.. InactivePurgeResult { + var buffer = [UInt8](repeating: 0, count: maximumEthernetFrameLength + 1) + var receiveOperations = 0 + var receivedBytes = 0 + let maximumBytesBeforeAnotherReceive = + limits.maximumSocketReceiveBytesPerTurn - buffer.count + while receiveOperations < limits.maximumSocketReceiveOperationsPerTurn, + receivedBytes <= maximumBytesBeforeAnotherReceive { + receiveOperations += 1 + guard let result: (count: Int, code: Int32) = socketOwner.withDescriptor({ descriptor in + buffer.withUnsafeMutableBytes { + let count = recv(descriptor, $0.baseAddress, $0.count, MSG_DONTWAIT) + return (count, count < 0 ? errno : 0) + } + }) else { return .failed(EBADF) } + let received = result.count + if received >= 0 { + receivedBytes += received + accountReceivedDatagram(byteCount: received) + if received > maximumEthernetFrameLength { + receiveTruncations.wrappingAdd(1, ordering: .relaxed) + } else if received < Self.ethernetHeaderLength { + receiveMalformed.wrappingAdd(1, ordering: .relaxed) + } + receiveInactiveDrops.wrappingAdd(1, ordering: .relaxed) + receiveDrops.wrappingAdd(1, ordering: .relaxed) + continue + } + if result.code == EINTR { continue } + if result.code == EAGAIN || result.code == EWOULDBLOCK { return .drained } + return .failed(result.code) + } + return .budgetExhausted + } + + /// A non-transient recv failure is terminal for this connected Unix socket. Keeping a read + /// source active after EBADF/EIO would spin indefinitely, so one path accounts the failure, + /// revokes the device generation, and hands descriptor retirement to the source cancel handler. + /// Internal visibility is an intentional deterministic test seam for an otherwise hard-to- + /// induce Darwin source error. + func handleSocketReceiveFailure(_ code: Int32) { + guard code != EINTR, code != EAGAIN, code != EWOULDBLOCK else { return } + if quiesceReceiveSocket() { + receiveSocketErrors.wrappingAdd(1, ordering: .relaxed) + } + } + + private func failReceiveActivation() { + if quiesceReceiveSocket() { + receiveActivationFailures.wrappingAdd(1, ordering: .relaxed) } - deferredReceiveFrames.append(frame) - receiveDeferred.wrappingAdd(1, ordering: .relaxed) } - private func compactDeferredFramesIfNeeded() { - guard deferredReceiveHead > 0 else { return } - if deferredReceiveHead == deferredReceiveFrames.count { - deferredReceiveFrames.removeAll(keepingCapacity: true) - deferredReceiveHead = 0 - } else if deferredReceiveHead >= 64 { - deferredReceiveFrames.removeFirst(deferredReceiveHead) - deferredReceiveHead = 0 + @discardableResult + private func quiesceReceiveSocket() -> Bool { + socketOwner.disableOperations() + // One connected datagram socket serves both directions. A terminal receive failure revokes + // TX admission too, rather than letting a retry timer consume descriptors against EBADF. + terminateTransmitLifecycle() + let transition = receiveState.withLock { state -> (discarded: Int, isNew: Bool) in + guard !state.terminal else { return (0, false) } + state.terminal = true + _ = state.advanceGeneration() + state.transport = nil + state.deviceIsReady = false + state.receiveQueueIsReady = false + return (state.clearDeferredFrames(), true) } + guard transition.isNew else { return false } + accountInactiveDrops(transition.discarded) + if let registration = receiveSourceRegistrationSnapshot() { + registration.source.cancel() + } else { + socketOwner.retire() + } + return true + } + + private func accountReceivedDatagram(byteCount: Int) { + receivePackets.wrappingAdd(1, ordering: .relaxed) + receiveBytes.wrappingAdd(UInt64(byteCount), ordering: .relaxed) + } + + private func accountInactiveDrops(_ count: Int) { + guard count > 0 else { return } + let amount = UInt64(count) + receiveInactiveDrops.wrappingAdd(amount, ordering: .relaxed) + receiveDrops.wrappingAdd(amount, ordering: .relaxed) } public var statistics: VirtioNetStatistics { - VirtioNetStatistics( + let now = Self.monotonicNanoseconds() + let transmitGauges = transmitState.withLock { state in + ( + maximumLatency: state.maximumCompletionLatencyNanoseconds, + oldestPendingLatency: state.headObservation.map { + now &- $0.firstObservedNanoseconds + } ?? 0, + queueDepth: UInt64(state.queueDepth), + queueHighWatermark: UInt64(state.queueHighWatermark) + ) + } + return VirtioNetStatistics( transmitPackets: transmitPackets.load(ordering: .relaxed), transmitBytes: transmitBytes.load(ordering: .relaxed), transmitDrops: transmitDrops.load(ordering: .relaxed), + transmitMalformed: transmitMalformed.load(ordering: .relaxed), + transmitOversized: transmitOversized.load(ordering: .relaxed), + transmitInvalidDescriptors: transmitInvalidDescriptors.load(ordering: .relaxed), + transmitBackpressure: transmitBackpressure.load(ordering: .relaxed), + transmitSocketErrors: transmitSocketErrors.load(ordering: .relaxed), + transmitRetryWakeups: transmitRetryWakeups.load(ordering: .relaxed), + transmitBoundedDrainStops: transmitBoundedDrainStops.load(ordering: .relaxed), + transmitCompletions: transmitCompletions.load(ordering: .relaxed), + transmitCompletionLatencyNanoseconds: transmitCompletionLatencyNanoseconds.load( + ordering: .relaxed + ), + transmitMaximumCompletionLatencyNanoseconds: transmitGauges.maximumLatency, + transmitOldestPendingLatencyNanoseconds: transmitGauges.oldestPendingLatency, + transmitQueueDepth: transmitGauges.queueDepth, + transmitQueueHighWatermark: transmitGauges.queueHighWatermark, receivePackets: receivePackets.load(ordering: .relaxed), receiveBytes: receiveBytes.load(ordering: .relaxed), receiveDeferred: receiveDeferred.load(ordering: .relaxed), receiveDrops: receiveDrops.load(ordering: .relaxed), - receiveTruncations: receiveTruncations.load(ordering: .relaxed) + receiveTruncations: receiveTruncations.load(ordering: .relaxed), + receiveMalformed: receiveMalformed.load(ordering: .relaxed), + receiveInvalidDescriptors: receiveInvalidDescriptors.load(ordering: .relaxed), + receiveInsufficientCapacity: receiveInsufficientCapacity.load(ordering: .relaxed), + receiveBacklogDrops: receiveBacklogDrops.load(ordering: .relaxed), + receiveInactiveDrops: receiveInactiveDrops.load(ordering: .relaxed), + receiveSocketErrors: receiveSocketErrors.load(ordering: .relaxed), + receiveActivationFailures: receiveActivationFailures.load(ordering: .relaxed) + ) + } + + private static func makeOwnedConnectedSocket( + socketPath: String, + remotePath: String + ) throws -> SocketOwner { + try validateSocketPath(socketPath) + try validateSocketPath(remotePath) + let localParentPath = (socketPath as NSString).deletingLastPathComponent + let remoteParentPath = (remotePath as NSString).deletingLastPathComponent + let localParentIdentity = try validateTrustedParentDirectory(of: socketPath) + let remoteParentIdentity = try validateTrustedParentDirectory(of: remotePath) + guard let remoteIdentity = socketIdentity(at: remotePath) else { + throw VMError.invalidConfiguration( + "virtio-net remote endpoint is not a same-user Unix socket: \(remotePath)" + ) + } + + socketPathMutationLock.lock() + defer { socketPathMutationLock.unlock() } + try retireStaleLocalEndpointIfPresent( + socketPath, + parentPath: localParentPath, + parentIdentity: localParentIdentity + ) + + let descriptor = socket(AF_UNIX, SOCK_DGRAM, 0) + guard descriptor >= 0 else { + throw systemCallError("create Unix datagram socket", path: socketPath, code: errno) + } + var boundIdentity: SocketPathIdentity? + do { + try configureSocket(descriptor) + var localAddress = try unixAddress(socketPath) + let bindResult = withUnsafePointer(to: &localAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + throw systemCallError("bind Unix datagram socket", path: socketPath, code: errno) + } + guard chmod(socketPath, 0o600) == 0 else { + throw systemCallError("chmod Unix datagram socket", path: socketPath, code: errno) + } + guard socketPermissions(at: socketPath) == 0o600 else { + throw systemCallError( + "verify private Unix datagram permissions", + path: socketPath, + code: EPERM + ) + } + guard let identity = socketIdentity(at: socketPath) else { + throw systemCallError("capture Unix datagram identity", path: socketPath, code: ESTALE) + } + boundIdentity = identity + guard directoryIdentity(at: localParentPath) == localParentIdentity else { + throw systemCallError("revalidate Unix datagram parent", path: localParentPath, code: ESTALE) + } + + var remoteAddress = try unixAddress(remotePath) + let connectResult = withUnsafePointer(to: &remoteAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard connectResult == 0 else { + throw systemCallError("connect Unix datagram socket", path: remotePath, code: errno) + } + guard socketIdentity(at: remotePath) == remoteIdentity else { + throw systemCallError("revalidate Unix datagram peer", path: remotePath, code: ESTALE) + } + guard directoryIdentity(at: remoteParentPath) == remoteParentIdentity else { + throw systemCallError("revalidate Unix datagram peer parent", path: remoteParentPath, code: ESTALE) + } + var peerUID: uid_t = 0 + var peerGID: gid_t = 0 + let peerCredentialResult = getpeereid(descriptor, &peerUID, &peerGID) + let usedPathnamePeerAuthentication: Bool + if peerCredentialResult == 0 { + guard peerUID == geteuid() else { + throw VMError.invalidConfiguration( + "virtio-net remote endpoint belongs to uid \(peerUID), expected \(geteuid())" + ) + } + usedPathnamePeerAuthentication = false + } else { + let peerCredentialError = errno + // Apple Libc's getpeereid(3) is defined only for SOCK_STREAM. XNU's + // uipc_ctloutput returns EINVAL for LOCAL_PEERCRED on a pathname SOCK_DGRAM that + // has no reciprocal peer association. Only that documented capability absence (or + // ENOTSUP on another Darwin release) may fall back to the already captured and + // post-connect revalidated same-euid socket and trusted-parent identities. + guard peerCredentialError == EINVAL || peerCredentialError == ENOTSUP else { + throw systemCallError( + "authenticate Unix datagram peer", + path: remotePath, + code: peerCredentialError + ) + } + usedPathnamePeerAuthentication = true + } + + try setSocketBuffer(descriptor, option: SO_SNDBUF, bytes: 1 << 20) + try setSocketBuffer(descriptor, option: SO_RCVBUF, bytes: 4 << 20) + + // gvproxy <= 0.8.6 requires this handshake; newer releases retain compatibility. + let magicResult: (count: Int, code: Int32) = vfkitMagic.withUnsafeBytes { + let count = send(descriptor, $0.baseAddress, $0.count, MSG_DONTWAIT) + return (count, count < 0 ? errno : 0) + } + guard magicResult.count == vfkitMagic.count else { + // Unix datagram sends are atomic, so a nonnegative short result is an invariant + // failure rather than an errno-bearing partial registration. + let code = magicResult.count < 0 ? magicResult.code : EIO + throw systemCallError("register vfkit peer", path: remotePath, code: code) + } + guard socketIdentity(at: socketPath) == identity else { + throw systemCallError("retain Unix datagram identity", path: socketPath, code: ESTALE) + } + guard socketIdentity(at: remotePath) == remoteIdentity else { + throw systemCallError("retain Unix datagram peer identity", path: remotePath, code: ESTALE) + } + guard directoryIdentity(at: localParentPath) == localParentIdentity, + directoryIdentity(at: remoteParentPath) == remoteParentIdentity else { + throw systemCallError("retain Unix datagram parent authority", path: socketPath, code: ESTALE) + } + return SocketOwner( + descriptor: descriptor, + localPath: socketPath, + localIdentity: identity, + parentPath: localParentPath, + parentIdentity: localParentIdentity, + usedPathnamePeerAuthentication: usedPathnamePeerAuthentication + ) + } catch { + if let boundIdentity, + directoryIdentity(at: localParentPath) == localParentIdentity { + unlinkSocketIfOwnedLocked(socketPath, identity: boundIdentity) + } + close(descriptor) + throw error + } + } + + private static func retireStaleLocalEndpointIfPresent( + _ path: String, + parentPath: String, + parentIdentity: DirectoryIdentity + ) throws { + guard directoryIdentity(at: parentPath) == parentIdentity else { + throw systemCallError("revalidate Unix datagram parent", path: parentPath, code: ESTALE) + } + var info = stat() + if lstat(path, &info) != 0 { + guard errno == ENOENT else { + throw systemCallError("inspect Unix datagram path", path: path, code: errno) + } + return + } + guard let identity = socketIdentity(at: path) else { + throw VMError.invalidConfiguration( + "refusing to replace a non-socket, symlink, multiply-linked, or foreign endpoint: \(path)" + ) + } + switch probeExistingDatagramEndpoint(path) { + case .live: + throw VMError.invalidConfiguration("refusing to replace a live Unix datagram endpoint: \(path)") + case let .indeterminate(code): + throw systemCallError("prove Unix datagram endpoint stale", path: path, code: code) + case .stale: + break + } + guard socketIdentity(at: path) == identity else { + throw systemCallError("revalidate stale Unix datagram endpoint", path: path, code: ESTALE) + } + guard directoryIdentity(at: parentPath) == parentIdentity else { + throw systemCallError("revalidate stale Unix datagram parent", path: parentPath, code: ESTALE) + } + guard unlink(path) == 0 else { + throw systemCallError("remove stale Unix datagram endpoint", path: path, code: errno) + } + } + + private static func probeExistingDatagramEndpoint(_ path: String) -> ExistingEndpointProbe { + let descriptor = socket(AF_UNIX, SOCK_DGRAM, 0) + guard descriptor >= 0 else { return .indeterminate(errno) } + defer { close(descriptor) } + do { + try configureSocket(descriptor) + var address = try unixAddress(path) + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + if result == 0 { return .live } + let code = errno + if code == ECONNREFUSED || code == ENOENT { return .stale } + return .indeterminate(code) + } catch { + return .indeterminate(errno == 0 ? EIO : errno) + } + } + + private static func retireOwnedSocket( + descriptor: Int32, + path: String, + identity: SocketPathIdentity, + parentPath: String, + parentIdentity: DirectoryIdentity + ) { + socketPathMutationLock.lock() + if directoryIdentity(at: parentPath) == parentIdentity { + unlinkSocketIfOwnedLocked(path, identity: identity) + } + close(descriptor) + socketPathMutationLock.unlock() + } + + private static func unlinkSocketIfOwnedLocked( + _ path: String, + identity: SocketPathIdentity + ) { + guard socketIdentity(at: path) == identity else { return } + _ = unlink(path) + } + + private static func socketIdentity(at path: String) -> SocketPathIdentity? { + var info = stat() + guard lstat(path, &info) == 0, + info.st_mode & mode_t(S_IFMT) == mode_t(S_IFSOCK), + info.st_uid == geteuid(), + info.st_nlink == 1 else { return nil } + return SocketPathIdentity( + device: info.st_dev, + inode: info.st_ino, + generation: info.st_gen, + birthTimeSeconds: Int64(info.st_birthtimespec.tv_sec), + birthTimeNanoseconds: Int64(info.st_birthtimespec.tv_nsec), + owner: info.st_uid + ) + } + + private static func socketPermissions(at path: String) -> mode_t? { + var info = stat() + guard lstat(path, &info) == 0, + info.st_mode & mode_t(S_IFMT) == mode_t(S_IFSOCK) else { return nil } + return info.st_mode & 0o777 + } + + private static func directoryIdentity(at path: String) -> DirectoryIdentity? { + var info = stat() + guard lstat(path, &info) == 0, + info.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) else { return nil } + return DirectoryIdentity( + device: info.st_dev, + inode: info.st_ino, + generation: info.st_gen, + birthTimeSeconds: Int64(info.st_birthtimespec.tv_sec), + birthTimeNanoseconds: Int64(info.st_birthtimespec.tv_nsec), + owner: info.st_uid, + permissions: info.st_mode & 0o7777 ) } + private static func validateSocketPath(_ path: String) throws { + let bytes = Array(path.utf8) + let address = sockaddr_un() + let maximumBytes = MemoryLayout.size(ofValue: address.sun_path) - 1 + guard path.hasPrefix("/"), !bytes.isEmpty else { + throw VMError.invalidConfiguration("Unix datagram socket path must be absolute: \(path)") + } + guard !bytes.contains(0) else { + throw VMError.invalidConfiguration("Unix datagram socket path contains a NUL byte: \(path)") + } + guard (path as NSString).standardizingPath == path else { + throw VMError.invalidConfiguration("Unix datagram socket path is not canonical: \(path)") + } + guard bytes.count <= maximumBytes else { + throw VMError.invalidConfiguration( + "Unix datagram socket path is too long (\(bytes.count) bytes, maximum \(maximumBytes)): \(path)" + ) + } + } + + private static func validateTrustedParentDirectory(of path: String) throws -> DirectoryIdentity { + let parent = (path as NSString).deletingLastPathComponent + var info = stat() + guard lstat(parent, &info) == 0, + info.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR), + info.st_uid == geteuid(), + info.st_mode & 0o022 == 0 else { + throw VMError.invalidConfiguration( + "Unix datagram socket parent must be a same-user, non-writable directory: \(parent)" + ) + } + + // A private leaf is not authority if another user can rename it through a writable + // ancestor. Validate the resolved chain as root/current-user owned and non-writable, with + // the standard sticky-directory exception that makes /private/tmp safe for owned children. + guard let resolvedPointer = realpath(parent, nil) else { + throw systemCallError("resolve Unix datagram parent", path: parent, code: errno) + } + defer { free(resolvedPointer) } + let resolvedParent = String(cString: resolvedPointer) + var ancestorPath = "" + for component in resolvedParent.split(separator: "/") { + ancestorPath += "/" + component + var ancestor = stat() + guard lstat(ancestorPath, &ancestor) == 0, + ancestor.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR), + ancestor.st_uid == 0 || ancestor.st_uid == geteuid() else { + throw VMError.invalidConfiguration( + "Unix datagram socket ancestor is not trusted: \(ancestorPath)" + ) + } + let isGroupOrWorldWritable = ancestor.st_mode & 0o022 != 0 + let hasStickyOwnershipProtection = ancestor.st_mode & mode_t(S_ISVTX) != 0 + guard !isGroupOrWorldWritable || hasStickyOwnershipProtection else { + throw VMError.invalidConfiguration( + "Unix datagram socket ancestor is writable without sticky protection: \(ancestorPath)" + ) + } + } + guard let identity = directoryIdentity(at: parent) else { + throw systemCallError("capture Unix datagram parent", path: parent, code: ESTALE) + } + return identity + } + + private static func unixAddress(_ path: String) throws -> sockaddr_un { + try validateSocketPath(path) + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(path.utf8) + withUnsafeMutableBytes(of: &address.sun_path) { destination in + bytes.withUnsafeBytes { source in + destination.baseAddress!.copyMemory( + from: source.baseAddress!, + byteCount: bytes.count + ) + } + } + return address + } + + private static func configureSocket(_ descriptor: Int32) throws { + let descriptorFlags = fcntl(descriptor, F_GETFD) + guard descriptorFlags >= 0, + fcntl(descriptor, F_SETFD, descriptorFlags | FD_CLOEXEC) == 0 else { + throw systemCallError("set close-on-exec on network socket", path: "", code: errno) + } + let statusFlags = fcntl(descriptor, F_GETFL) + guard statusFlags >= 0, + fcntl(descriptor, F_SETFL, statusFlags | O_NONBLOCK) == 0 else { + throw systemCallError("make network socket nonblocking", path: "", code: errno) + } + var noSigpipe: Int32 = 1 + guard setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size) + ) == 0 else { + throw systemCallError("set no-sigpipe on network socket", path: "", code: errno) + } + } + private static func setSocketBuffer(_ descriptor: Int32, option: Int32, bytes: Int32) throws { var value = bytes guard setsockopt( @@ -271,41 +2092,120 @@ public final class VirtioNet: VirtioDeviceBackend, @unchecked Sendable { &value, socklen_t(MemoryLayout.size) ) == 0 else { - throw VMError.invalidConfiguration("cannot set network socket buffer option \(option): errno \(errno)") + throw systemCallError("set network socket buffer option \(option)", path: "", code: errno) } } - private static func setNonBlocking(_ descriptor: Int32) throws { - let flags = fcntl(descriptor, F_GETFL, 0) - guard flags >= 0 else { - throw VMError.invalidConfiguration("cannot read network socket flags: errno \(errno)") - } - guard fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0 else { - throw VMError.invalidConfiguration("cannot make network socket nonblocking: errno \(errno)") - } + private static func systemCallError(_ operation: String, path: String, code: Int32) -> VMError { + let suffix = path.isEmpty ? "" : " \(path)" + return VMError.invalidConfiguration("cannot \(operation)\(suffix): errno \(code)") } - /// Test-only observability for the descriptor-level half of the nonblocking TX invariant. - /// Production sends additionally pass MSG_DONTWAIT, so either defense prevents lock pinning. + /// Test-only observability for both halves of the descriptor inheritance invariant. var isSocketNonblockingForTesting: Bool { - let flags = fcntl(socketFD, F_GETFL, 0) + let flags = fcntl(socketOwner.descriptor, F_GETFL) return flags >= 0 && flags & O_NONBLOCK != 0 } - private static func validateSocketPath(_ path: String) throws { - var address = sockaddr_un() - let capacity = withUnsafeBytes(of: &address.sun_path) { $0.count } - guard path.utf8.count < capacity else { - throw VMError.invalidConfiguration( - "unix datagram socket path is too long (\(path.utf8.count) bytes, maximum \(capacity - 1)): \(path)" - ) - } + var isSocketCloseOnExecForTesting: Bool { + let flags = fcntl(socketOwner.descriptor, F_GETFD) + return flags >= 0 && flags & FD_CLOEXEC != 0 } - private static func copyPath(_ path: String, into address: inout sockaddr_un) { - withUnsafeMutableBytes(of: &address.sun_path) { destination in - let bytes = [UInt8](path.utf8.prefix(destination.count - 1)) - destination.copyBytes(from: bytes) + var usesPathnamePeerAuthenticationForTesting: Bool { + socketOwner.usedPathnamePeerAuthentication + } + + var effectiveMTUForTesting: Int { + maximumEthernetFrameLength - Self.ethernetHeaderLength + } + + var deferredReceiveResourceSnapshotForTesting: (frames: Int, bytes: Int) { + receiveState.withLock { ($0.deferredCount, $0.deferredBytes) } + } + + var isReceiveTerminalForTesting: Bool { + receiveState.withLock { $0.terminal } + } + + var isReceiveActiveForTesting: Bool { + receiveState.withLock { $0.transport?.value != nil } + } + + func synchronizeReceiveQueueForTesting() { + receiveQueue.sync {} + } + + func withReceiveQueueSerializedForTesting( + _ body: () throws -> Result + ) rethrows -> Result { + try receiveQueue.sync(execute: body) + } + + func setTransmitDropCountForTesting(_ value: UInt64) { + transmitDrops.store(value, ordering: .relaxed) + } + + func synchronizeTransmitQueueForTesting() { + transmitQueue.sync {} + } + + @discardableResult + func triggerTransmitRetryForTesting() -> Bool { + guard let target = transmitState.withLock({ state in + state.retryRegistration.flatMap { registration in + state.transport?.value.map { (registration.generation, $0) } + } + }) else { return false } + wakeTransmitRetry(generation: target.0, transport: target.1) + return true + } + + var isTransmitRetryPendingForTesting: Bool { + transmitState.withLock { $0.retryRegistration != nil } + } +} + +/// A virtio-net function that remains visible to the guest while its carrier is down. This is +/// deliberately a device, rather than an omitted backend: persistent interface identity and guest +/// configuration survive a later reconnect without accidentally granting host connectivity. +public final class VirtioDisconnectedNet: VirtioDeviceBackend, @unchecked Sendable { + public let deviceID: UInt32 = 1 + public let queueCount = 2 + public let deviceFeatures: UInt64 + public let configSpace: [UInt8] + + public init( + macAddress: [UInt8] = VirtioNet.guestMAC, + maximumTransmissionUnit: UInt16 + ) { + precondition(macAddress.count == 6, "a virtio-net MAC address must contain six bytes") + precondition( + (1_280...9_000).contains(Int(maximumTransmissionUnit)), + "virtio-net MTU must be 1280...9000 bytes" + ) + // virtio_net_config.mac followed by little-endian status. A zero status keeps LINK_UP + // clear, which Linux reports as NO-CARRIER while retaining the interface. + deviceFeatures = (1 << 5) | (1 << 16) | (1 << 3) + configSpace = macAddress + [0, 0, 0, 0] + + [UInt8(truncatingIfNeeded: maximumTransmissionUnit), + UInt8(truncatingIfNeeded: maximumTransmissionUnit >> 8)] + } + + public func handleKick(queue: Int, transport: VirtioMMIOTransport) { + guard queue == 1 else { return } + let virtqueue = transport.queues[1] + var interrupt = false + while true { + do { + guard let chain = try virtqueue.pop() else { break } + interrupt = try virtqueue.push(chain, written: 0) || interrupt + } catch { + // A malformed disconnected TX queue cannot be completed safely; stop this drain + // instead of conflating the fault with an empty queue and walking later entries. + break + } } + if interrupt { transport.notifyUsed() } } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioRng.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioRng.swift index 1b877d87..1bb89baa 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioRng.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioRng.swift @@ -1,31 +1,497 @@ import Foundation import Security +import Synchronization + +public struct VirtioRngStatistics: Equatable, Sendable { + public var completedRequests: UInt64 + public var bytesProvided: UInt64 + public var invalidRequests: UInt64 + public var entropyFailures: UInt64 + public var queueFaults: UInt64 + public var boundedDrainStops: UInt64 + public var workerTurns: UInt64 + public var workerYields: UInt64 + public var coalescedWorkerRequests: UInt64 + public var revokedWorkerTurns: UInt64 + public var entropyProcessingNanoseconds: UInt64 + public var maximumEntropyProcessingNanoseconds: UInt64 +} + +/// Device-specific work bounds. VirtIO permits the device to return fewer bytes than the guest +/// offered, and Linux currently submits only a cache-line-sized buffer. A fixed partial completion +/// therefore avoids guest-sized entropy work while remaining protocol-correct. The turn bound is +/// intentionally much smaller than the ring: a busy guest yields between batches without needing +/// another notification to make progress. +struct VirtioRngLimits: Equatable, Sendable { + static let production = VirtioRngLimits( + maximumBytesPerRequest: 4 * 1_024, + maximumRequestsPerWorkerTurn: 8 + ) + + let maximumBytesPerRequest: Int + let maximumRequestsPerWorkerTurn: Int + + init(maximumBytesPerRequest: Int, maximumRequestsPerWorkerTurn: Int) { + precondition(maximumBytesPerRequest > 0) + precondition(maximumRequestsPerWorkerTurn > 0) + precondition(maximumRequestsPerWorkerTurn <= Int(Virtqueue.maximumSize)) + self.maximumBytesPerRequest = maximumBytesPerRequest + self.maximumRequestsPerWorkerTurn = maximumRequestsPerWorkerTurn + } +} /// virtio-entropy: fills guest buffers from the host CSPRNG. Keeps the guest crng healthy in a -/// machine with almost no interrupt-timing entropy. -public final class VirtioRng: VirtioDeviceBackend { +/// machine with almost no interrupt-timing entropy. Queue notifications only schedule a bounded +/// serial worker turn; CSPRNG calls and used-ring publication never run on the notifying vCPU. +public final class VirtioRng: VirtioDeviceBackend, @unchecked Sendable { public let deviceID: UInt32 = 4 public let queueCount = 1 public let deviceFeatures: UInt64 = 0 + public let kickSynchronization: VirtioKickSynchronization = .backendManaged public var configSpace: [UInt8] { [] } - public init() {} + private final class WeakTransportReference: @unchecked Sendable { + weak var value: VirtioMMIOTransport? + + init(_ value: VirtioMMIOTransport) { + self.value = value + } + } + + private struct WorkerState { + var transport: WeakTransportReference? + var generation: UInt64 = 1 + var scheduled = false + var kickPending = false + } + + private enum RequestAdmission { + case accepted(Int) + case invalid + case revoked + } + + private enum PreparedWork { + case empty + case completed(wantsInterrupt: Bool) + case entropy(chain: VirtqueueChain, requestedBytes: Int) + case fault + case stale + } + + private enum EntropyCompletion { + case published(wantsInterrupt: Bool) + case fault + case stale + } + + private let limits: VirtioRngLimits + private let fillEntropy: @Sendable (UnsafeMutableRawBufferPointer) -> Bool + private let submitWork: (@escaping @Sendable () -> Void) -> Void + private let monotonicNanoseconds: @Sendable () -> UInt64 + private let workerStateLock = NSLock() + // Reset and QueueReady callbacks hold the transport lock before entering this fence. A worker + // never takes the transport lock while holding it. Thus an in-flight entropy fill and its one + // lease-held guest write finish before lifecycle revocation, without a lock-order cycle. + private let lifecycleFence = NSLock() + private var workerState = WorkerState() + private let completedRequests = Atomic(0) + private let bytesProvided = Atomic(0) + private let invalidRequests = Atomic(0) + private let entropyFailures = Atomic(0) + private let queueFaults = Atomic(0) + private let boundedDrainStops = Atomic(0) + private let workerTurns = Atomic(0) + private let workerYields = Atomic(0) + private let coalescedWorkerRequests = Atomic(0) + private let revokedWorkerTurns = Atomic(0) + private let entropyProcessingNanoseconds = Atomic(0) + private let maximumEntropyProcessingNanoseconds = Mutex(0) + + public convenience init() { + let worker = DispatchQueue(label: "dev.dory.virtio-rng", qos: .utility) + self.init( + limits: .production, + fillEntropy: { buffer in + guard let baseAddress = buffer.baseAddress else { return false } + return SecRandomCopyBytes(kSecRandomDefault, buffer.count, baseAddress) + == errSecSuccess + }, + submitWork: { operation in worker.async(execute: operation) } + ) + } + + init( + limits: VirtioRngLimits, + fillEntropy: @escaping @Sendable (UnsafeMutableRawBufferPointer) -> Bool, + submitWork: @escaping (@escaping @Sendable () -> Void) -> Void, + monotonicNanoseconds: @escaping @Sendable () -> UInt64 = { + DispatchTime.now().uptimeNanoseconds + } + ) { + self.limits = limits + self.fillEntropy = fillEntropy + self.submitWork = submitWork + self.monotonicNanoseconds = monotonicNanoseconds + } public func handleKick(queue: Int, transport: VirtioMMIOTransport) { - let virtqueue = transport.queues[0] - var interrupt = false - while let chain = (try? virtqueue.pop()) ?? nil { - var written = 0 - for segment in chain.writableSegments { - if SecRandomCopyBytes(kSecRandomDefault, segment.length, segment.pointer) == errSecSuccess { - written += segment.length + guard queue == 0, transport.queues.indices.contains(queue) else { return } + let generation = workerStateLock.withLock { () -> UInt64? in + if let reference = workerState.transport { + if let existing = reference.value { + guard existing === transport else { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return nil + } + } else { + // Rebinding a synthetic backend after its transport died must first make every + // closure queued for that transport stale. + advanceWorkerGenerationLocked() + workerState.transport = WeakTransportReference(transport) + } + } else { + workerState.transport = WeakTransportReference(transport) + } + + if workerState.scheduled { + workerState.kickPending = true + coalescedWorkerRequests.wrappingAdd(1, ordering: .relaxed) + return nil + } + workerState.scheduled = true + return workerState.generation + } + guard let generation else { return } + submitWorkerTurn(generation: generation, transport: transport) + } + + public func deviceReset(transport: VirtioMMIOTransport) { + revokeWorker(transport: transport) + } + + public func queueStateChanged( + queue: Int, + ready: Bool, + transport: VirtioMMIOTransport + ) { + _ = ready + guard queue == 0 else { return } + revokeWorker(transport: transport) + } + + private func submitWorkerTurn(generation: UInt64, transport: VirtioMMIOTransport) { + submitWork { [weak self, weak transport] in + guard let self, let transport else { return } + self.runWorkerTurn(generation: generation, transport: transport) + } + } + + private func runWorkerTurn(generation: UInt64, transport: VirtioMMIOTransport) { + guard beginWorkerTurn(generation: generation, transport: transport) else { + recordRevokedWorkerTurn(generation: generation, transport: transport) + return + } + workerTurns.wrappingAdd(1, ordering: .relaxed) + var handled = 0 + var wantsInterrupt = false + var stoppedOnFault = false + + while handled < limits.maximumRequestsPerWorkerTurn { + switch prepareWork(generation: generation, transport: transport) { + case .empty: + finishWorkerTurn( + generation: generation, + transport: transport, + wantsInterrupt: wantsInterrupt, + knownPendingWork: false + ) + return + case let .completed(interrupt): + handled += 1 + wantsInterrupt = wantsInterrupt || interrupt + case let .entropy(chain, requestedBytes): + handled += 1 + switch completeEntropy( + chain, + requestedBytes: requestedBytes, + generation: generation, + transport: transport + ) { + case let .published(interrupt): + wantsInterrupt = wantsInterrupt || interrupt + case .fault: + stoppedOnFault = true + case .stale: + recordRevokedWorkerTurn(generation: generation, transport: transport) + return + } + case .fault: + stoppedOnFault = true + case .stale: + recordRevokedWorkerTurn(generation: generation, transport: transport) + return + } + if stoppedOnFault { break } + } + + let pending = stoppedOnFault ? false : pendingWork( + generation: generation, + transport: transport + ) + if pending { + boundedDrainStops.wrappingAdd(1, ordering: .relaxed) + } + finishWorkerTurn( + generation: generation, + transport: transport, + wantsInterrupt: wantsInterrupt, + knownPendingWork: pending + ) + } + + private func prepareWork( + generation: UInt64, + transport: VirtioMMIOTransport + ) -> PreparedWork { + transport.withQueueLock { + guard isCurrentWorker(generation: generation, transport: transport) else { + return .stale + } + let virtqueue = transport.queues[0] + let chain: VirtqueueChain + do { + guard let next = try virtqueue.pop() else { return .empty } + chain = next + } catch { + // pop() consumes a malformed available entry before descriptor resolution fails. + // Stop this turn explicitly rather than walking unrelated later entries. + queueFaults.wrappingAdd(1, ordering: .relaxed) + return .fault + } + + switch admitRequest(chain) { + case .invalid: + invalidRequests.wrappingAdd(1, ordering: .relaxed) + do { + switch try virtqueue.pushOutcome(chain, written: 0) { + case let .published(wantsInterrupt): + return .completed(wantsInterrupt: wantsInterrupt) + case .revoked: + return .stale + } + } catch { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return .fault + } + case .revoked: + return .stale + case let .accepted(requestedBytes): + // The popped chain and its exact queue lease are retained across the CSPRNG call. + // Only this chain can be published, and pushOutcome exposes lifecycle revocation. + return .entropy(chain: chain, requestedBytes: requestedBytes) + } + } + } + + private func completeEntropy( + _ chain: VirtqueueChain, + requestedBytes: Int, + generation: UInt64, + transport: VirtioMMIOTransport + ) -> EntropyCompletion { + // Allocate only the typed device ceiling, never the guest chain length. Entropy remains in + // a host-owned snapshot until the fill reports success, so a failed CSPRNG call cannot + // expose partially initialized bytes to the guest. + var entropy = [UInt8](repeating: 0, count: requestedBytes) + lifecycleFence.lock() + guard isCurrentWorker(generation: generation, transport: transport) else { + lifecycleFence.unlock() + return .stale + } + let startedAt = monotonicNanoseconds() + let didFill = entropy.withUnsafeMutableBytes(fillEntropy) + let written = didFill ? (chain.withLeaseHeld { $0.writeBytes(entropy) } ?? 0) : 0 + let elapsed = monotonicNanoseconds() &- startedAt + lifecycleFence.unlock() + + entropyProcessingNanoseconds.wrappingAdd(elapsed, ordering: .relaxed) + maximumEntropyProcessingNanoseconds.withLock { maximum in + maximum = max(maximum, elapsed) + } + + if didFill { + guard written == requestedBytes else { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return .fault + } + } else { + entropyFailures.wrappingAdd(1, ordering: .relaxed) + } + + return transport.withQueueLock { + guard isCurrentWorker(generation: generation, transport: transport) else { + return .stale + } + do { + switch try transport.queues[0].pushOutcome(chain, written: written) { + case let .published(wantsInterrupt): + if didFill { + completedRequests.wrappingAdd(1, ordering: .relaxed) + bytesProvided.wrappingAdd(UInt64(written), ordering: .relaxed) + } + return .published(wantsInterrupt: wantsInterrupt) + case .revoked: + return .stale + } + } catch { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return .fault + } + } + } + + private func pendingWork(generation: UInt64, transport: VirtioMMIOTransport) -> Bool { + transport.withQueueLock { + guard isCurrentWorker(generation: generation, transport: transport) else { + return false + } + do { + return try transport.queues[0].pendingCount() > 0 + } catch { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return false + } + } + } + + private func finishWorkerTurn( + generation: UInt64, + transport: VirtioMMIOTransport, + wantsInterrupt: Bool, + knownPendingWork: Bool + ) { + if wantsInterrupt { + transport.withQueueLock { + if isCurrentWorker(generation: generation, transport: transport) { + transport.notifyUsed() } } - let wants = (try? virtqueue.push(chain, written: written)) ?? false - interrupt = interrupt || wants } - if interrupt { - transport.notifyUsed() + + let shouldContinue = workerStateLock.withLock { () -> Bool in + guard isCurrentWorkerLocked(generation: generation, transport: transport) else { + return false + } + let pending = knownPendingWork || workerState.kickPending + workerState.kickPending = false + if !pending { + workerState.scheduled = false + } + return pending + } + if shouldContinue { + workerYields.wrappingAdd(1, ordering: .relaxed) + submitWorkerTurn(generation: generation, transport: transport) } } + + private func revokeWorker(transport: VirtioMMIOTransport) { + lifecycleFence.lock() + workerStateLock.withLock { + if let existing = workerState.transport?.value, existing !== transport { + queueFaults.wrappingAdd(1, ordering: .relaxed) + return + } + if workerState.transport == nil { + workerState.transport = WeakTransportReference(transport) + } + advanceWorkerGenerationLocked() + } + lifecycleFence.unlock() + } + + private func advanceWorkerGenerationLocked() { + workerState.generation &+= 1 + if workerState.generation == 0 { + workerState.generation = 1 + } + workerState.scheduled = false + workerState.kickPending = false + } + + private func recordRevokedWorkerTurn( + generation: UInt64, + transport: VirtioMMIOTransport + ) { + revokedWorkerTurns.wrappingAdd(1, ordering: .relaxed) + // A typed queue-lease revocation can theoretically precede a lifecycle callback. If the + // worker generation is otherwise still current, revoke it here so no scheduled bit can + // strand later kicks. Normal reset/QueueReady paths have already advanced the generation. + workerStateLock.withLock { + if isCurrentWorkerLocked(generation: generation, transport: transport) { + advanceWorkerGenerationLocked() + } + } + } + + private func isCurrentWorker(generation: UInt64, transport: VirtioMMIOTransport) -> Bool { + workerStateLock.withLock { + isCurrentWorkerLocked(generation: generation, transport: transport) + } + } + + private func beginWorkerTurn( + generation: UInt64, + transport: VirtioMMIOTransport + ) -> Bool { + workerStateLock.withLock { + guard isCurrentWorkerLocked(generation: generation, transport: transport) else { + return false + } + // Notifications coalesced before this turn began are covered by the queue snapshot it + // is about to drain. A later notification sets the bit again and forces a continuation. + workerState.kickPending = false + return true + } + } + + private func isCurrentWorkerLocked( + generation: UInt64, + transport: VirtioMMIOTransport + ) -> Bool { + workerState.transport?.value === transport + && workerState.generation == generation + && workerState.scheduled + } + + private func admitRequest(_ chain: VirtqueueChain) -> RequestAdmission { + chain.withLeaseHeld { access -> RequestAdmission in + // VirtIO 1.3 section 5.4.6.1 forbids device-readable entropy buffers. A raw + // zero-length descriptor is also not a usable request, even when another segment in + // the same chain has data. + guard !chain.containsZeroLengthDescriptor, + access.readableSegmentCount == 0, + access.writableSegmentCount > 0, + access.writableByteCount > 0 else { return .invalid } + return .accepted(min(access.writableByteCount, limits.maximumBytesPerRequest)) + } ?? .revoked + } + + public var statistics: VirtioRngStatistics { + VirtioRngStatistics( + completedRequests: completedRequests.load(ordering: .relaxed), + bytesProvided: bytesProvided.load(ordering: .relaxed), + invalidRequests: invalidRequests.load(ordering: .relaxed), + entropyFailures: entropyFailures.load(ordering: .relaxed), + queueFaults: queueFaults.load(ordering: .relaxed), + boundedDrainStops: boundedDrainStops.load(ordering: .relaxed), + workerTurns: workerTurns.load(ordering: .relaxed), + workerYields: workerYields.load(ordering: .relaxed), + coalescedWorkerRequests: coalescedWorkerRequests.load(ordering: .relaxed), + revokedWorkerTurns: revokedWorkerTurns.load(ordering: .relaxed), + entropyProcessingNanoseconds: entropyProcessingNanoseconds.load(ordering: .relaxed), + maximumEntropyProcessingNanoseconds: maximumEntropyProcessingNanoseconds.withLock { $0 } + ) + } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift new file mode 100644 index 00000000..49929ece --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioSound.swift @@ -0,0 +1,1203 @@ +import DoryFSWorkerContracts +import Foundation + +public enum VirtioSoundDirection: UInt8, Sendable { + case output = 0 + case input = 1 +} + +public struct VirtioSoundPCMParameters: Equatable, Sendable { + public var bufferBytes: Int + public var periodBytes: Int + public var sampleRate: Double + public var channels: Int + public var bytesPerSample: Int + + public init( + bufferBytes: Int, + periodBytes: Int, + sampleRate: Double, + channels: Int, + bytesPerSample: Int = 2 + ) { + self.bufferBytes = bufferBytes + self.periodBytes = periodBytes + self.sampleRate = sampleRate + self.channels = channels + self.bytesPerSample = bytesPerSample + } + + public var bytesPerFrame: Int { channels * bytesPerSample } +} + +/// Host audio implementation used by the virtio-snd transport. Dory's production implementation +/// is backed by AVAudioEngine; tests use an in-memory backend so the device state machine and queue +/// completion rules remain deterministic. +public protocol VirtioSoundHost: AnyObject, Sendable { + func configure( + streamID: Int, + direction: VirtioSoundDirection, + parameters: VirtioSoundPCMParameters + ) -> Bool + func prepare(streamID: Int, direction: VirtioSoundDirection) -> Bool + func start(streamID: Int, direction: VirtioSoundDirection) -> Bool + func stop(streamID: Int, direction: VirtioSoundDirection) -> Bool + func release(streamID: Int, direction: VirtioSoundDirection) + func enqueuePlayback( + _ data: Data, + parameters: VirtioSoundPCMParameters, + completion: @escaping @Sendable (_ success: Bool, _ latencyBytes: UInt32) -> Void + ) -> Bool + func requestCapture( + byteCount: Int, + parameters: VirtioSoundPCMParameters, + completion: @escaping @Sendable (_ data: Data?, _ latencyBytes: UInt32) -> Void + ) -> Bool + func reset() +} + +public struct VirtioSoundStatistics: Equatable, Sendable { + public var invalidControlChains: UInt64 + public var invalidEventChains: UInt64 + public var invalidPlaybackChains: UInt64 + public var invalidCaptureChains: UInt64 + public var completedPlaybackPeriods: UInt64 + public var completedCapturePeriods: UInt64 + public var timedOutPeriods: UInt64 + public var lateHostCompletions: UInt64 + public var backpressuredPeriods: UInt64 + public var queueFaults: UInt64 + public var publicationFaults: UInt64 + public var boundedDrainStops: UInt64 +} + +/// Frontend work/retention limits. Byte ceilings intentionally match the production Core Audio +/// backend, while the period and kick ceilings are independent untrusted-guest admission bounds. +struct VirtioSoundLimits: Equatable, Sendable { + static let production = VirtioSoundLimits( + maximumBufferBytes: 4 * 1_024 * 1_024, + maximumPeriodBytes: 1 * 1_024 * 1_024, + maximumPeriodsPerStream: 64, + maximumChainsPerKick: 64, + maximumBytesPerKick: 4 * 1_024 * 1_024, + maximumRetainedEventBuffers: 64, + completionTimeout: .seconds(30) + ) + + let maximumBufferBytes: Int + let maximumPeriodBytes: Int + let maximumPeriodsPerStream: Int + let maximumChainsPerKick: Int + let maximumBytesPerKick: Int + let maximumRetainedEventBuffers: Int + let completionTimeout: Duration + + init( + maximumBufferBytes: Int, + maximumPeriodBytes: Int, + maximumPeriodsPerStream: Int, + maximumChainsPerKick: Int, + maximumBytesPerKick: Int, + maximumRetainedEventBuffers: Int, + completionTimeout: Duration + ) { + precondition(maximumBufferBytes > 0) + precondition(maximumPeriodBytes > 0 && maximumPeriodBytes <= maximumBufferBytes) + precondition(maximumPeriodsPerStream > 0) + precondition(maximumChainsPerKick > 0) + precondition(maximumChainsPerKick <= Int(Virtqueue.maximumSize)) + precondition(maximumBytesPerKick >= maximumPeriodBytes) + precondition(maximumRetainedEventBuffers > 0) + precondition(completionTimeout > .zero) + self.maximumBufferBytes = maximumBufferBytes + self.maximumPeriodBytes = maximumPeriodBytes + self.maximumPeriodsPerStream = maximumPeriodsPerStream + self.maximumChainsPerKick = maximumChainsPerKick + self.maximumBytesPerKick = maximumBytesPerKick + self.maximumRetainedEventBuffers = maximumRetainedEventBuffers + self.completionTimeout = completionTimeout + } +} + +protocol VirtioSoundScheduledOperation: AnyObject, Sendable { + func cancel() +} + +protocol VirtioSoundCompletionScheduling: Sendable { + func schedule( + after delay: Duration, + operation: @escaping @Sendable () -> Void + ) -> any VirtioSoundScheduledOperation +} + +private final class TaskVirtioSoundScheduledOperation: + VirtioSoundScheduledOperation, + @unchecked Sendable +{ + private let task: Task + + init(task: Task) { + self.task = task + } + + func cancel() { task.cancel() } + + deinit { task.cancel() } +} + +private struct TaskVirtioSoundCompletionScheduler: VirtioSoundCompletionScheduling { + func schedule( + after delay: Duration, + operation: @escaping @Sendable () -> Void + ) -> any VirtioSoundScheduledOperation { + let task = Task { + do { + try await Task.sleep(for: delay) + } catch { + return + } + guard !Task.isCancelled else { return } + operation() + } + return TaskVirtioSoundScheduledOperation(task: task) + } +} + +/// VirtIO 1.3 sound with the explicitly enabled PCM directions. No optional sound feature is +/// advertised: shared-memory transport, polling, period events, XRUN events, jacks, channel maps, +/// and mixer controls remain unsupported rather than being emulated incompletely. Omitting input +/// removes the capture PCM stream entirely, so a UI privacy toggle cannot retain microphone access. +public final class VirtioSound: VirtioDeviceBackend, @unchecked Sendable { + public let deviceID: UInt32 = 25 + public let deviceFeatures: UInt64 = 0 + public let queueCount = 4 // control, event, playback TX, capture RX + + private enum Request { + static let pcmInfo: UInt32 = 0x0100 + static let pcmSetParameters: UInt32 = 0x0101 + static let pcmPrepare: UInt32 = 0x0102 + static let pcmRelease: UInt32 = 0x0103 + static let pcmStart: UInt32 = 0x0104 + static let pcmStop: UInt32 = 0x0105 + } + + private enum Status: UInt32 { + case ok = 0x8000 + case badMessage = 0x8001 + case notSupported = 0x8002 + case ioError = 0x8003 + } + + private enum Lifecycle { + case idle + case parameters + case prepared + case running + case faulted + } + + private enum PendingKind: Sendable { + case playback + case capture + } + + private enum CompletionSource: Equatable { + case host + case hostRejected + case watchdog + } + + private struct Stream { + var direction: VirtioSoundDirection + var lifecycle: Lifecycle = .idle + var parameters: VirtioSoundPCMParameters? + } + + private struct PendingIO { + var streamID: Int + var chain: VirtqueueChain + var generation: UInt64 + var payloadBytes: Int + var watchdog: (any VirtioSoundScheduledOperation)? + } + + private struct OrderedLayout { + var readableBytes: Int + var writableBytes: Int + } + + private static let controlStatusSize = 4 + private static let pcmTransferHeaderSize = 4 + private static let pcmStatusSize = 8 + private static let eventSize = 8 + private static let queryInfoSize = 16 + private static let setParametersSize = 24 + private static let pcmHeaderSize = 8 + private static let maximumControlRequestSize = setParametersSize + private static let pcmInfoSize = 32 + private static let s16Format: UInt8 = 5 + private static let supportedFormats: UInt64 = 1 << s16Format + private static let rateValues: [UInt8: Double] = [6: 44_100, 7: 48_000] + private static let supportedRates: UInt64 = rateValues.keys.reduce(0) { $0 | (1 << $1) } + + private let host: VirtioSoundHost + private let log: @Sendable (String) -> Void + private let limits: VirtioSoundLimits + private let completionScheduler: any VirtioSoundCompletionScheduling + private let streamDirections: [VirtioSoundDirection] + private let lock = NSLock() + private var streams: [Stream] + private var nextRequestID: UInt64 = 1 + // Each stream advances independently so releasing capture cannot invalidate an in-flight + // playback completion (and vice versa). + private var streamGenerations: [UInt64] + private var pendingPlayback = [UInt64: PendingIO]() + private var pendingCapture = [UInt64: PendingIO]() + private var retainedEventBuffers = [VirtqueueChain]() + private var terminalQueues = Set() + private var statisticsState = VirtioSoundStatistics( + invalidControlChains: 0, + invalidEventChains: 0, + invalidPlaybackChains: 0, + invalidCaptureChains: 0, + completedPlaybackPeriods: 0, + completedCapturePeriods: 0, + timedOutPeriods: 0, + lateHostCompletions: 0, + backpressuredPeriods: 0, + queueFaults: 0, + publicationFaults: 0, + boundedDrainStops: 0 + ) + + public convenience init( + host: VirtioSoundHost, + enabledDirections: [VirtioSoundDirection] = [.output, .input], + log: @escaping @Sendable (String) -> Void = { _ in } + ) { + self.init( + host: host, + enabledDirections: enabledDirections, + log: log, + limits: .production, + completionScheduler: TaskVirtioSoundCompletionScheduler() + ) + } + + init( + host: VirtioSoundHost, + enabledDirections: [VirtioSoundDirection] = [.output, .input], + log: @escaping @Sendable (String) -> Void = { _ in }, + limits: VirtioSoundLimits, + completionScheduler: any VirtioSoundCompletionScheduling + ) { + let canonicalDirections = [VirtioSoundDirection.output, .input].filter { + enabledDirections.contains($0) + } + precondition(!canonicalDirections.isEmpty) + precondition(canonicalDirections.count == enabledDirections.count) + self.host = host + self.log = log + self.limits = limits + self.completionScheduler = completionScheduler + self.streamDirections = canonicalDirections + self.streams = canonicalDirections.map { Stream(direction: $0) } + self.streamGenerations = [UInt64]( + repeating: 1, + count: canonicalDirections.count + ) + } + + deinit { + let watchdogs = lock.withLock { + let values = Array(pendingPlayback.values) + Array(pendingCapture.values) + pendingPlayback.removeAll() + pendingCapture.removeAll() + retainedEventBuffers.removeAll() + return values.compactMap(\.watchdog) + } + watchdogs.forEach { $0.cancel() } + } + + public var configSpace: [UInt8] { + var bytes = [UInt8]() + bytes.appendLE(UInt32(0)) + bytes.appendLE(UInt32(streamDirections.count)) + bytes.appendLE(UInt32(0)) + return bytes + } + + public var statistics: VirtioSoundStatistics { + lock.withLock { statisticsState } + } + + public func handleKick(queue: Int, transport: VirtioMMIOTransport) { + guard (0.. 0 || discardedCapture > 0 { + log( + "virtio sound queue \(queue) reconfigured ready=\(ready) with " + + "playback=\(discardedPlayback), capture=\(discardedCapture) pending" + ) + } + } + + private func drainControlQueue(_ transport: VirtioMMIOTransport) { + let queue = transport.queues[0] + var interrupt = false + var handled = 0 + while handled < limits.maximumChainsPerKick { + guard let chain = pop(queue, queueIndex: 0) else { break } + handled += 1 + let admission = chain.withLeaseHeld { access -> ([UInt8], Int)? in + guard let layout = Self.orderedLayout(access), + !chain.containsZeroLengthDescriptor, + layout.readableBytes >= Self.controlStatusSize, + layout.readableBytes <= Self.maximumControlRequestSize, + layout.writableBytes >= Self.controlStatusSize else { return nil } + let request = access.readBytes(maximum: Self.maximumControlRequestSize) + return request.count == layout.readableBytes + ? (request, layout.writableBytes) + : nil + } ?? nil + guard let (request, capacity) = admission else { + lock.withLock { statisticsState.invalidControlChains &+= 1 } + guard push(chain, queue: queue, queueIndex: 0, written: 0, interrupt: &interrupt) else { + break + } + continue + } + let response = processControlRequest( + request, + responseCapacity: capacity, + transport: transport + ) + let written = chain.withLeaseHeld { $0.writeBytes(response) } ?? 0 + guard written == response.count else { + recordPublicationFault(queue: 0, reason: "short control response write") + break + } + guard push(chain, queue: queue, queueIndex: 0, written: written, interrupt: &interrupt) else { + break + } + } + recordBoundedStopIfNeeded(handled: handled, queue: queue) + if interrupt { transport.notifyUsed() } + } + + /// Linux pre-populates eventq with exact writable 8-byte entries. No event-producing feature + /// is advertised, but retaining a bounded validated prefix prevents arbitrary guest chains + /// from becoming hidden state and leaves an explicit admission seam for future event support. + private func drainEventQueue(_ transport: VirtioMMIOTransport) { + let queue = transport.queues[1] + var interrupt = false + var handled = 0 + while handled < limits.maximumChainsPerKick { + guard lock.withLock({ retainedEventBuffers.count < limits.maximumRetainedEventBuffers }) else { + lock.withLock { statisticsState.backpressuredPeriods &+= 1 } + break + } + guard let chain = pop(queue, queueIndex: 1) else { break } + handled += 1 + let valid = chain.withLeaseHeld { access in + !chain.containsZeroLengthDescriptor + && access.readableSegmentCount == 0 + && access.writableSegmentCount > 0 + && access.writableByteCount == Self.eventSize + } ?? false + if valid { + lock.withLock { retainedEventBuffers.append(chain) } + } else { + lock.withLock { statisticsState.invalidEventChains &+= 1 } + guard push(chain, queue: queue, queueIndex: 1, written: 0, interrupt: &interrupt) else { + break + } + } + } + recordBoundedStopIfNeeded(handled: handled, queue: queue) + if interrupt { transport.notifyUsed() } + } + + private func drainPlaybackQueue(_ transport: VirtioMMIOTransport) { + let queue = transport.queues[2] + var interrupt = false + var handled = 0 + var copiedBytes = 0 + while handled < limits.maximumChainsPerKick { + if lock.withLock({ pendingPlayback.count >= limits.maximumPeriodsPerStream }) { + lock.withLock { + statisticsState.backpressuredPeriods &+= 1 + statisticsState.boundedDrainStops &+= 1 + } + break + } + guard let chain = pop(queue, queueIndex: 2) else { break } + handled += 1 + let admission = chain.withLeaseHeld { access -> ([UInt8], Int)? in + guard let layout = Self.orderedLayout(access), + !chain.containsZeroLengthDescriptor, + layout.readableBytes > Self.pcmTransferHeaderSize, + layout.readableBytes <= Self.pcmTransferHeaderSize + limits.maximumPeriodBytes, + layout.writableBytes == Self.pcmStatusSize else { return nil } + let request = access.readBytes( + maximum: Self.pcmTransferHeaderSize + limits.maximumPeriodBytes + ) + return request.count == layout.readableBytes + ? (request, layout.readableBytes - Self.pcmTransferHeaderSize) + : nil + } ?? nil + guard let (request, payloadBytes) = admission else { + lock.withLock { statisticsState.invalidPlaybackChains &+= 1 } + guard push(chain, queue: queue, queueIndex: 2, written: 0, interrupt: &interrupt) else { + break + } + continue + } + guard payloadBytes <= limits.maximumBytesPerKick - copiedBytes else { + lock.withLock { + statisticsState.backpressuredPeriods &+= 1 + statisticsState.boundedDrainStops &+= 1 + } + guard completePlaybackImmediately(chain, queue: queue, interrupt: &interrupt) else { break } + continue + } + copiedBytes += payloadBytes + let streamID = Int(request.leUInt32(at: 0)) + let audio = Data(request[Self.pcmTransferHeaderSize...]) + let reservation: (UInt64, VirtioSoundPCMParameters)? = lock.withLock { + guard streams.indices.contains(streamID), + streams[streamID].direction == .output, + streams[streamID].lifecycle == .prepared + || streams[streamID].lifecycle == .running, + let parameters = streams[streamID].parameters, + payloadBytes <= parameters.periodBytes, + payloadBytes % parameters.bytesPerFrame == 0, + pendingPlayback.count < limits.maximumPeriodsPerStream, + Self.pendingBytes(pendingPlayback) <= parameters.bufferBytes - payloadBytes else { + return nil + } + let requestID = allocateRequestID() + pendingPlayback[requestID] = PendingIO( + streamID: streamID, + chain: chain, + generation: streamGenerations[streamID], + payloadBytes: payloadBytes, + watchdog: nil + ) + return (requestID, parameters) + } + guard let (requestID, parameters) = reservation else { + lock.withLock { statisticsState.invalidPlaybackChains &+= 1 } + guard completePlaybackImmediately(chain, queue: queue, interrupt: &interrupt) else { break } + continue + } + let accepted = host.enqueuePlayback(audio, parameters: parameters) { + [weak self, weak transport] success, latency in + guard let self, let transport else { return } + self.completePlayback( + requestID: requestID, + success: success, + latencyBytes: latency, + source: .host, + transport: transport + ) + } + if accepted { + installWatchdog(kind: .playback, requestID: requestID, transport: transport) + } else { + completePlayback( + requestID: requestID, + success: false, + latencyBytes: 0, + source: .hostRejected, + transport: transport + ) + } + } + recordBoundedStopIfNeeded(handled: handled, queue: queue) + if interrupt { transport.notifyUsed() } + } + + private func drainCaptureQueue(_ transport: VirtioMMIOTransport) { + let queue = transport.queues[3] + var interrupt = false + var handled = 0 + var requestedBytes = 0 + while handled < limits.maximumChainsPerKick { + if lock.withLock({ pendingCapture.count >= limits.maximumPeriodsPerStream }) { + lock.withLock { + statisticsState.backpressuredPeriods &+= 1 + statisticsState.boundedDrainStops &+= 1 + } + break + } + guard let chain = pop(queue, queueIndex: 3) else { break } + handled += 1 + let admission = chain.withLeaseHeld { access -> (UInt32, Int)? in + guard let layout = Self.orderedLayout(access), + !chain.containsZeroLengthDescriptor, + layout.readableBytes == Self.pcmTransferHeaderSize, + layout.writableBytes > Self.pcmStatusSize, + layout.writableBytes <= limits.maximumPeriodBytes + Self.pcmStatusSize else { + return nil + } + let request = access.readBytes(maximum: Self.pcmTransferHeaderSize) + guard request.count == Self.pcmTransferHeaderSize else { return nil } + return (request.leUInt32(at: 0), layout.writableBytes - Self.pcmStatusSize) + } ?? nil + guard let (rawStreamID, payloadBytes) = admission else { + lock.withLock { statisticsState.invalidCaptureChains &+= 1 } + guard push(chain, queue: queue, queueIndex: 3, written: 0, interrupt: &interrupt) else { + break + } + continue + } + guard payloadBytes <= limits.maximumBytesPerKick - requestedBytes else { + lock.withLock { + statisticsState.backpressuredPeriods &+= 1 + statisticsState.boundedDrainStops &+= 1 + } + guard completeCaptureImmediately( + chain, + payloadBytes: payloadBytes, + queue: queue, + interrupt: &interrupt + ) else { break } + continue + } + requestedBytes += payloadBytes + let streamID = Int(rawStreamID) + let reservation: (UInt64, VirtioSoundPCMParameters)? = lock.withLock { + guard streams.indices.contains(streamID), + streams[streamID].direction == .input, + streams[streamID].lifecycle == .prepared + || streams[streamID].lifecycle == .running, + let parameters = streams[streamID].parameters, + payloadBytes <= parameters.periodBytes, + payloadBytes % parameters.bytesPerFrame == 0, + pendingCapture.count < limits.maximumPeriodsPerStream, + Self.pendingBytes(pendingCapture) <= parameters.bufferBytes - payloadBytes else { + return nil + } + let requestID = allocateRequestID() + pendingCapture[requestID] = PendingIO( + streamID: streamID, + chain: chain, + generation: streamGenerations[streamID], + payloadBytes: payloadBytes, + watchdog: nil + ) + return (requestID, parameters) + } + guard let (requestID, parameters) = reservation else { + lock.withLock { statisticsState.invalidCaptureChains &+= 1 } + guard completeCaptureImmediately( + chain, + payloadBytes: payloadBytes, + queue: queue, + interrupt: &interrupt + ) else { break } + continue + } + let accepted = host.requestCapture(byteCount: payloadBytes, parameters: parameters) { + [weak self, weak transport] data, latency in + guard let self, let transport else { return } + self.completeCapture( + requestID: requestID, + data: data, + latencyBytes: latency, + source: .host, + transport: transport + ) + } + if accepted { + installWatchdog(kind: .capture, requestID: requestID, transport: transport) + } else { + completeCapture( + requestID: requestID, + data: nil, + latencyBytes: 0, + source: .hostRejected, + transport: transport + ) + } + } + recordBoundedStopIfNeeded(handled: handled, queue: queue) + if interrupt { transport.notifyUsed() } + } + + private func installWatchdog( + kind: PendingKind, + requestID: UInt64, + transport: VirtioMMIOTransport + ) { + let operation = completionScheduler.schedule(after: limits.completionTimeout) { + [weak self, weak transport] in + guard let self, let transport else { return } + switch kind { + case .playback: + self.completePlayback( + requestID: requestID, + success: false, + latencyBytes: 0, + source: .watchdog, + transport: transport + ) + case .capture: + self.completeCapture( + requestID: requestID, + data: nil, + latencyBytes: 0, + source: .watchdog, + transport: transport + ) + } + } + let installed = lock.withLock { + switch kind { + case .playback: + guard var pending = pendingPlayback[requestID] else { return false } + pending.watchdog = operation + pendingPlayback[requestID] = pending + case .capture: + guard var pending = pendingCapture[requestID] else { return false } + pending.watchdog = operation + pendingCapture[requestID] = pending + } + return true + } + if !installed { operation.cancel() } + } + + private func completePlayback( + requestID: UInt64, + success: Bool, + latencyBytes: UInt32, + source: CompletionSource, + transport: VirtioMMIOTransport + ) { + var interrupt = false + var watchdog: (any VirtioSoundScheduledOperation)? + var published = false + transport.withQueueLock { + let pending: PendingIO? = lock.withLock { + guard let value = pendingPlayback.removeValue(forKey: requestID), + value.generation == streamGenerations[value.streamID] else { + if source == .host { statisticsState.lateHostCompletions &+= 1 } + return nil + } + if source == .watchdog { statisticsState.timedOutPeriods &+= 1 } + watchdog = value.watchdog + return value + } + guard let pending, + !isTerminal(2), + transport.queues[2].ready, + transport.queues[2].isLeaseValid(pending.chain) else { return } + let status = Self.ioStatus(success ? .ok : .ioError, latencyBytes: latencyBytes) + let written = pending.chain.withLeaseHeld { $0.writeBytes(status) } ?? 0 + guard written == Self.pcmStatusSize else { + recordPublicationFault(queue: 2, reason: "short playback status write") + return + } + published = push( + pending.chain, + queue: transport.queues[2], + queueIndex: 2, + written: written, + interrupt: &interrupt + ) + } + watchdog?.cancel() + if published { lock.withLock { statisticsState.completedPlaybackPeriods &+= 1 } } + if interrupt { transport.notifyUsed() } + } + + private func completeCapture( + requestID: UInt64, + data: Data?, + latencyBytes: UInt32, + source: CompletionSource, + transport: VirtioMMIOTransport + ) { + var interrupt = false + var watchdog: (any VirtioSoundScheduledOperation)? + var published = false + transport.withQueueLock { + let pending: PendingIO? = lock.withLock { + guard let value = pendingCapture.removeValue(forKey: requestID), + value.generation == streamGenerations[value.streamID] else { + if source == .host { statisticsState.lateHostCompletions &+= 1 } + return nil + } + if source == .watchdog { statisticsState.timedOutPeriods &+= 1 } + watchdog = value.watchdog + return value + } + guard let pending, + !isTerminal(3), + transport.queues[3].ready, + transport.queues[3].isLeaseValid(pending.chain) else { return } + let validData = data.flatMap { $0.count == pending.payloadBytes ? $0 : nil } + let publication = pending.chain.withLeaseHeld { access -> Int? in + var payloadWritten = 0 + if let validData { + payloadWritten = access.writeBytes(Array(validData)) + guard payloadWritten == pending.payloadBytes else { return nil } + } + let status = Self.ioStatus( + validData == nil ? .ioError : .ok, + latencyBytes: latencyBytes + ) + let statusWritten = access.writeBytes(status, atWritableOffset: pending.payloadBytes) + guard statusWritten == Self.pcmStatusSize else { return nil } + return validData == nil ? Self.pcmStatusSize : payloadWritten + statusWritten + } ?? nil + guard let written = publication else { + recordPublicationFault(queue: 3, reason: "short capture response write") + return + } + published = push( + pending.chain, + queue: transport.queues[3], + queueIndex: 3, + written: written, + interrupt: &interrupt + ) + } + watchdog?.cancel() + if published { lock.withLock { statisticsState.completedCapturePeriods &+= 1 } } + if interrupt { transport.notifyUsed() } + } + + private func completePlaybackImmediately( + _ chain: VirtqueueChain, + queue: Virtqueue, + interrupt: inout Bool + ) -> Bool { + let written = chain.withLeaseHeld { + $0.writeBytes(Self.ioStatus(.ioError, latencyBytes: 0)) + } ?? 0 + return push( + chain, + queue: queue, + queueIndex: 2, + written: written == Self.pcmStatusSize ? written : 0, + interrupt: &interrupt + ) + } + + private func completeCaptureImmediately( + _ chain: VirtqueueChain, + payloadBytes: Int, + queue: Virtqueue, + interrupt: inout Bool + ) -> Bool { + let written = chain.withLeaseHeld { + $0.writeBytes(Self.ioStatus(.ioError, latencyBytes: 0), atWritableOffset: payloadBytes) + } ?? 0 + guard written == Self.pcmStatusSize else { + recordPublicationFault(queue: 3, reason: "short immediate capture status write") + return false + } + return push( + chain, + queue: queue, + queueIndex: 3, + written: written, + interrupt: &interrupt + ) + } + + private func processControlRequest( + _ request: [UInt8], + responseCapacity: Int, + transport: VirtioMMIOTransport? + ) -> [UInt8] { + guard responseCapacity >= Self.controlStatusSize else { return [] } + guard request.count >= Self.controlStatusSize else { return Self.header(.badMessage) } + let code = request.leUInt32(at: 0) + switch code { + case Request.pcmInfo: + guard request.count == Self.queryInfoSize else { return Self.header(.badMessage) } + return pcmInfoResponse(request, responseCapacity: responseCapacity) + case Request.pcmSetParameters: + guard request.count == Self.setParametersSize else { return Self.header(.badMessage) } + return setParametersResponse(request, responseCapacity: responseCapacity) + case Request.pcmPrepare, Request.pcmRelease, Request.pcmStart, Request.pcmStop: + guard request.count == Self.pcmHeaderSize else { return Self.header(.badMessage) } + return lifecycleResponse( + code: code, + streamID: Int(request.leUInt32(at: 4)), + responseCapacity: responseCapacity, + transport: transport + ) + default: + return Self.header(.notSupported) + } + } + + private func pcmInfoResponse(_ request: [UInt8], responseCapacity: Int) -> [UInt8] { + let start = Int(request.leUInt32(at: 4)) + let count = Int(request.leUInt32(at: 8)) + let itemSize = Int(request.leUInt32(at: 12)) + guard count > 0, start < streamDirections.count, + count <= streamDirections.count - start, + itemSize == Self.pcmInfoSize else { + return Self.header(.badMessage) + } + let requiredCapacity = Self.controlStatusSize + count * Self.pcmInfoSize + guard responseCapacity >= requiredCapacity else { return Self.header(.badMessage) } + var response = Self.header(.ok) + response.reserveCapacity(requiredCapacity) + for streamID in start..<(start + count) { + response.appendLE(UInt32(1)) + response.appendLE(UInt32(0)) + response.appendLE(Self.supportedFormats) + response.appendLE(Self.supportedRates) + response.append(streamDirections[streamID].rawValue) + response.append(1) + response.append(2) + response.append(contentsOf: repeatElement(0, count: 5)) + } + return response + } + + private func setParametersResponse(_ request: [UInt8], responseCapacity: Int) -> [UInt8] { + guard responseCapacity >= Self.controlStatusSize else { return [] } + let streamID = Int(request.leUInt32(at: 4)) + let bufferBytes = Int(request.leUInt32(at: 8)) + let periodBytes = Int(request.leUInt32(at: 12)) + let features = request.leUInt32(at: 16) + let channels = Int(request[20]) + let format = request[21] + let rate = request[22] + guard streams.indices.contains(streamID), + bufferBytes > 0, bufferBytes <= limits.maximumBufferBytes, + periodBytes > 0, periodBytes <= limits.maximumPeriodBytes, + periodBytes <= bufferBytes, + bufferBytes % periodBytes == 0, + bufferBytes / periodBytes <= limits.maximumPeriodsPerStream, + features == 0, + channels >= 1, channels <= 2, + format == Self.s16Format, + let sampleRate = Self.rateValues[rate], + request[23] == 0 else { + return Self.header(.notSupported) + } + let bytesPerFrame = channels * 2 + guard periodBytes % bytesPerFrame == 0, + bufferBytes % bytesPerFrame == 0 else { + return Self.header(.badMessage) + } + let current = lock.withLock { () -> Stream? in + guard pendingPlayback.values.allSatisfy({ $0.streamID != streamID }), + pendingCapture.values.allSatisfy({ $0.streamID != streamID }), + streams[streamID].lifecycle != .running, + streams[streamID].lifecycle != .faulted else { return nil } + return streams[streamID] + } + guard let current else { return Self.header(.ioError) } + let parameters = VirtioSoundPCMParameters( + bufferBytes: bufferBytes, + periodBytes: periodBytes, + sampleRate: sampleRate, + channels: channels + ) + guard host.configure( + streamID: streamID, + direction: current.direction, + parameters: parameters + ) else { + return Self.header(.ioError) + } + lock.withLock { + streams[streamID].parameters = parameters + streams[streamID].lifecycle = .parameters + } + return Self.header(.ok) + } + + private func lifecycleResponse( + code: UInt32, + streamID: Int, + responseCapacity: Int, + transport: VirtioMMIOTransport? + ) -> [UInt8] { + guard responseCapacity >= Self.controlStatusSize else { return [] } + guard streams.indices.contains(streamID) else { return Self.header(.badMessage) } + let current = lock.withLock { streams[streamID] } + switch code { + case Request.pcmPrepare: + guard current.parameters != nil, + current.lifecycle == .parameters || current.lifecycle == .prepared, + host.prepare(streamID: streamID, direction: current.direction) else { + return Self.header(.ioError) + } + lock.withLock { streams[streamID].lifecycle = .prepared } + case Request.pcmStart: + guard current.lifecycle == .prepared, + host.start(streamID: streamID, direction: current.direction) else { + return Self.header(.ioError) + } + lock.withLock { streams[streamID].lifecycle = .running } + case Request.pcmStop: + guard current.lifecycle == .running, + host.stop(streamID: streamID, direction: current.direction) else { + return Self.header(.ioError) + } + lock.withLock { streams[streamID].lifecycle = .prepared } + case Request.pcmRelease: + guard current.lifecycle == .prepared || current.lifecycle == .parameters else { + return Self.header(.ioError) + } + let flushed: Bool + if let transport { + flushed = flushPending(streamID: streamID, transport: transport) + } else { + flushed = lock.withLock { + pendingPlayback.values.allSatisfy { $0.streamID != streamID } + && pendingCapture.values.allSatisfy { $0.streamID != streamID } + } + } + host.release(streamID: streamID, direction: current.direction) + lock.withLock { + streams[streamID].lifecycle = flushed ? .parameters : .faulted + } + guard flushed else { return Self.header(.ioError) } + default: + return Self.header(.notSupported) + } + return Self.header(.ok) + } + + private func flushPending(streamID: Int, transport: VirtioMMIOTransport) -> Bool { + let playback: [PendingIO] + let capture: [PendingIO] + lock.lock() + streamGenerations[streamID] &+= 1 + playback = pendingPlayback.values.filter { $0.streamID == streamID } + capture = pendingCapture.values.filter { $0.streamID == streamID } + pendingPlayback = pendingPlayback.filter { $0.value.streamID != streamID } + pendingCapture = pendingCapture.filter { $0.value.streamID != streamID } + lock.unlock() + (playback + capture).compactMap(\.watchdog).forEach { $0.cancel() } + + if !playback.isEmpty || !capture.isEmpty { + log("virtio sound stream \(streamID) released with playback=\(playback.count), capture=\(capture.count) pending") + } + + var interrupt = false + var succeeded = true + for pending in playback { + guard transport.queues[2].ready, + transport.queues[2].isLeaseValid(pending.chain) else { + succeeded = false + continue + } + let written = pending.chain.withLeaseHeld { + $0.writeBytes(Self.ioStatus(.ioError, latencyBytes: 0)) + } ?? 0 + guard written == Self.pcmStatusSize, + push( + pending.chain, + queue: transport.queues[2], + queueIndex: 2, + written: written, + interrupt: &interrupt + ) else { + succeeded = false + continue + } + } + for pending in capture { + guard transport.queues[3].ready, + transport.queues[3].isLeaseValid(pending.chain) else { + succeeded = false + continue + } + let written = pending.chain.withLeaseHeld { + $0.writeBytes( + Self.ioStatus(.ioError, latencyBytes: 0), + atWritableOffset: pending.payloadBytes + ) + } ?? 0 + guard written == Self.pcmStatusSize, + push( + pending.chain, + queue: transport.queues[3], + queueIndex: 3, + written: written, + interrupt: &interrupt + ) else { + succeeded = false + continue + } + } + if interrupt { transport.notifyUsed() } + return succeeded + } + + private func streamID(for direction: VirtioSoundDirection) -> Int? { + streamDirections.firstIndex(of: direction) + } + + private static func orderedLayout(_ access: VirtqueueLeaseAccess) -> OrderedLayout? { + guard !access.segments.isEmpty else { return nil } + var sawWritable = false + for segment in access.segments { + if segment.isDeviceWritable { + sawWritable = true + } else if sawWritable { + return nil + } + } + return OrderedLayout( + readableBytes: access.readableByteCount, + writableBytes: access.writableByteCount + ) + } + + private static func pendingBytes(_ pending: [UInt64: PendingIO]) -> Int { + pending.values.reduce(into: 0) { total, value in + let (next, overflow) = total.addingReportingOverflow(value.payloadBytes) + total = overflow ? Int.max : next + } + } + + private func pop(_ queue: Virtqueue, queueIndex: Int) -> VirtqueueChain? { + do { + return try queue.pop() + } catch { + recordQueueFault(queue: queueIndex, reason: "descriptor pop failed") + return nil + } + } + + private func push( + _ chain: VirtqueueChain, + queue: Virtqueue, + queueIndex: Int, + written: Int, + interrupt: inout Bool + ) -> Bool { + do { + interrupt = try queue.push(chain, written: written) || interrupt + return true + } catch { + recordPublicationFault(queue: queueIndex, reason: "used-ring publication failed") + return false + } + } + + private func recordQueueFault(queue: Int, reason: String) { + lock.withLock { + statisticsState.queueFaults &+= 1 + terminalQueues.insert(queue) + } + log("virtio sound queue \(queue) terminal fault: \(reason)") + } + + private func recordPublicationFault(queue: Int, reason: String) { + lock.withLock { + statisticsState.publicationFaults &+= 1 + terminalQueues.insert(queue) + } + log("virtio sound queue \(queue) terminal publication fault: \(reason)") + } + + private func recordBoundedStopIfNeeded(handled: Int, queue: Virtqueue) { + if handled == limits.maximumChainsPerKick, queue.hasPending { + lock.withLock { statisticsState.boundedDrainStops &+= 1 } + } + } + + private func isTerminal(_ queue: Int) -> Bool { + lock.withLock { terminalQueues.contains(queue) } + } + + private func allocateRequestID() -> UInt64 { + let value = nextRequestID + nextRequestID &+= 1 + return value + } + + private static func header(_ status: Status) -> [UInt8] { + var bytes = [UInt8]() + bytes.appendLE(status.rawValue) + return bytes + } + + private static func ioStatus(_ status: Status, latencyBytes: UInt32) -> [UInt8] { + var bytes = header(status) + bytes.appendLE(latencyBytes) + return bytes + } + + // Protocol-level test hook; production requests always arrive through controlq. + func controlResponseForTesting(_ request: [UInt8], responseCapacity: Int = 4096) -> [UInt8] { + processControlRequest(request, responseCapacity: responseCapacity, transport: nil) + } +} + +private extension NSLock { + func withLock(_ body: () -> T) -> T { + lock() + defer { unlock() } + return body() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioVsock.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioVsock.swift index 270c5683..1a570ad2 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioVsock.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioVsock.swift @@ -1,6 +1,6 @@ import Foundation -public struct VirtioVsockHeader: Equatable { +public struct VirtioVsockHeader: Equatable, Sendable { public static let byteCount = 44 public var sourceCID: UInt64 @@ -14,7 +14,7 @@ public struct VirtioVsockHeader: Equatable { public var bufferAllocation: UInt32 public var forwardCount: UInt32 - public enum Operation: UInt16 { + public enum Operation: UInt16, Sendable { case invalid = 0 case request = 1 case response = 2 @@ -50,8 +50,10 @@ public struct VirtioVsockHeader: Equatable { } public init(decoding bytes: some Collection) throws { - let data = Array(bytes) - guard data.count >= Self.byteCount else { + // Never materialize an untrusted packet just to decode its fixed-size header. Queue + // admission separately bounds and copies the declared payload after this prefix parses. + let data = Array(bytes.prefix(Self.byteCount)) + guard data.count == Self.byteCount else { throw VMError.invalidConfiguration("short virtio-vsock header") } func le16(_ offset: Int) -> UInt16 { @@ -98,47 +100,21 @@ public struct VirtioVsockHeader: Equatable { return bytes } - public func reply(operation: Operation, length: UInt32 = 0, forwardCount: UInt32? = nil) -> VirtioVsockHeader { - VirtioVsockHeader( - sourceCID: destinationCID, - destinationCID: sourceCID, - sourcePort: destinationPort, - destinationPort: sourcePort, - length: length, - type: type, - operation: operation, - flags: flags, - bufferAllocation: bufferAllocation, - forwardCount: forwardCount ?? self.forwardCount - ) - } } public enum VsockConnectionWriteError: Error, Equatable, Sendable { case timedOut case connectionClosed + case outboundQueueFull } -public protocol VsockConnection: AnyObject { +public protocol VsockConnection: AnyObject, Sendable { func read(into buffer: UnsafeMutableRawBufferPointer) throws -> Int func write(_ bytes: [UInt8]) throws - /// Writes with a bounded credit wait. A nil timeout preserves the streaming bridges' existing - /// behavior; bounded control-plane calls use this so a guest that stops returning credit cannot - /// permanently occupy a host worker. func write(_ bytes: [UInt8], timeoutNanoseconds: UInt64?) throws func close() - /// Blocks until a subsequent `read(into:)` can return bytes, or until the peer closes. A nil - /// timeout waits indefinitely. Implementations keep `read(into:)` nonblocking for callers that - /// already manage their own waits. func waitForReadable(timeoutNanoseconds: UInt64?) -> Bool - /// Half-close: signals the peer that this side is done sending (SHUT_WR) while the connection - /// stays open for the peer's remaining data. Bridges relaying a client's request-EOF must use - /// this, not `close()` — a full close truncates the response mid-stream. func shutdownSend() - /// True once the peer has shut the connection down (or it was reset). `read` returns 0 both when - /// no bytes are buffered yet and after the peer is gone, so a long-lived reader (e.g. the USB - /// bridge) needs this to tell "idle" from EOF — without it a claimed device can never be released - /// on guest reboot. var isPeerClosed: Bool { get } } @@ -161,21 +137,233 @@ public extension VsockConnection { public enum VsockPorts { public static let agent: UInt32 = 1024 public static let usbip: UInt32 = 1025 - /// The guest agent's docker-socket proxy: each host connection is piped to /var/run/docker.sock - /// inside the engine VM with full half-close fidelity (the gvproxy unix forward this replaces - /// tears the stream down on a client SHUT_WR, which is how `docker run` attaches output). public static let docker: UInt32 = 1026 - /// Host-edit batches sent only after virtio-fs invalidation has completed. The guest agent turns - /// them into Linux VFS metadata operations so inotify-backed tools receive native events. public static let fsevents: UInt32 = 1028 - /// Guest-side `/run/host-services/ssh-auth.sock` dials this host listener. The bridge connects - /// only to the configured, same-user macOS SSH agent Unix socket. public static let sshAgent: UInt32 = 1029 } +public enum VirtioVsockConfigurationError: Error, Equatable, Sendable { + case invalidGuestCID(UInt32) + case invalidLimit(String) +} + +/// Immutable per-device resource ceilings used by every admission and accounting path. +public struct VirtioVsockLimits: Equatable, Sendable { + /// Linux's virtio transport splits socket writes into at most 64 KiB packet payloads + /// (`VIRTIO_VSOCK_MAX_PKT_BUF_SIZE`). Keeping the same frontend ceiling bounds copies while + /// preserving interoperability with the in-tree guest driver. + public static let linuxMaximumPacketPayloadBytes = 64 * 1024 + + public static let hardenedDefault = VirtioVsockLimits( + maximumConnections: 256, + maximumListeners: 64, + maximumInboundBytesPerConnection: 256 * 1024, + maximumInboundBytesTotal: 16 * 1024 * 1024, + maximumPendingGuestPackets: 1024, + maximumPendingGuestBytes: 8 * 1024 * 1024, + maximumPacketPayloadBytes: linuxMaximumPacketPayloadBytes, + maximumChainsPerKick: Int(Virtqueue.maximumSize), + maximumBytesPerKick: (linuxMaximumPacketPayloadBytes + VirtioVsockHeader.byteCount) + * Int(Virtqueue.maximumSize), + hostPortRange: 49_152...65_535, + shutdownTimeoutNanoseconds: 5_000_000_000, + validated: () + ) + + public let maximumConnections: Int + public let maximumListeners: Int + public let maximumInboundBytesPerConnection: Int + public let maximumInboundBytesTotal: Int + public let maximumPendingGuestPackets: Int + public let maximumPendingGuestBytes: Int + public let maximumPacketPayloadBytes: Int + public let maximumChainsPerKick: Int + public let maximumBytesPerKick: Int + public let hostPortRange: ClosedRange + public let shutdownTimeoutNanoseconds: UInt64 + + public init( + maximumConnections: Int, + maximumListeners: Int, + maximumInboundBytesPerConnection: Int, + maximumInboundBytesTotal: Int, + maximumPendingGuestPackets: Int, + maximumPendingGuestBytes: Int, + maximumPacketPayloadBytes: Int = Self.linuxMaximumPacketPayloadBytes, + maximumChainsPerKick: Int = Int(Virtqueue.maximumSize), + maximumBytesPerKick: Int = (Self.linuxMaximumPacketPayloadBytes + + VirtioVsockHeader.byteCount) * Int(Virtqueue.maximumSize), + hostPortRange: ClosedRange, + shutdownTimeoutNanoseconds: UInt64 = 5_000_000_000 + ) throws { + guard maximumConnections > 0 else { + throw VirtioVsockConfigurationError.invalidLimit("maximumConnections") + } + guard maximumListeners >= 0 else { + throw VirtioVsockConfigurationError.invalidLimit("maximumListeners") + } + guard maximumInboundBytesPerConnection > 0, + maximumInboundBytesPerConnection <= Int(UInt32.max) else { + throw VirtioVsockConfigurationError.invalidLimit( + "maximumInboundBytesPerConnection" + ) + } + guard maximumInboundBytesTotal > 0 else { + throw VirtioVsockConfigurationError.invalidLimit("maximumInboundBytesTotal") + } + guard maximumPendingGuestPackets > 0 else { + throw VirtioVsockConfigurationError.invalidLimit("maximumPendingGuestPackets") + } + guard maximumPendingGuestBytes > 0 else { + throw VirtioVsockConfigurationError.invalidLimit("maximumPendingGuestBytes") + } + guard maximumPacketPayloadBytes > 0, + maximumPacketPayloadBytes <= Int(UInt32.max) else { + throw VirtioVsockConfigurationError.invalidLimit("maximumPacketPayloadBytes") + } + guard maximumChainsPerKick > 0, + maximumChainsPerKick <= Int(Virtqueue.maximumSize) else { + throw VirtioVsockConfigurationError.invalidLimit("maximumChainsPerKick") + } + let (maximumPacketBytes, packetOverflow) = maximumPacketPayloadBytes + .addingReportingOverflow(VirtioVsockHeader.byteCount) + guard !packetOverflow, maximumBytesPerKick >= maximumPacketBytes else { + throw VirtioVsockConfigurationError.invalidLimit("maximumBytesPerKick") + } + guard shutdownTimeoutNanoseconds > 0 else { + throw VirtioVsockConfigurationError.invalidLimit("shutdownTimeoutNanoseconds") + } + self.init( + maximumConnections: maximumConnections, + maximumListeners: maximumListeners, + maximumInboundBytesPerConnection: maximumInboundBytesPerConnection, + maximumInboundBytesTotal: maximumInboundBytesTotal, + maximumPendingGuestPackets: maximumPendingGuestPackets, + maximumPendingGuestBytes: maximumPendingGuestBytes, + maximumPacketPayloadBytes: maximumPacketPayloadBytes, + maximumChainsPerKick: maximumChainsPerKick, + maximumBytesPerKick: maximumBytesPerKick, + hostPortRange: hostPortRange, + shutdownTimeoutNanoseconds: shutdownTimeoutNanoseconds, + validated: () + ) + } + + private init( + maximumConnections: Int, + maximumListeners: Int, + maximumInboundBytesPerConnection: Int, + maximumInboundBytesTotal: Int, + maximumPendingGuestPackets: Int, + maximumPendingGuestBytes: Int, + maximumPacketPayloadBytes: Int, + maximumChainsPerKick: Int, + maximumBytesPerKick: Int, + hostPortRange: ClosedRange, + shutdownTimeoutNanoseconds: UInt64, + validated: Void + ) { + self.maximumConnections = maximumConnections + self.maximumListeners = maximumListeners + self.maximumInboundBytesPerConnection = maximumInboundBytesPerConnection + self.maximumInboundBytesTotal = maximumInboundBytesTotal + self.maximumPendingGuestPackets = maximumPendingGuestPackets + self.maximumPendingGuestBytes = maximumPendingGuestBytes + self.maximumPacketPayloadBytes = maximumPacketPayloadBytes + self.maximumChainsPerKick = maximumChainsPerKick + self.maximumBytesPerKick = maximumBytesPerKick + self.hostPortRange = hostPortRange + self.shutdownTimeoutNanoseconds = shutdownTimeoutNanoseconds + } +} + +public enum VirtioVsockConnectionAdmissionError: Error, Equatable, Sendable { + case deviceQuiesced + case connectionCapacityReached(limit: Int) + case hostPortRangeExhausted + case outboundQueueCapacityReached +} + +public enum VirtioVsockListenerRegistrationError: Error, Equatable, Sendable { + case deviceQuiesced + case duplicatePort(UInt32) + case listenerCapacityReached(limit: Int) +} + +public struct VirtioVsockResourceSnapshot: Equatable, Sendable { + public let connections: Int + public let listeners: Int + public let inboundBufferedBytes: Int + public let pendingGuestPackets: Int + public let pendingGuestBytes: Int + public let isQuiesced: Bool +} + +/// Cumulative frontend telemetry. Queue faults are terminal for that queue generation and become +/// admissible again only after QueueReady is rewritten or the device is reset. +public struct VirtioVsockStatistics: Equatable, Sendable { + public var receivedGuestPackets: UInt64 = 0 + public var publishedGuestPackets: UInt64 = 0 + public var invalidTXChains: UInt64 = 0 + public var invalidRXChains: UInt64 = 0 + public var invalidEventChains: UInt64 = 0 + public var malformedGuestPackets: UInt64 = 0 + public var oversizedGuestPackets: UInt64 = 0 + public var rxStarvationEvents: UInt64 = 0 + public var responseBackpressureStops: UInt64 = 0 + public var boundedDrainStops: UInt64 = 0 + public var peerCreditClamps: UInt64 = 0 + public var staleHostOperations: UInt64 = 0 + public var queueFaults: UInt64 = 0 + public var publicationFaults: UInt64 = 0 + public var revokedCompletions: UInt64 = 0 + + public init() {} +} + +/// Closing or releasing the token unregisters only its exact listener generation. +public final class VirtioVsockListenerRegistration: @unchecked Sendable { + public let port: UInt32 + + private let lock = NSLock() + private var closeAction: (@Sendable () -> Void)? + + fileprivate init(port: UInt32, closeAction: @escaping @Sendable () -> Void) { + self.port = port + self.closeAction = closeAction + } + + public func close() { + lock.lock() + let action = closeAction + closeAction = nil + lock.unlock() + action?() + } + + deinit { + close() + } +} + +enum VirtioVsockCreditArithmetic { + /// VirtIO 1.3 section 5.10.6.3 defines free-running u32 counters. Counter subtraction wraps, + /// while an in-flight value larger than the peer's allocation must fail closed. + static func available( + bufferAllocation: UInt32, + transmittedCount: UInt32, + peerForwardCount: UInt32 + ) -> UInt32 { + let inFlight = transmittedCount &- peerForwardCount + guard inFlight <= bufferAllocation else { return 0 } + return bufferAllocation - inFlight + } +} + public final class VirtioVsock: VirtioDeviceBackend { public let deviceID: UInt32 = 19 public let queueCount = 3 + /// VirtIO 1.3 section 5.10.3: stream support is implied with no negotiated feature bits. public let deviceFeatures: UInt64 = 0 public var configSpace: [UInt8] { var bytes = [UInt8]() @@ -184,84 +372,476 @@ public final class VirtioVsock: VirtioDeviceBackend { return bytes } + private static let hostCID: UInt64 = 2 + private static let streamType: UInt16 = 1 + private let guestCID: UInt32 + private let limits: VirtioVsockLimits + private let serviceAdmissionAuthority: VirtioVsockServiceAdmissionAuthority + private let lifecycleResetLock = NSLock() private let stateLock = NSLock() - private var listeners: [UInt32: (VsockConnection) -> Void] = [:] + private var listeners: [UInt32: Listener] = [:] private var connections: [ConnectionKey: InProcessConnection] = [:] - private var pendingGuestPackets: [[UInt8]] = [] - private var nextHostPort: UInt32 = 49_152 + private var pendingGuestPackets: [PendingGuestPacket?] = [] + private var pendingGuestPacketHead = 0 + private var pendingGuestBytes = 0 + private var controlResponseReservations: Set = [] + private var reservedControlResponseBytes = 0 + private var uncommittedTerminalResetKeys: Set = [] + private var inboundBufferedBytes = 0 + private var nextHostPort: UInt32 + private var lifecycleEpoch: UInt64 = 1 + private var isQuiesced = false + private var isResetting = false + private var terminalQueues = Set() + private var statisticsState = VirtioVsockStatistics() + private var closingConnections: [ConnectionKey: ClosingConnection] = [:] + private let shutdownReaperQueue = DispatchQueue(label: "com.dory.vsock.shutdown-reaper") + private var shutdownReaper: DispatchSourceTimer? private weak var lastTransport: VirtioMMIOTransport? - private struct ConnectionKey: Hashable { + private struct ConnectionKey: Hashable, Sendable { var guestPort: UInt32 var hostPort: UInt32 } + private struct Listener { + var registrationID: UUID + var handler: @Sendable (VsockConnection) -> Void + } + + private struct PendingGuestPacket { + var id: UUID + var key: ConnectionKey? + var bytes: [UInt8] + } + + private struct PendingGuestDelivery { + var packetID: UUID + var bytes: [UInt8] + /// Nil means the head packet was delivered completely. A value replaces the exact head + /// after used-ring publication, allowing a large RW packet to be fragmented transactionally. + var replacement: [UInt8]? + } + + private struct ControlResponseReservation { + var id: UUID + var epoch: UInt64 + } + + private struct ClosingConnection { + var id: UUID + var deadlineNanoseconds: UInt64 + } + + private struct GuestPacketResult { + var responses: [[UInt8]] = [] + var invokeListener: (() -> Void)? + var terminalResetKey: ConnectionKey? + } + + private enum TXChainInspection { + case invalid + case packet( + bytes: [UInt8], + header: VirtioVsockHeader, + copiedByteCount: Int + ) + } + + private enum RXPublicationResult { + case published(wantsInterrupt: Bool) + case revoked + case stalePacket + } + + private enum ConnectionOrigin: Equatable { + case guest + case host + } + + private enum HostPacketEnqueueResult: Equatable { + case enqueued + case connectionClosed + case capacityExceeded + } + + private enum InboundReservationResult { + case reserved + case connectionClosed + case globalCapacityExceeded + } + + private enum InboundReceiveResult { + case accepted + case connectionClosed + case perConnectionCapacityExceeded + case globalCapacityExceeded + } + public init(guestCID: UInt32) { + precondition(Self.isValidGuestCID(guestCID), "virtio-vsock guest CID is reserved") + self.guestCID = guestCID + limits = .hardenedDefault + serviceAdmissionAuthority = VirtioVsockServiceAdmissionAuthority( + limits: .hardenedDefault + ) + nextHostPort = limits.hostPortRange.lowerBound + } + + public init( + guestCID: UInt32, + serviceAdmissionLimits: VirtioVsockServiceAdmissionLimits + ) { + precondition(Self.isValidGuestCID(guestCID), "virtio-vsock guest CID is reserved") + self.guestCID = guestCID + limits = .hardenedDefault + serviceAdmissionAuthority = VirtioVsockServiceAdmissionAuthority( + limits: serviceAdmissionLimits + ) + nextHostPort = limits.hostPortRange.lowerBound + } + + public init( + guestCID: UInt32, + limits: VirtioVsockLimits, + serviceAdmissionLimits: VirtioVsockServiceAdmissionLimits = .hardenedDefault + ) throws { + guard Self.isValidGuestCID(guestCID) else { + throw VirtioVsockConfigurationError.invalidGuestCID(guestCID) + } self.guestCID = guestCID + self.limits = limits + serviceAdmissionAuthority = VirtioVsockServiceAdmissionAuthority( + limits: serviceAdmissionLimits + ) + nextHostPort = limits.hostPortRange.lowerBound + } + + public var resourceSnapshot: VirtioVsockResourceSnapshot { + withLock { + VirtioVsockResourceSnapshot( + connections: connections.count, + listeners: listeners.count, + inboundBufferedBytes: inboundBufferedBytes, + pendingGuestPackets: pendingGuestPacketCountLocked + + controlResponseReservations.count, + pendingGuestBytes: pendingGuestBytes + reservedControlResponseBytes, + isQuiesced: isQuiesced + ) + } + } + + public var statistics: VirtioVsockStatistics { + withLock { statisticsState } } - private func withLock(_ body: () -> T) -> T { + public var serviceAdmissionSnapshot: VirtioVsockServiceAdmissionSnapshot { + serviceAdmissionAuthority.snapshot + } + + private static func isValidGuestCID(_ cid: UInt32) -> Bool { + cid > 2 && cid != UInt32.max + } + + private func withLock(_ body: () throws -> T) rethrows -> T { stateLock.lock() defer { stateLock.unlock() } - return body() + return try body() } - public func listen(port: UInt32, handler: @escaping (VsockConnection) -> Void) { - withLock { listeners[port] = handler } + func registerListener( + port: UInt32, + handler: @escaping @Sendable (VsockConnection) -> Void + ) throws -> VirtioVsockListenerRegistration { + let registrationID = UUID() + try withLock { + guard !isQuiesced, !isResetting else { + throw VirtioVsockListenerRegistrationError.deviceQuiesced + } + guard listeners[port] == nil else { + throw VirtioVsockListenerRegistrationError.duplicatePort(port) + } + guard listeners.count < limits.maximumListeners else { + throw VirtioVsockListenerRegistrationError.listenerCapacityReached( + limit: limits.maximumListeners + ) + } + listeners[port] = Listener(registrationID: registrationID, handler: handler) + } + return VirtioVsockListenerRegistration(port: port) { [weak self] in + self?.unregisterListener(port: port, registrationID: registrationID) + } + } + + /// Registers a guest-initiated service. Admission is reserved before the untrusted callback is + /// invoked, and the wrapped connection holds that exact service lease until close/peer reset. + public func registerServiceListener( + port: UInt32, + service: VirtioVsockService, + handler: @escaping @Sendable (VsockConnection) -> Void + ) throws -> VirtioVsockListenerRegistration { + try registerListener(port: port) { [weak self] connection in + guard let self else { + connection.close() + return + } + let reservation: VirtioVsockServiceReservation + do { + reservation = try serviceAdmissionAuthority.reserve(service) + } catch { + connection.close() + return + } + guard let lease = serviceAdmissionAuthority.publish( + reservation, + requestStop: { connection.close() } + ) else { + connection.close() + return + } + handler(ServiceOwnedVsockConnection(connection: connection, lease: lease)) + } } - public func connect(port guestPort: UInt32) -> VsockConnection { - let (key, connection) = withLock { () -> (ConnectionKey, InProcessConnection) in - let hostPort = allocateHostPortLocked() + private func unregisterListener(port: UInt32, registrationID: UUID) { + withLock { + guard listeners[port]?.registrationID == registrationID else { return } + listeners.removeValue(forKey: port) + } + } + + /// Reserves a connection slot, collision-free tuple, and REQUEST queue space atomically. + func connectIfCapacity(port guestPort: UInt32) throws -> VsockConnection { + let admitted = try withLock { () throws -> (InProcessConnection, VirtioMMIOTransport?) in + guard !isQuiesced, !isResetting else { + throw VirtioVsockConnectionAdmissionError.deviceQuiesced + } + guard connections.count < limits.maximumConnections else { + throw VirtioVsockConnectionAdmissionError.connectionCapacityReached( + limit: limits.maximumConnections + ) + } + let hostPort = try allocateHostPortLocked(guestPort: guestPort) let key = ConnectionKey(guestPort: guestPort, hostPort: hostPort) - let connection = InProcessConnection(key: key) { [weak self] operation, payload, forwardCount, flags in - self?.enqueueHostPacket(key: key, operation: operation, payload: payload, forwardCount: forwardCount, flags: flags) - } onClose: { [weak self] key in - self?.removeConnection(key: key) + let connection = makeConnectionLocked(key: key, origin: .host) + let packet = makeHostPacket( + key: key, + operation: .request, + payload: [], + forwardCount: 0, + flags: 0 + ) + guard appendPendingGuestPacketLocked(packet, key: key) else { + throw VirtioVsockConnectionAdmissionError.outboundQueueCapacityReached } connections[key] = connection - return (key, connection) + return (connection, lastTransport) } - enqueueHostPacket(key: key, operation: .request) - return connection + flushIfAttached(admitted.1) + return admitted.0 } - private func removeConnection(key: ConnectionKey) { - withLock { _ = connections.removeValue(forKey: key) } + /// Admits one host-initiated service session before allocating a transport tuple or REQUEST. + /// The returned connection owns both resources and releases the service lease exactly once. + public func connectForServiceIfCapacity( + port guestPort: UInt32, + service: VirtioVsockService + ) throws -> VsockConnection { + let reservation = try serviceAdmissionAuthority.reserve(service) + do { + let connection = try connectIfCapacity(port: guestPort) + guard let lease = serviceAdmissionAuthority.publish( + reservation, + requestStop: { connection.close() } + ) else { + connection.close() + throw VirtioVsockServiceAdmissionError.lifecycleRevoked(service: service) + } + return ServiceOwnedVsockConnection(connection: connection, lease: lease) + } catch { + serviceAdmissionAuthority.cancel(reservation) + throw error + } + } + + func reserveServiceSession( + _ service: VirtioVsockService + ) throws -> VirtioVsockServiceReservation { + try serviceAdmissionAuthority.reserve(service) + } + + func publishServiceSession( + _ reservation: VirtioVsockServiceReservation, + requestStop: @escaping @Sendable () -> Void + ) -> VirtioVsockServiceLease? { + serviceAdmissionAuthority.publish(reservation, requestStop: requestStop) + } + + func cancelServiceSession(_ reservation: VirtioVsockServiceReservation) { + serviceAdmissionAuthority.cancel(reservation) } - public func drainPendingGuestPackets() -> [[UInt8]] { + func drainPendingGuestPackets() -> [[UInt8]] { withLock { - defer { pendingGuestPackets.removeAll() } - return pendingGuestPackets + let result = pendingGuestPackets.dropFirst(pendingGuestPacketHead) + .compactMap { $0?.bytes } + pendingGuestPackets.removeAll(keepingCapacity: true) + pendingGuestPacketHead = 0 + pendingGuestBytes = 0 + return result } } public func handleKick(queue: Int, transport: VirtioMMIOTransport) { - withLock { lastTransport = transport } - if queue == 0 { + guard transport.queues.indices.contains(queue) else { return } + let isAdmissible = withLock { () -> Bool in + lastTransport = transport + return !terminalQueues.contains(queue) + } + guard isAdmissible else { return } + + switch queue { + case 0: flushPendingGuestPackets(transport: transport) - return + case 1: + // Section 5.10.6.1 requires progress on incoming TX packets while bounded resources + // remain even if the peer neglected RX. Flush first to reclaim response capacity, then + // drain TX once and make one bounded pass over newly generated replies. + flushPendingGuestPackets(transport: transport) + drainGuestTX(transport: transport) + flushPendingGuestPackets(transport: transport) + case 2: + validateEventQueue(transport: transport) + default: + break } - guard queue == 1 else { return } + } + + private func drainGuestTX(transport: VirtioMMIOTransport) { let virtqueue = transport.queues[1] var interrupt = false - while let chain = (try? virtqueue.pop()) ?? nil { - let packet = chain.readBytes() - let responses = (try? receive(packet: packet)) ?? [] - for response in responses { - if let rx = (try? transport.queues[0].pop()) ?? nil { - let written = rx.writeBytes(response) - let wants = (try? transport.queues[0].push(rx, written: written)) ?? false - interrupt = interrupt || wants + defer { if interrupt { transport.notifyUsed() } } + + let pending: UInt16 + do { + pending = try virtqueue.pendingCount() + } catch { + recordQueueFault(queue: 1) + return + } + let chainBudget = min(Int(pending), limits.maximumChainsPerKick) + if Int(pending) > chainBudget { recordBoundedDrainStop() } + + var handled = 0 + var copiedBytes = 0 + while handled < chainBudget { + let preview: VirtqueueChain + do { + guard let next = try virtqueue.peek() else { break } + preview = next + } catch { + recordQueueFault(queue: 1) + return + } + + let inspection = inspectTXChain(preview) + guard case .packet( + let packet, + let header, + let packetCopyBytes + ) = inspection else { + let rejected: VirtqueueChain + do { + rejected = try popPreviewed(preview, from: virtqueue) + } catch { + recordQueueFault(queue: 1) + return } + handled += 1 + withLock { statisticsState.invalidTXChains &+= 1 } + guard publish( + rejected, + written: 0, + on: virtqueue, + queueIndex: 1, + interrupt: &interrupt + ) else { return } + continue } - let wants = (try? virtqueue.push(chain, written: 0)) ?? false - interrupt = interrupt || wants - } - if interrupt { - transport.notifyUsed() + + guard copiedBytes <= limits.maximumBytesPerKick - packetCopyBytes else { + recordBoundedDrainStop() + break + } + let reservation: ControlResponseReservation? + if requiresControlResponse(header) { + guard let reserved = reserveControlResponse() else { + withLock { statisticsState.responseBackpressureStops &+= 1 } + break + } + reservation = reserved + } else { + reservation = nil + } + + let chain: VirtqueueChain + do { + chain = try popPreviewed(preview, from: virtqueue) + } catch { + if let reservation { releaseControlResponse(reservation) } + recordQueueFault(queue: 1) + return + } + handled += 1 + copiedBytes += packetCopyBytes + + let result: GuestPacketResult + do { + result = try processGuestPacket( + packet, + transactionEpoch: reservation?.epoch ?? currentLifecycleEpoch + ) + } catch { + if let reservation { releaseControlResponse(reservation) } + withLock { statisticsState.malformedGuestPackets &+= 1 } + guard publish( + chain, + written: 0, + on: virtqueue, + queueIndex: 1, + interrupt: &interrupt + ) else { return } + continue + } + withLock { statisticsState.receivedGuestPackets &+= 1 } + + let responseCommitted: Bool + if let reservation, let response = result.responses.first { + responseCommitted = commitControlResponse( + response, + reservation: reservation, + terminalResetKey: result.terminalResetKey + ) + } else if let reservation { + releaseControlResponse(reservation) + responseCommitted = result.responses.isEmpty + } else if result.responses.isEmpty { + responseCommitted = true + } else { + // Admission reserves for every packet that can produce a response. Reaching this + // branch is an internal fail-closed invariant violation, not guest backpressure. + responseCommitted = false + recordQueueFault(queue: 1) + } + if responseCommitted { result.invokeListener?() } + guard publish( + chain, + written: 0, + on: virtqueue, + queueIndex: 1, + interrupt: &interrupt + ) else { return } } } @@ -269,182 +849,1308 @@ public final class VirtioVsock: VirtioDeviceBackend { withLock { lastTransport = transport } } + public func queueStateChanged(queue: Int, ready: Bool, transport: VirtioMMIOTransport) { + guard transport.queues.indices.contains(queue) else { return } + withLock { + // QueueReady writes establish a new ring generation. Old faults remain counted, while + // the replacement queue receives a fresh admission opportunity. + terminalQueues.remove(queue) + lastTransport = transport + } + } + + /// Clears all transport-owned state. Host listener registrations are configuration authority, + /// so they survive reset as required for listeners by VirtIO 1.3 section 5.10.6.7. + public func deviceReset(transport: VirtioMMIOTransport) { + resetTransportState(preserveListeners: true, remainQuiesced: false) + } + + /// Permanently stops admission and releases connections, queues, bytes, and listener tokens. + @discardableResult + public func quiesce() -> VirtioVsockResourceSnapshot { + resetTransportState(preserveListeners: false, remainQuiesced: true) + return resourceSnapshot + } + + deinit { + resetTransportState(preserveListeners: false, remainQuiesced: true) + } + + private var maximumGuestPacketPayloadBytes: Int { + min(limits.maximumPacketPayloadBytes, limits.maximumInboundBytesPerConnection) + } + + private var currentLifecycleEpoch: UInt64 { + withLock { lifecycleEpoch } + } + + private func inspectTXChain(_ chain: VirtqueueChain) -> TXChainInspection { + // VirtIO 1.3 section 5.10.6.4: every outgoing packet buffer is device-readable. Zero-byte + // descriptors do not carry protocol data and are rejected rather than normalized away. + guard !chain.containsZeroLengthDescriptor, + chain.readableSegmentCount > 0, + chain.writableSegmentCount == 0, + chain.readableByteCount >= VirtioVsockHeader.byteCount else { + return .invalid + } + + let headerBytes = chain.readBytes(maximum: VirtioVsockHeader.byteCount) + guard headerBytes.count == VirtioVsockHeader.byteCount else { return .invalid } + let header: VirtioVsockHeader + do { + header = try VirtioVsockHeader(decoding: headerBytes) + } catch { + return .invalid + } + + guard let payloadByteCount = Int(exactly: header.length) else { + return .packet( + bytes: headerBytes, + header: header, + copiedByteCount: headerBytes.count + ) + } + if payloadByteCount > maximumGuestPacketPayloadBytes { + // The fixed header is sufficient to produce the protocol RST. Never copy the claimed + // oversized body merely to reject it. + return .packet( + bytes: headerBytes, + header: header, + copiedByteCount: headerBytes.count + ) + } + let requiredByteCount = VirtioVsockHeader.byteCount + payloadByteCount + guard requiredByteCount <= chain.readableByteCount else { + // Preserve the parsed route so processGuestPacket can generate the required RST without + // copying unavailable or unrelated descriptor memory. + return .packet( + bytes: headerBytes, + header: header, + copiedByteCount: headerBytes.count + ) + } + let packet = chain.readBytes(maximum: requiredByteCount) + guard packet.count == requiredByteCount else { return .invalid } + return .packet( + bytes: packet, + header: header, + copiedByteCount: packet.count + ) + } + + private func popPreviewed( + _ preview: VirtqueueChain, + from queue: Virtqueue + ) throws -> VirtqueueChain { + guard let chain = try queue.pop(), + chain.head == preview.head, + chain.lease == preview.lease else { + throw VMError.unexpectedExit("virtio-vsock queue changed between admission and pop") + } + return chain + } + + private func requiresControlResponse(_ header: VirtioVsockHeader) -> Bool { + // A well-formed RST is the only input that can never require an outbound packet. Every + // other opcode may need RESPONSE, CREDIT_UPDATE, SHUTDOWN, or a fail-closed RST. + !(header.operation == .reset + && header.sourceCID == UInt64(guestCID) + && header.destinationCID == Self.hostCID + && header.type == Self.streamType + && header.length == 0 + && header.flags == 0) + } + + @discardableResult + private func publish( + _ chain: VirtqueueChain, + written: Int, + on queue: Virtqueue, + queueIndex: Int, + interrupt: inout Bool + ) -> Bool { + do { + switch try queue.pushOutcome(chain, written: written) { + case .published(let wantsInterrupt): + interrupt = interrupt || wantsInterrupt + return true + case .revoked: + withLock { statisticsState.revokedCompletions &+= 1 } + return false + } + } catch { + recordPublicationFault(queue: queueIndex) + return false + } + } + + private func validateEventQueue(transport: VirtioMMIOTransport) { + let queue = transport.queues[2] + var interrupt = false + defer { if interrupt { transport.notifyUsed() } } + + let pending: UInt16 + do { + pending = try queue.pendingCount() + } catch { + recordQueueFault(queue: 2) + return + } + let budget = min(Int(pending), limits.maximumChainsPerKick) + if Int(pending) > budget { recordBoundedDrainStop() } + + for _ in 0.. 0 + && preview.writableByteCount >= 4 + if isValid { + // No transport-reset event is pending. Leave the valid buffer available to the + // device, exactly as Linux expects, instead of completing/replenishing it in a loop. + return + } + let rejected: VirtqueueChain + do { + rejected = try popPreviewed(preview, from: queue) + } catch { + recordQueueFault(queue: 2) + return + } + withLock { statisticsState.invalidEventChains &+= 1 } + guard publish( + rejected, + written: 0, + on: queue, + queueIndex: 2, + interrupt: &interrupt + ) else { return } + } + } + + private func recordQueueFault(queue: Int) { + withLock { + statisticsState.queueFaults &+= 1 + terminalQueues.insert(queue) + } + } + + private func recordPublicationFault(queue: Int) { + withLock { + statisticsState.publicationFaults &+= 1 + terminalQueues.insert(queue) + } + } + + private func recordBoundedDrainStop() { + withLock { statisticsState.boundedDrainStops &+= 1 } + } + + private func flushIfAttached(_ transport: VirtioMMIOTransport?) { + guard let transport else { return } + transport.withQueueLock { + flushPendingGuestPackets(transport: transport) + } + } + private func flushPendingGuestPackets(transport: VirtioMMIOTransport) { + guard withLock({ !terminalQueues.contains(0) }) else { return } + guard withLock({ pendingGuestPacketCountLocked > 0 }) else { return } + + let queue = transport.queues[0] var interrupt = false - while withLock({ !pendingGuestPackets.isEmpty }), let rx = (try? transport.queues[0].pop()) ?? nil { - let packet = withLock { pendingGuestPackets.removeFirst() } - let written = rx.writeBytes(packet) - let wants = (try? transport.queues[0].push(rx, written: written)) ?? false - interrupt = interrupt || wants + defer { if interrupt { transport.notifyUsed() } } + + let pending: UInt16 + do { + pending = try queue.pendingCount() + } catch { + recordQueueFault(queue: 0) + return + } + let chainBudget = min(Int(pending), limits.maximumChainsPerKick) + if Int(pending) > chainBudget { recordBoundedDrainStop() } + + var handled = 0 + var publishedBytes = 0 + while handled < chainBudget, + withLock({ pendingGuestPacketCountLocked > 0 }) { + let remainingByteBudget = limits.maximumBytesPerKick - publishedBytes + let minimumDeliveryBytes = withLock { minimumPendingDeliveryBytesLocked() } + guard let minimumDeliveryBytes, + remainingByteBudget >= minimumDeliveryBytes else { + recordBoundedDrainStop() + break + } + + let rx: VirtqueueChain + do { + guard let next = try queue.pop() else { break } + rx = next + } catch { + recordQueueFault(queue: 0) + return + } + handled += 1 + + // VirtIO 1.3 section 5.10.6.4: RX packet chains are device-writable only. A mixed or + // readable chain receives no bytes and cannot consume the pending packet authority. + guard !rx.containsZeroLengthDescriptor, + rx.writableSegmentCount > 0, + rx.readableSegmentCount == 0 else { + withLock { statisticsState.invalidRXChains &+= 1 } + guard publish( + rx, + written: 0, + on: queue, + queueIndex: 0, + interrupt: &interrupt + ) else { return } + continue + } + + let capacity = min(rx.writableByteCount, remainingByteBudget) + let delivery: PendingGuestDelivery? + do { + delivery = try withLock { + try pendingGuestDeliveryLocked(maximumBytes: capacity) + } + } catch { + recordQueueFault(queue: 0) + return + } + guard let delivery else { + withLock { statisticsState.rxStarvationEvents &+= 1 } + guard publish( + rx, + written: 0, + on: queue, + queueIndex: 0, + interrupt: &interrupt + ) else { return } + continue + } + + let outcome: RXPublicationResult + do { + outcome = try withLock { + guard peekPendingGuestPacketLocked()?.id == delivery.packetID else { + return .stalePacket + } + guard rx.writeBytes(delivery.bytes) == delivery.bytes.count else { + throw VMError.unexpectedExit( + "virtio-vsock RX lease revoked during bounded write" + ) + } + switch try queue.pushOutcome(rx, written: delivery.bytes.count) { + case .published(let wantsInterrupt): + commitPendingGuestDeliveryLocked(delivery) + statisticsState.publishedGuestPackets &+= 1 + return .published(wantsInterrupt: wantsInterrupt) + case .revoked: + statisticsState.revokedCompletions &+= 1 + return .revoked + } + } + } catch { + recordPublicationFault(queue: 0) + return + } + switch outcome { + case .published(let wantsInterrupt): + publishedBytes += delivery.bytes.count + interrupt = interrupt || wantsInterrupt + case .revoked: + return + case .stalePacket: + // A concurrent direct control path removed the exact pending flow before guest + // memory was touched. Complete this offered buffer empty and preserve newer FIFO. + guard publish( + rx, + written: 0, + on: queue, + queueIndex: 0, + interrupt: &interrupt + ) else { return } + } } - if interrupt { - transport.notifyUsed() + } + + private func minimumPendingDeliveryBytesLocked() -> Int? { + guard let packet = peekPendingGuestPacketLocked() else { return nil } + guard packet.bytes.count > VirtioVsockHeader.byteCount else { + return packet.bytes.count + } + // Every fragment needs a complete header and at least one stream byte. Control packets are + // fixed at one header and are returned in full. + do { + let header = try VirtioVsockHeader(decoding: packet.bytes) + if header.operation == .readWrite { + return VirtioVsockHeader.byteCount + 1 + } + return packet.bytes.count + } catch { + return packet.bytes.count } } - public func receive(packet: [UInt8]) throws -> [[UInt8]] { - let header = try VirtioVsockHeader(decoding: packet.prefix(VirtioVsockHeader.byteCount)) - let payload = Array(packet.dropFirst(VirtioVsockHeader.byteCount)) - let key = ConnectionKey(guestPort: header.sourcePort, hostPort: header.destinationPort) + private func pendingGuestDeliveryLocked( + maximumBytes: Int + ) throws -> PendingGuestDelivery? { + guard let current = peekPendingGuestPacketLocked() else { return nil } + guard maximumBytes >= VirtioVsockHeader.byteCount else { return nil } + if current.bytes.count <= maximumBytes { + return PendingGuestDelivery( + packetID: current.id, + bytes: current.bytes, + replacement: nil + ) + } + + // Linux may provide an RX buffer smaller than a queued stream packet. The vhost transport + // handles this by emitting a shorter RW packet and retaining the rest. Do the same without + // increasing pending packet/byte accounting and without splitting any control opcode. + let header = try VirtioVsockHeader(decoding: current.bytes) + let payload = current.bytes.dropFirst(VirtioVsockHeader.byteCount) + guard header.operation == .readWrite, + header.length == UInt32(payload.count), + maximumBytes > VirtioVsockHeader.byteCount else { return nil } + let fragmentPayloadCount = min( + maximumBytes - VirtioVsockHeader.byteCount, + payload.count + ) + guard fragmentPayloadCount > 0 else { return nil } + + var fragmentHeader = header + fragmentHeader.length = UInt32(fragmentPayloadCount) + let fragmentPayload = payload.prefix(fragmentPayloadCount) + let emitted = fragmentHeader.encoded() + fragmentPayload + + let remainingPayload = payload.dropFirst(fragmentPayloadCount) + var remainingHeader = header + remainingHeader.length = UInt32(remainingPayload.count) + let replacement = remainingHeader.encoded() + remainingPayload + return PendingGuestDelivery( + packetID: current.id, + bytes: emitted, + replacement: replacement + ) + } + + private func commitPendingGuestDeliveryLocked(_ delivery: PendingGuestDelivery) { + guard pendingGuestPacketHead < pendingGuestPackets.count, + let current = pendingGuestPackets[pendingGuestPacketHead], + current.id == delivery.packetID else { return } + if let replacement = delivery.replacement { + pendingGuestPackets[pendingGuestPacketHead]?.bytes = replacement + pendingGuestBytes -= current.bytes.count - replacement.count + } else { + _ = dequeuePendingGuestPacketLocked() + } + } + + func receive(packet: [UInt8]) throws -> [[UInt8]] { + let result = try processGuestPacket(packet, transactionEpoch: nil) + result.invokeListener?() + return result.responses + } + + private func processGuestPacket( + _ packet: [UInt8], + transactionEpoch: UInt64? + ) throws -> GuestPacketResult { + let header = try VirtioVsockHeader( + decoding: packet.prefix(VirtioVsockHeader.byteCount) + ) + let key = ConnectionKey( + guestPort: header.sourcePort, + hostPort: header.destinationPort + ) + let addressIsValid = header.sourceCID == UInt64(guestCID) + && header.destinationCID == Self.hostCID + let typeIsValid = header.type == Self.streamType + let availablePayloadBytes = packet.count - VirtioVsockHeader.byteCount + + guard Int(header.length) <= maximumGuestPacketPayloadBytes else { + withLock { + statisticsState.oversizedGuestPackets &+= 1 + statisticsState.malformedGuestPackets &+= 1 + } + if addressIsValid && typeIsValid { + return terminalResetResult( + to: header, + key: key, + transactionEpoch: transactionEpoch + ) + } + return GuestPacketResult(responses: [makeReply(to: header, operation: .reset)]) + } + // VirtIO 1.3 section 5.10.6 explicitly permits descriptor bytes beyond len. Consume only + // the first len payload bytes, and RST a header that claims unavailable bytes. + guard Int(header.length) <= availablePayloadBytes else { + withLock { statisticsState.malformedGuestPackets &+= 1 } + if addressIsValid && typeIsValid { + return terminalResetResult( + to: header, + key: key, + transactionEpoch: transactionEpoch + ) + } + return GuestPacketResult(responses: [makeReply(to: header, operation: .reset)]) + } + guard typeIsValid else { + withLock { statisticsState.malformedGuestPackets &+= 1 } + // Section 5.10.6.4.2 requires RST for every unsupported type. + return GuestPacketResult(responses: [makeReply(to: header, operation: .reset)]) + } + guard addressIsValid else { + withLock { statisticsState.malformedGuestPackets &+= 1 } + return GuestPacketResult(responses: [makeReply(to: header, operation: .reset)]) + } + guard fieldsAreValid(header) else { + withLock { statisticsState.malformedGuestPackets &+= 1 } + return terminalResetResult( + to: header, + key: key, + transactionEpoch: transactionEpoch + ) + } + + if header.bufferAllocation > UInt32(limits.maximumInboundBytesPerConnection) { + // Match Linux's fail-safe policy: a peer may advertise a larger receive window, but it + // cannot make this implementation queue more than its own configured socket buffer. + withLock { statisticsState.peerCreditClamps &+= 1 } + } + + let payloadStart = VirtioVsockHeader.byteCount + let payloadEnd = payloadStart + Int(header.length) + let payload = Array(packet[payloadStart.. InProcessConnection in - let connection = InProcessConnection(key: key) { [weak self] operation, payload, forwardCount, flags in - self?.enqueueHostPacket(key: key, operation: operation, payload: payload, forwardCount: forwardCount, flags: flags) - } onClose: { [weak self] key in - self?.removeConnection(key: key) - } - connections[key] = connection - return connection + case .response: + guard connection.acceptResponse( + bufferAllocation: header.bufferAllocation, + forwardCount: header.forwardCount + ) else { + return terminalResetResult( + to: header, + key: key, + transactionEpoch: transactionEpoch + ) } - connection.updatePeerCredit(bufferAllocation: header.bufferAllocation, forwardCount: header.forwardCount) - listener(connection) - return [header.reply(operation: .response).encoded()] + return GuestPacketResult() case .readWrite: - let connection = withLock { connections[key] } - guard let connection, UInt32(payload.count) <= header.bufferAllocation else { - return [header.reply(operation: .reset).encoded()] + connection.updatePeerCredit( + bufferAllocation: header.bufferAllocation, + forwardCount: header.forwardCount + ) + switch connection.receive(payload) { + case .accepted: + return GuestPacketResult(responses: [ + makeReply( + to: header, + operation: .creditUpdate, + forwardCount: connection.forwardCount + ), + ]) + case .connectionClosed, .perConnectionCapacityExceeded, + .globalCapacityExceeded: + return terminalResetResult( + to: header, + key: key, + transactionEpoch: transactionEpoch + ) } - connection.updatePeerCredit(bufferAllocation: header.bufferAllocation, forwardCount: header.forwardCount) - connection.receive(payload) - return [header.reply(operation: .creditUpdate, forwardCount: connection.forwardCount).encoded()] case .shutdown: - // A guest half-close (SHUT_WR carries only VIRTIO_VSOCK_SHUTDOWN_SEND) means the guest is - // done sending but can still receive, so keep the connection alive for the host to finish - // streaming its reply and only mark inbound EOF. Any other shutdown tears the connection down. - if header.flags == VsockShutdown.send { - let connection = withLock { connections[key] } - connection?.updatePeerCredit(bufferAllocation: header.bufferAllocation, forwardCount: header.forwardCount) - connection?.markPeerSendClosed() - } else { - withLock { connections.removeValue(forKey: key) }?.close() + connection.updatePeerCredit( + bufferAllocation: header.bufferAllocation, + forwardCount: header.forwardCount + ) + if connection.markPeerShutdown(flags: header.flags) { + return terminalResetResult( + to: header, + key: key, + transactionEpoch: transactionEpoch + ) } - return [header.reply(operation: .shutdown).encoded()] - case .reset: - withLock { connections.removeValue(forKey: key) }?.close() - return [header.reply(operation: .shutdown).encoded()] + return GuestPacketResult(responses: [ + makeReply( + to: header, + operation: .shutdown, + forwardCount: connection.forwardCount, + flags: header.flags + ), + ]) case .creditRequest: - return [header.reply(operation: .creditUpdate).encoded()] - case .response: - withLock { connections[key] }? - .updatePeerCredit(bufferAllocation: header.bufferAllocation, forwardCount: header.forwardCount) - return [] + connection.updatePeerCredit( + bufferAllocation: header.bufferAllocation, + forwardCount: header.forwardCount + ) + return GuestPacketResult(responses: [ + makeReply( + to: header, + operation: .creditUpdate, + forwardCount: connection.forwardCount + ), + ]) case .creditUpdate: - withLock { connections[key] }? - .updatePeerCredit(bufferAllocation: header.bufferAllocation, forwardCount: header.forwardCount) - return [] + connection.updatePeerCredit( + bufferAllocation: header.bufferAllocation, + forwardCount: header.forwardCount + ) + return GuestPacketResult() + case .request, .reset, .invalid: + return terminalResetResult( + to: header, + key: key, + transactionEpoch: transactionEpoch + ) + } + } + + private func fieldsAreValid(_ header: VirtioVsockHeader) -> Bool { + switch header.operation { + case .readWrite: + return header.length > 0 && header.flags == 0 + case .shutdown: + return header.length == 0 + && header.flags != 0 + && header.flags & ~VsockShutdown.all == 0 + case .request, .response, .reset, .creditUpdate, .creditRequest: + return header.length == 0 && header.flags == 0 case .invalid: - return [] + return false + } + } + + private func admitGuestRequest( + header: VirtioVsockHeader, + key: ConnectionKey, + transactionEpoch: UInt64? + ) -> GuestPacketResult { + var rejected: InProcessConnection? + var terminalResetKey: ConnectionKey? + let admission = withLock { () -> (Listener, InProcessConnection)? in + guard transactionEpoch == nil || transactionEpoch == lifecycleEpoch else { + return nil + } + if connections[key] != nil { + rejected = prepareTerminalResetLocked( + key: key, + transactionEpoch: transactionEpoch + ) + if transactionEpoch != nil { terminalResetKey = key } + return nil + } + guard !isQuiesced, !isResetting, + connections.count < limits.maximumConnections, + !hasPendingGuestPacketLocked(for: key), + !uncommittedTerminalResetKeys.contains(key), + let listener = listeners[header.destinationPort] else { + rejected = prepareTerminalResetLocked( + key: key, + transactionEpoch: transactionEpoch + ) + if transactionEpoch != nil { terminalResetKey = key } + return nil + } + let connection = makeConnectionLocked(key: key, origin: .guest) + connections[key] = connection + return (listener, connection) + } + rejected?.abort() + guard let (listener, connection) = admission else { + // Section 5.10.6.5 requires RST for a missing listener or insufficient resources. + return GuestPacketResult( + responses: [makeReply(to: header, operation: .reset)], + terminalResetKey: terminalResetKey + ) } + connection.updatePeerCredit( + bufferAllocation: header.bufferAllocation, + forwardCount: header.forwardCount + ) + return GuestPacketResult( + responses: [makeReply(to: header, operation: .response)], + invokeListener: { listener.handler(connection) } + ) } - private func allocateHostPortLocked() -> UInt32 { - defer { nextHostPort &+= 1 } - return nextHostPort + private func terminalResetResult( + to header: VirtioVsockHeader, + key: ConnectionKey, + transactionEpoch: UInt64? + ) -> GuestPacketResult { + var removed: InProcessConnection? + var terminalResetKey: ConnectionKey? + withLock { + guard transactionEpoch == nil || transactionEpoch == lifecycleEpoch else { return } + removed = prepareTerminalResetLocked( + key: key, + transactionEpoch: transactionEpoch + ) + if transactionEpoch != nil { terminalResetKey = key } + } + removed?.abort() + return GuestPacketResult( + responses: [makeReply(to: header, operation: .reset)], + terminalResetKey: terminalResetKey + ) } - private func enqueueHostPacket( + private func prepareTerminalResetLocked( key: ConnectionKey, + transactionEpoch: UInt64? + ) -> InProcessConnection? { + let removed = connections.removeValue(forKey: key) + removePendingGuestPacketsLocked(for: key) + closingConnections.removeValue(forKey: key) + if transactionEpoch != nil { uncommittedTerminalResetKeys.insert(key) } + scheduleShutdownReaperLocked() + return removed + } + + private func makeReply( + to header: VirtioVsockHeader, operation: VirtioVsockHeader.Operation, - payload: [UInt8] = [], forwardCount: UInt32 = 0, flags: UInt32 = 0 - ) { - let header = VirtioVsockHeader( - sourceCID: 2, + ) -> [UInt8] { + VirtioVsockHeader( + sourceCID: Self.hostCID, + destinationCID: UInt64(guestCID), + sourcePort: header.destinationPort, + destinationPort: header.sourcePort, + length: 0, + type: header.type, + operation: operation, + flags: flags, + bufferAllocation: UInt32(limits.maximumInboundBytesPerConnection), + forwardCount: forwardCount + ).encoded() + } + + private func makeHostPacket( + key: ConnectionKey, + operation: VirtioVsockHeader.Operation, + payload: [UInt8], + forwardCount: UInt32, + flags: UInt32 + ) -> [UInt8] { + VirtioVsockHeader( + sourceCID: Self.hostCID, destinationCID: UInt64(guestCID), sourcePort: key.hostPort, destinationPort: key.guestPort, length: UInt32(payload.count), operation: operation, flags: flags, + bufferAllocation: UInt32(limits.maximumInboundBytesPerConnection), forwardCount: forwardCount + ).encoded() + payload + } + + private func makeConnectionLocked( + key: ConnectionKey, + origin: ConnectionOrigin + ) -> InProcessConnection { + let id = UUID() + let epoch = lifecycleEpoch + return InProcessConnection( + id: id, + key: key, + origin: origin, + maximumInboundBytes: limits.maximumInboundBytesPerConnection, + send: { [weak self] operation, payload, forwardCount, flags in + self?.enqueueHostPacket( + id: id, + key: key, + epoch: epoch, + operation: operation, + payload: payload, + forwardCount: forwardCount, + flags: flags + ) ?? .connectionClosed + }, + reserveInbound: { [weak self] count in + self?.reserveInboundBytes( + count, + id: id, + key: key, + epoch: epoch + ) ?? .connectionClosed + }, + releaseInbound: { [weak self] count in + self?.releaseInboundBytes(count) + }, + onLocalClose: { [weak self] in + self?.markConnectionClosing(id: id, key: key, epoch: epoch) + } ) - let transport = withLock { () -> VirtioMMIOTransport? in - pendingGuestPackets.append(header.encoded() + payload) - return lastTransport + } + + private func reserveInboundBytes( + _ count: Int, + id: UUID, + key: ConnectionKey, + epoch: UInt64 + ) -> InboundReservationResult { + withLock { + guard !isQuiesced, !isResetting, + lifecycleEpoch == epoch, + connections[key]?.id == id else { return .connectionClosed } + let (next, overflow) = inboundBufferedBytes.addingReportingOverflow(count) + guard !overflow, next <= limits.maximumInboundBytesTotal else { + return .globalCapacityExceeded + } + inboundBufferedBytes = next + return .reserved } - if let transport { - transport.withQueueLock { - flushPendingGuestPackets(transport: transport) + } + + private func releaseInboundBytes(_ count: Int) { + withLock { + inboundBufferedBytes = max(0, inboundBufferedBytes - count) + } + } + + private func enqueueHostPacket( + id: UUID, + key: ConnectionKey, + epoch: UInt64, + operation: VirtioVsockHeader.Operation, + payload: [UInt8], + forwardCount: UInt32, + flags: UInt32 + ) -> HostPacketEnqueueResult { + let result = withLock { () -> (HostPacketEnqueueResult, VirtioMMIOTransport?) in + guard !isQuiesced, !isResetting, + lifecycleEpoch == epoch, + connections[key]?.id == id else { + statisticsState.staleHostOperations &+= 1 + return (.connectionClosed, nil) + } + let packet = makeHostPacket( + key: key, + operation: operation, + payload: payload, + forwardCount: forwardCount, + flags: flags + ) + guard appendPendingGuestPacketLocked(packet, key: key) else { + return (.capacityExceeded, nil) } + return (.enqueued, lastTransport) + } + flushIfAttached(result.1) + return result.0 + } + + private var pendingGuestPacketCountLocked: Int { + pendingGuestPackets.count - pendingGuestPacketHead + } + + private func reserveControlResponse() -> ControlResponseReservation? { + withLock { + guard !isQuiesced, !isResetting, + pendingGuestPacketCountLocked + controlResponseReservations.count + < limits.maximumPendingGuestPackets else { + return nil + } + let (usedBytes, usedOverflow) = pendingGuestBytes.addingReportingOverflow( + reservedControlResponseBytes + ) + guard !usedOverflow else { return nil } + let (next, overflow) = usedBytes.addingReportingOverflow( + VirtioVsockHeader.byteCount + ) + guard !overflow, next <= limits.maximumPendingGuestBytes else { return nil } + let id = UUID() + controlResponseReservations.insert(id) + reservedControlResponseBytes += VirtioVsockHeader.byteCount + return ControlResponseReservation(id: id, epoch: lifecycleEpoch) + } + } + + private func commitControlResponse( + _ packet: [UInt8], + reservation: ControlResponseReservation, + terminalResetKey: ConnectionKey? + ) -> Bool { + withLock { + guard reservation.epoch == lifecycleEpoch, + controlResponseReservations.remove(reservation.id) != nil else { + return false + } + reservedControlResponseBytes -= VirtioVsockHeader.byteCount + if let terminalResetKey, + uncommittedTerminalResetKeys.remove(terminalResetKey) == nil { + return false + } + guard packet.count <= VirtioVsockHeader.byteCount, + !isQuiesced, !isResetting else { + return false + } + // The count/bytes were reserved while every ordinary append included reservations in + // its capacity check, so this commit cannot be displaced by a concurrent host enqueue. + pendingGuestPackets.append(PendingGuestPacket( + id: UUID(), + key: terminalResetKey, + bytes: packet + )) + pendingGuestBytes += packet.count + return true + } + } + + private func releaseControlResponse(_ reservation: ControlResponseReservation) { + withLock { + guard reservation.epoch == lifecycleEpoch, + controlResponseReservations.remove(reservation.id) != nil else { return } + reservedControlResponseBytes -= VirtioVsockHeader.byteCount + } + } + + private func appendPendingGuestPacketLocked( + _ packet: [UInt8], + key: ConnectionKey? + ) -> Bool { + guard pendingGuestPacketCountLocked + controlResponseReservations.count + < limits.maximumPendingGuestPackets else { + return false + } + let (usedBytes, usedOverflow) = pendingGuestBytes.addingReportingOverflow( + reservedControlResponseBytes + ) + guard !usedOverflow else { return false } + let (nextBytes, overflow) = usedBytes.addingReportingOverflow(packet.count) + guard !overflow, nextBytes <= limits.maximumPendingGuestBytes else { return false } + let (newPendingBytes, pendingOverflow) = pendingGuestBytes.addingReportingOverflow( + packet.count + ) + guard !pendingOverflow else { return false } + pendingGuestPackets.append(PendingGuestPacket(id: UUID(), key: key, bytes: packet)) + pendingGuestBytes = newPendingBytes + return true + } + + private func peekPendingGuestPacketLocked() -> PendingGuestPacket? { + guard pendingGuestPacketHead < pendingGuestPackets.count else { return nil } + return pendingGuestPackets[pendingGuestPacketHead] + } + + @discardableResult + private func dequeuePendingGuestPacketLocked() -> PendingGuestPacket? { + guard pendingGuestPacketHead < pendingGuestPackets.count, + let packet = pendingGuestPackets[pendingGuestPacketHead] else { return nil } + pendingGuestPackets[pendingGuestPacketHead] = nil + pendingGuestPacketHead += 1 + pendingGuestBytes -= packet.bytes.count + compactPendingGuestPacketsLockedIfNeeded() + return packet + } + + private func compactPendingGuestPacketsLockedIfNeeded() { + guard pendingGuestPacketHead > 0 else { return } + if pendingGuestPacketHead == pendingGuestPackets.count { + pendingGuestPackets.removeAll(keepingCapacity: true) + pendingGuestPacketHead = 0 + } else if pendingGuestPacketHead >= 64, + pendingGuestPacketHead * 2 >= pendingGuestPackets.count { + pendingGuestPackets.removeFirst(pendingGuestPacketHead) + pendingGuestPacketHead = 0 + } + } + + private func removePendingGuestPacketsLocked(for key: ConnectionKey) { + let kept = pendingGuestPackets.dropFirst(pendingGuestPacketHead) + .compactMap { $0 } + .filter { $0.key != key } + pendingGuestPackets = kept.map(Optional.some) + pendingGuestPacketHead = 0 + pendingGuestBytes = kept.reduce(into: 0) { $0 += $1.bytes.count } + } + + private func hasPendingGuestPacketLocked(for key: ConnectionKey) -> Bool { + pendingGuestPackets.dropFirst(pendingGuestPacketHead).contains { $0?.key == key } + } + + private func allocateHostPortLocked(guestPort: UInt32) throws -> UInt32 { + let lower = limits.hostPortRange.lowerBound + let upper = limits.hostPortRange.upperBound + let rangeCount = UInt64(upper) - UInt64(lower) + 1 + var candidate = nextHostPort + for _ in 0.. InProcessConnection? in + let removed = connections.removeValue(forKey: key) + removePendingGuestPacketsLocked(for: key) + uncommittedTerminalResetKeys.remove(key) + closingConnections.removeValue(forKey: key) + scheduleShutdownReaperLocked() + return removed + } + connection?.abort() + } + + private func markConnectionClosing(id: UUID, key: ConnectionKey, epoch: UInt64) { + withLock { + guard !isQuiesced, !isResetting, + lifecycleEpoch == epoch, + connections[key]?.id == id else { return } + closingConnections[key] = ClosingConnection( + id: id, + deadlineNanoseconds: addingClamped( + DispatchTime.now().uptimeNanoseconds, + limits.shutdownTimeoutNanoseconds + ) + ) + scheduleShutdownReaperLocked() + } + } + + private func scheduleShutdownReaperLocked() { + guard let deadline = closingConnections.values + .map(\.deadlineNanoseconds) + .min() else { + shutdownReaper?.cancel() + shutdownReaper = nil + return + } + + let timer: DispatchSourceTimer + if let shutdownReaper { + timer = shutdownReaper + } else { + let newTimer = DispatchSource.makeTimerSource(queue: shutdownReaperQueue) + newTimer.setEventHandler { [weak self] in + self?.reapExpiredConnections() + } + newTimer.activate() + shutdownReaper = newTimer + timer = newTimer + } + timer.schedule( + deadline: DispatchTime(uptimeNanoseconds: deadline), + leeway: .milliseconds(1) + ) + } + + private func reapExpiredConnections() { + let now = DispatchTime.now().uptimeNanoseconds + let candidates = withLock { () -> [(ConnectionKey, ClosingConnection, InProcessConnection)] in + closingConnections.compactMap { key, closing in + guard closing.deadlineNanoseconds <= now, + let connection = connections[key], + connection.id == closing.id else { return nil } + return (key, closing, connection) + } + } + let candidatesWithCredit = candidates.map { candidate in + (candidate.0, candidate.1, candidate.2, candidate.2.forwardCount) + } + + let outcome = withLock { + () -> ([InProcessConnection], VirtioMMIOTransport?) in + guard !isQuiesced, !isResetting else { + scheduleShutdownReaperLocked() + return ([], nil) + } + var retired = [InProcessConnection]() + var enqueuedReset = false + let retryDelay = max( + 10_000_000, + min(limits.shutdownTimeoutNanoseconds, 100_000_000) + ) + for (key, closing, connection, forwardCount) in candidatesWithCredit { + guard closingConnections[key]?.id == closing.id, + closingConnections[key]?.deadlineNanoseconds ?? UInt64.max <= now, + connections[key]?.id == closing.id else { continue } + let reset = makeHostPacket( + key: key, + operation: .reset, + payload: [], + forwardCount: forwardCount, + flags: 0 + ) + var resetCommitted = appendPendingGuestPacketLocked(reset, key: key) + if !resetCommitted, hasPendingGuestPacketLocked(for: key) { + // At the implementation shutdown deadline this flow is being reset, so its + // not-yet-delivered payload/SHUTDOWN packets are stale authority. Replacing + // them with the terminal RST makes ordinary close retirement wall-bounded. + removePendingGuestPacketsLocked(for: key) + resetCommitted = appendPendingGuestPacketLocked(reset, key: key) + } + guard resetCommitted else { + // Required responses or packets belonging to other flows are never dropped to + // make room. Keep this one bounded tombstone and retry on the single reaper. + closingConnections[key]?.deadlineNanoseconds = addingClamped(now, retryDelay) + continue + } + // The RST is committed before the live tuple is retired. Its keyed pending authority + // prevents either host-port reuse or a guest REQUEST for this tuple until delivery. + connections.removeValue(forKey: key) + closingConnections.removeValue(forKey: key) + retired.append(connection) + enqueuedReset = true + } + scheduleShutdownReaperLocked() + return (retired, enqueuedReset ? lastTransport : nil) + } + for connection in outcome.0 { connection.abort() } + flushIfAttached(outcome.1) + } + + private func addingClamped(_ lhs: UInt64, _ rhs: UInt64) -> UInt64 { + let (sum, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? UInt64.max : sum + } + + private func resetTransportState( + preserveListeners: Bool, + remainQuiesced: Bool + ) { + lifecycleResetLock.lock() + defer { lifecycleResetLock.unlock() } + let terminallyQuiesced = withLock { () -> Bool in + let terminal = remainQuiesced || isQuiesced + isResetting = true + lifecycleEpoch &+= 1 + return terminal + } + + // Revoke service work before aborting the transport objects it may be using. Stop callbacks + // run outside both authority and device locks; their close paths see isResetting and cannot + // publish a new shutdown tombstone into the replacement generation. + if terminallyQuiesced { + serviceAdmissionAuthority.quiesce() + } else { + serviceAdmissionAuthority.beginReset() + } + + let staleConnections = withLock { () -> [InProcessConnection] in + let stale = Array(connections.values) + connections.removeAll(keepingCapacity: true) + pendingGuestPackets.removeAll(keepingCapacity: true) + pendingGuestPacketHead = 0 + pendingGuestBytes = 0 + controlResponseReservations.removeAll(keepingCapacity: true) + reservedControlResponseBytes = 0 + uncommittedTerminalResetKeys.removeAll(keepingCapacity: true) + terminalQueues.removeAll(keepingCapacity: true) + nextHostPort = limits.hostPortRange.lowerBound + lastTransport = nil + closingConnections.removeAll(keepingCapacity: true) + shutdownReaper?.cancel() + shutdownReaper = nil + if !preserveListeners || terminallyQuiesced { + listeners.removeAll(keepingCapacity: true) + } + return stale + } + for connection in staleConnections { connection.abort() } + withLock { + inboundBufferedBytes = 0 + // Quiesce is a terminal host lifecycle decision. A late guest MMIO reset must not + // resurrect admission after teardown has begun. + isQuiesced = terminallyQuiesced + isResetting = false + } + if !terminallyQuiesced { + serviceAdmissionAuthority.finishReset() } } private enum VsockShutdown { - static let receive: UInt32 = 1 // VIRTIO_VSOCK_SHUTDOWN_RCV - static let send: UInt32 = 2 // VIRTIO_VSOCK_SHUTDOWN_SEND + static let receive: UInt32 = 1 + static let send: UInt32 = 2 + static let all = receive | send } - private final class InProcessConnection: VsockConnection { + private final class InProcessConnection: VsockConnection, @unchecked Sendable { + let id: UUID let key: ConnectionKey - private let send: (VirtioVsockHeader.Operation, [UInt8], UInt32, UInt32) -> Void - private let onClose: (ConnectionKey) -> Void - // `receive` runs on the vsock queue while `read`/`close` may run on a bridge's own thread, so - // inbound + isClosed are guarded. Drained bytes still count toward forwardCount (credit), so - // it is tracked separately from what remains buffered. + + private let origin: ConnectionOrigin + private let maximumInboundBytes: Int + private let send: ( + VirtioVsockHeader.Operation, + [UInt8], + UInt32, + UInt32 + ) -> HostPacketEnqueueResult + private let reserveInbound: (Int) -> InboundReservationResult + private let releaseInbound: (Int) -> Void + private let onLocalClose: () -> Void private let condition = NSCondition() + private let writeLock = NSLock() private var inbound = [UInt8]() private var forwardCountValue: UInt32 = 0 private var isClosed = false private var peerSendClosed = false + private var peerReceiveClosed = false private var hostSendClosed = false - // Peer credit: how much the guest socket can still absorb. Writes block while the in-flight - // window is exhausted — without this a fast host writer (a docker build context upload) - // overruns the guest's vsock buffer and the kernel drops the payload mid-stream. The values - // refresh from every guest packet header; 256 KiB matches the kernel's default buf_alloc as - // the pre-handshake estimate. + private var established: Bool private var peerBufferAllocation: UInt32 = 256 * 1024 private var peerForwardCount: UInt32 = 0 private var transmittedCount: UInt32 = 0 - // Linux's virtio-vsock RX buffers are smaller than the socket-level credit window. Keep each - // host->guest packet comfortably below the observed RX descriptor size so the header length - // can never describe more payload than fits in one virtqueue buffer. private static let writeChunk = 4 * 1024 - var forwardCount: UInt32 { condition.lock(); defer { condition.unlock() }; return forwardCountValue } - // True once the guest can no longer send (either a full close or a SHUT_WR half-close). Readers - // draining inbound use it as EOF; the host may still write a reply until a full close. - var isPeerClosed: Bool { condition.lock(); defer { condition.unlock() }; return isClosed || peerSendClosed } + var forwardCount: UInt32 { + condition.lock() + defer { condition.unlock() } + return forwardCountValue + } + + var isEstablished: Bool { + condition.lock() + defer { condition.unlock() } + return established && !isClosed + } + + var isPeerClosed: Bool { + condition.lock() + defer { condition.unlock() } + return isClosed || peerSendClosed + } init( + id: UUID, key: ConnectionKey, - send: @escaping (VirtioVsockHeader.Operation, [UInt8], UInt32, UInt32) -> Void, - onClose: @escaping (ConnectionKey) -> Void + origin: ConnectionOrigin, + maximumInboundBytes: Int, + send: @escaping ( + VirtioVsockHeader.Operation, + [UInt8], + UInt32, + UInt32 + ) -> HostPacketEnqueueResult, + reserveInbound: @escaping (Int) -> InboundReservationResult, + releaseInbound: @escaping (Int) -> Void, + onLocalClose: @escaping () -> Void ) { + self.id = id self.key = key + self.origin = origin + self.maximumInboundBytes = maximumInboundBytes self.send = send - self.onClose = onClose + self.reserveInbound = reserveInbound + self.releaseInbound = releaseInbound + self.onLocalClose = onLocalClose + established = origin == .guest } - func receive(_ bytes: [UInt8]) { + func acceptResponse(bufferAllocation: UInt32, forwardCount: UInt32) -> Bool { condition.lock() - inbound.append(contentsOf: bytes) - forwardCountValue &+= UInt32(bytes.count) + defer { condition.unlock() } + guard origin == .host, !established, !isClosed else { return false } + established = true + peerBufferAllocation = min(bufferAllocation, UInt32(maximumInboundBytes)) + peerForwardCount = forwardCount condition.broadcast() - condition.unlock() + return true } - func read(into buffer: UnsafeMutableRawBufferPointer) throws -> Int { + func receive(_ bytes: [UInt8]) -> InboundReceiveResult { condition.lock() defer { condition.unlock() } + guard established, !isClosed, !peerSendClosed else { + return .connectionClosed + } + let (next, overflow) = inbound.count.addingReportingOverflow(bytes.count) + guard !overflow, next <= maximumInboundBytes else { + return .perConnectionCapacityExceeded + } + switch reserveInbound(bytes.count) { + case .reserved: + inbound.append(contentsOf: bytes) + condition.broadcast() + return .accepted + case .connectionClosed: + return .connectionClosed + case .globalCapacityExceeded: + return .globalCapacityExceeded + } + } + + func read(into buffer: UnsafeMutableRawBufferPointer) throws -> Int { + condition.lock() let count = min(buffer.count, inbound.count) - guard count > 0 else { return 0 } + guard count > 0 else { + condition.unlock() + return 0 + } inbound.prefix(count).withUnsafeBytes { source in buffer.baseAddress?.copyMemory(from: source.baseAddress!, byteCount: count) } inbound.removeFirst(count) + // fwd_cnt advances only when the host consumer frees receive bytes. Advancing it when + // data is merely enqueued would falsely grant unlimited credit (VirtIO 1.3 5.10.6.3). + forwardCountValue &+= UInt32(count) + let credit = forwardCountValue + let shouldUpdateCredit = established && !isClosed && !peerSendClosed + condition.unlock() + + releaseInbound(count) + if shouldUpdateCredit { + // If this optional update meets outbound backpressure, CREDIT_REQUEST can recover + // the same current counter later without dropping any payload or required reply. + _ = send(.creditUpdate, [], credit, 0) + } return count } func updatePeerCredit(bufferAllocation: UInt32, forwardCount: UInt32) { condition.lock() - if bufferAllocation > 0 { peerBufferAllocation = bufferAllocation } + peerBufferAllocation = min(bufferAllocation, UInt32(maximumInboundBytes)) peerForwardCount = forwardCount condition.broadcast() condition.unlock() @@ -455,9 +2161,16 @@ public final class VirtioVsock: VirtioDeviceBackend { defer { condition.unlock() } if !inbound.isEmpty || isClosed || peerSendClosed { return true } if let timeoutNanoseconds { - let deadline = Date().addingTimeInterval(Double(timeoutNanoseconds) / 1_000_000_000) + let deadline = ProcessInfo.processInfo.systemUptime + + Double(timeoutNanoseconds) / 1_000_000_000 while inbound.isEmpty && !isClosed && !peerSendClosed { - if !condition.wait(until: deadline) { break } + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { break } + // NSCondition exposes a wall-clock Date API only. Short waits plus a monotonic + // outer deadline prevent clock adjustments from extending the caller's bound. + _ = condition.wait( + until: Date().addingTimeInterval(min(remaining, 0.05)) + ) } } else { while inbound.isEmpty && !isClosed && !peerSendClosed { @@ -475,34 +2188,57 @@ public final class VirtioVsock: VirtioDeviceBackend { let deadline = timeoutNanoseconds.map { ProcessInfo.processInfo.systemUptime + Double($0) / 1_000_000_000 } + writeLock.lock() + defer { writeLock.unlock() } var offset = 0 while offset < bytes.count { - let chunk = min(Self.writeChunk, bytes.count - offset) - try waitForCredit(chunk: UInt32(chunk), deadline: deadline) - condition.lock() - let credit = forwardCountValue - transmittedCount &+= UInt32(chunk) - condition.unlock() - send(.readWrite, Array(bytes[offset..<(offset + chunk)]), credit, 0) - offset += chunk + let maximumChunkCount = min(Self.writeChunk, bytes.count - offset) + let reservation = try reserveTransmit( + maximumCount: UInt32(maximumChunkCount), + deadline: deadline + ) + let chunkCount = Int(reservation.count) + let result = send( + .readWrite, + Array(bytes[offset..<(offset + chunkCount)]), + reservation.forwardCount, + 0 + ) + guard result == .enqueued else { + condition.lock() + transmittedCount &-= reservation.count + condition.unlock() + if result == .capacityExceeded { + throw VsockConnectionWriteError.outboundQueueFull + } + throw VsockConnectionWriteError.connectionClosed + } + offset += chunkCount } } - /// Blocks until the peer's receive window admits `chunk` more bytes. The deadline covers the - /// whole write, not each chunk, so a trickle of credit cannot extend a control call forever. - private func waitForCredit(chunk: UInt32, deadline: TimeInterval?) throws { + private func reserveTransmit( + maximumCount: UInt32, + deadline: TimeInterval? + ) throws -> (count: UInt32, forwardCount: UInt32) { while true { condition.lock() - let writable = !isClosed && !hostSendClosed - let inFlight = transmittedCount &- peerForwardCount - let allowance = peerBufferAllocation + let writable = !isClosed && !hostSendClosed && !peerReceiveClosed + let available = VirtioVsockCreditArithmetic.available( + bufferAllocation: peerBufferAllocation, + transmittedCount: transmittedCount, + peerForwardCount: peerForwardCount + ) if !writable { condition.unlock() throw VsockConnectionWriteError.connectionClosed } - if inFlight &+ chunk <= allowance { + if established && available > 0 { + let count = min(maximumCount, available) + transmittedCount &+= count + let credit = forwardCountValue condition.unlock() - return + return (count, credit) } if let deadline { let remaining = deadline - ProcessInfo.processInfo.systemUptime @@ -510,11 +2246,10 @@ public final class VirtioVsock: VirtioDeviceBackend { condition.unlock() throw VsockConnectionWriteError.timedOut } - let signalled = condition.wait(until: Date().addingTimeInterval(remaining)) + _ = condition.wait( + until: Date().addingTimeInterval(min(remaining, 0.05)) + ) condition.unlock() - if !signalled, ProcessInfo.processInfo.systemUptime >= deadline { - throw VsockConnectionWriteError.timedOut - } } else { condition.wait() condition.unlock() @@ -522,35 +2257,78 @@ public final class VirtioVsock: VirtioDeviceBackend { } } - func markPeerSendClosed() { + func markPeerShutdown(flags: UInt32) -> Bool { condition.lock() - peerSendClosed = true + if flags & VsockShutdown.receive != 0 { peerReceiveClosed = true } + if flags & VsockShutdown.send != 0 { peerSendClosed = true } + let complete = peerReceiveClosed && peerSendClosed condition.broadcast() condition.unlock() + return complete } - /// Host-side half-close: tells the guest this end is done sending (VIRTIO_VSOCK_SHUTDOWN_SEND) - /// while its remaining data keeps flowing back. The relay for `docker run`'s attach depends on - /// this — the CLI half-closes as soon as the request is sent and then reads the whole stream. func shutdownSend() { + writeLock.lock() condition.lock() - if isClosed || hostSendClosed { condition.unlock(); return } + if isClosed || hostSendClosed { + condition.unlock() + writeLock.unlock() + return + } hostSendClosed = true let credit = forwardCountValue condition.broadcast() condition.unlock() - send(.shutdown, [], credit, VsockShutdown.send) + let result = send(.shutdown, [], credit, VsockShutdown.send) + if result != .enqueued { + abort() + onLocalClose() + } + writeLock.unlock() } func close() { condition.lock() - if isClosed { condition.unlock(); return } + if isClosed { + condition.unlock() + return + } + // Publish closure before waiting for write serialization. A writer can be asleep in + // reserveTransmit with no timeout; setting this state and broadcasting is what lets it + // release writeLock so teardown cannot deadlock behind peer-credit starvation. isClosed = true - let credit = forwardCountValue + let released = inbound.count + inbound.removeAll(keepingCapacity: false) + let credit = forwardCountValue &+ UInt32(released) + forwardCountValue = credit + condition.broadcast() + condition.unlock() + if released > 0 { releaseInbound(released) } + + // Start the bounded retirement deadline before waiting for an in-flight writer to + // release serialization. The writer was woken above; if it is instead stuck below the + // connection layer, the tuple still fails closed and retires through the reaper. + onLocalClose() + writeLock.lock() + _ = send(.shutdown, [], credit, VsockShutdown.all) + // Keep a bounded tombstone until peer RST/reset; section 5.10.6.5 forbids tuple reuse + // while the peer may still be processing the old connection. + writeLock.unlock() + } + + func abort() { + condition.lock() + if isClosed && inbound.isEmpty { + condition.broadcast() + condition.unlock() + return + } + isClosed = true + let released = inbound.count + inbound.removeAll(keepingCapacity: false) condition.broadcast() condition.unlock() - send(.shutdown, [], credit, VsockShutdown.receive | VsockShutdown.send) - onClose(key) + if released > 0 { releaseInbound(released) } } } } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift index f0df8f09..8f469274 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift @@ -1,6 +1,68 @@ import Foundation import Synchronization +/// Transport-wide split-ring features implemented by the queue parser. +public enum VirtqueueFeature { + /// VIRTIO_RING_F_INDIRECT_DESC, virtio 1.2 section 2.7.5.3. + public static let indirectDescriptors: UInt64 = 1 << 28 + /// VIRTIO_F_VERSION_1. Virtio-MMIO v2 devices reject negotiation without this feature. + public static let version1: UInt64 = 1 << 32 +} + +/// Opaque identity for the exact queue configuration that produced a descriptor chain. +/// +/// Retaining a chain is safe only while this token is still current. Reset, QueueReady changes, +/// layout reconfiguration, and negotiated-feature changes all revoke previously issued tokens. +public struct VirtqueueLease: Equatable, Hashable, Sendable { + fileprivate let queueIdentity: UUID + public let generation: UInt64 +} + +private struct VirtqueueLeaseState: Sendable { + var queueIdentity: UUID + var generation: UInt64 +} + +private final class VirtqueueLeaseAuthority: Sendable { + private let state = Mutex(VirtqueueLeaseState(queueIdentity: UUID(), generation: 1)) + + var current: VirtqueueLease { + state.withLock { + VirtqueueLease(queueIdentity: $0.queueIdentity, generation: $0.generation) + } + } + + func invalidate() { + state.withLock { + if $0.generation == UInt64.max { + $0.queueIdentity = UUID() + $0.generation = 1 + } else { + $0.generation += 1 + } + } + } + + func validates(_ lease: VirtqueueLease) -> Bool { + state.withLock { + $0.queueIdentity == lease.queueIdentity && $0.generation == lease.generation + } + } + + /// Executes synchronous guest-memory access while preventing reset/reconfiguration from + /// revoking this lease. The body must not escape a pointer or segment beyond the call. + func withValidLease( + _ lease: VirtqueueLease, + _ body: () throws -> Result + ) rethrows -> Result? { + try state.withLock { + guard $0.queueIdentity == lease.queueIdentity, + $0.generation == lease.generation else { return nil } + return try body() + } + } +} + /// One buffer segment of a descriptor chain, resolved to host memory. public struct VirtqueueSegment { public let pointer: UnsafeMutableRawPointer @@ -8,25 +70,82 @@ public struct VirtqueueSegment { public let isDeviceWritable: Bool } -/// A popped descriptor chain: read segments first, then device-writable ones. -public struct VirtqueueChain { - public let head: UInt16 - public let segments: [VirtqueueSegment] +/// A synchronous, lease-held view of one descriptor chain. +/// +/// Instances are created only by `VirtqueueChain.withLeaseHeld`. Raw segments must not escape the +/// callback: reset/reconfiguration is blocked only until that callback returns. The type is +/// deliberately not `Sendable`, as its storage is guest-owned mutable memory. +struct VirtqueueLeaseAccess { + fileprivate let resolvedSegments: [VirtqueueSegment] - public var readableSegments: [VirtqueueSegment] { segments.filter { !$0.isDeviceWritable } } - public var writableSegments: [VirtqueueSegment] { segments.filter { $0.isDeviceWritable } } - public var hasWritableSegments: Bool { segments.contains { $0.isDeviceWritable } } + /// Module-internal raw view for zero-copy device backends. Its validity is bounded by the + /// surrounding `withLeaseHeld` callback. + var segments: [VirtqueueSegment] { resolvedSegments } - public func readBytes(maximum: Int = Int.max) -> [UInt8] { + var readableSegments: [VirtqueueSegment] { + resolvedSegments.filter { !$0.isDeviceWritable } + } + + var writableSegments: [VirtqueueSegment] { + resolvedSegments.filter(\.isDeviceWritable) + } + + var hasWritableSegments: Bool { + resolvedSegments.contains(where: \.isDeviceWritable) + } + + var readableSegmentCount: Int { + resolvedSegments.lazy.filter { !$0.isDeviceWritable }.count + } + + var writableSegmentCount: Int { + resolvedSegments.lazy.filter(\.isDeviceWritable).count + } + + var readableByteCount: Int { + byteCount(deviceWritable: false) + } + + var writableByteCount: Int { + byteCount(deviceWritable: true) + } + + private func byteCount(deviceWritable: Bool) -> Int { + var total = 0 + for segment in resolvedSegments where segment.isDeviceWritable == deviceWritable { + let (next, overflow) = total.addingReportingOverflow(segment.length) + guard segment.length >= 0, !overflow else { return 0 } + total = next + } + return total + } + + func readBytes(maximum: Int = Int.max) -> [UInt8] { + copyBytes(deviceWritable: false, maximum: maximum) + } + + /// Copies an already-encoded response while the queue lease is held. Async backends use this + /// host-owned snapshot to roll back grants after reset without rereading a repurposed buffer. + func copyWritableBytes(maximum: Int = Int.max) -> [UInt8] { + copyBytes(deviceWritable: true, maximum: maximum) + } + + private func copyBytes(deviceWritable: Bool, maximum: Int) -> [UInt8] { + guard maximum > 0 else { return [] } var bytes = [UInt8]() var capacity = 0 - for segment in segments where !segment.isDeviceWritable { + for segment in resolvedSegments where segment.isDeviceWritable == deviceWritable { + guard segment.length >= 0, capacity <= maximum else { return [] } let remaining = maximum - capacity guard remaining > 0 else { break } - capacity += min(segment.length, remaining) + let take = min(segment.length, remaining) + let (nextCapacity, overflow) = capacity.addingReportingOverflow(take) + guard !overflow else { return [] } + capacity = nextCapacity } bytes.reserveCapacity(capacity) - for segment in segments where !segment.isDeviceWritable { + for segment in resolvedSegments where segment.isDeviceWritable == deviceWritable { + guard segment.length >= 0, bytes.count <= maximum else { return [] } let take = min(segment.length, maximum - bytes.count) guard take > 0 else { break } bytes.append(contentsOf: UnsafeRawBufferPointer(start: segment.pointer, count: take)) @@ -35,17 +154,153 @@ public struct VirtqueueChain { } @discardableResult - public func writeBytes(_ bytes: [UInt8]) -> Int { - var offset = 0 - for segment in segments where segment.isDeviceWritable { - let take = min(segment.length, bytes.count - offset) + func writeBytes(_ bytes: [UInt8]) -> Int { + writeBytes(bytes, atWritableOffset: 0) + } + + @discardableResult + func writeBytes(_ bytes: [UInt8], atWritableOffset requestedOffset: Int) -> Int { + guard requestedOffset >= 0 else { return 0 } + var sourceOffset = 0 + var writableOffset = 0 + for segment in resolvedSegments where segment.isDeviceWritable { + guard segment.length >= 0 else { return sourceOffset } + let (segmentEnd, overflow) = writableOffset.addingReportingOverflow(segment.length) + guard !overflow else { return sourceOffset } + if segmentEnd <= requestedOffset { + writableOffset = segmentEnd + continue + } + let destinationOffset = max(0, requestedOffset - writableOffset) + let available = segment.length - destinationOffset + let take = min(available, bytes.count - sourceOffset) guard take > 0 else { break } - bytes[offset..<(offset + take)].withUnsafeBytes { source in - segment.pointer.copyMemory(from: source.baseAddress!, byteCount: take) + bytes[sourceOffset..<(sourceOffset + take)].withUnsafeBytes { source in + segment.pointer.advanced(by: destinationOffset).copyMemory( + from: source.baseAddress!, + byteCount: take + ) } - offset += take + sourceOffset += take + writableOffset = segmentEnd } - return offset + return sourceOffset + } +} + +/// A popped descriptor chain in the exact order supplied by the guest. +/// +/// Protocol backends must validate any required readable-prefix/writable-suffix layout; the +/// queue parser deliberately preserves mixed or reversed direction changes so they cannot be +/// normalized into an apparently valid request. +public struct VirtqueueChain: @unchecked Sendable { + public let head: UInt16 + public let lease: VirtqueueLease + /// True when the raw descriptor walk encountered a zero-length data descriptor. The queue + /// parser preserves its historical nonzero segment view, while protocol frontends that require + /// every descriptor to carry data can reject the original chain without guessing. + public let containsZeroLengthDescriptor: Bool + private let resolvedSegments: [VirtqueueSegment] + private let leaseAuthority: VirtqueueLeaseAuthority + + fileprivate init( + head: UInt16, + segments: [VirtqueueSegment], + containsZeroLengthDescriptor: Bool, + lease: VirtqueueLease, + leaseAuthority: VirtqueueLeaseAuthority + ) { + self.head = head + self.resolvedSegments = segments + self.containsZeroLengthDescriptor = containsZeroLengthDescriptor + self.lease = lease + self.leaseAuthority = leaseAuthority + } + + public var isLeaseValid: Bool { leaseAuthority.validates(lease) } + + public var hasWritableSegments: Bool { + withLeaseHeld(\.hasWritableSegments) ?? false + } + + public var readableSegmentCount: Int { + withLeaseHeld(\.readableSegmentCount) ?? 0 + } + + public var writableSegmentCount: Int { + withLeaseHeld(\.writableSegmentCount) ?? 0 + } + + public var readableByteCount: Int { + withLeaseHeld(\.readableByteCount) ?? 0 + } + + public var writableByteCount: Int { + withLeaseHeld(\.writableByteCount) ?? 0 + } + + /// Runs synchronous guest-buffer work under the queue's lifecycle lease. Reset, + /// QueueReady changes, feature renegotiation, and layout reconfiguration wait for this body to + /// finish before revoking the chain. The body must not escape `VirtqueueLeaseAccess` or any raw + /// segment/pointer obtained from it. + func withLeaseHeld( + _ body: (VirtqueueLeaseAccess) throws -> Result + ) rethrows -> Result? { + try leaseAuthority.withValidLease(lease) { + try body(VirtqueueLeaseAccess(resolvedSegments: resolvedSegments)) + } + } + + public func readBytes(maximum: Int = Int.max) -> [UInt8] { + withLeaseHeld { $0.readBytes(maximum: maximum) } ?? [] + } + + @discardableResult + public func writeBytes(_ bytes: [UInt8]) -> Int { + writeBytes(bytes, atWritableOffset: 0) + } + + /// Writes into the concatenated device-writable portion of the chain at a byte offset. + /// Virtio protocols such as virtio-snd place a writable PCM payload before a writable status + /// structure, so the device must address the latter without overwriting the former. + @discardableResult + public func writeBytes(_ bytes: [UInt8], atWritableOffset requestedOffset: Int) -> Int { + withLeaseHeld { + $0.writeBytes(bytes, atWritableOffset: requestedOffset) + } ?? 0 + } +} + +/// Exact result of attempting to publish a used-ring completion. A revoked chain is a lifecycle +/// outcome, not the same thing as a successfully published completion that suppressed interrupts. +public enum VirtqueuePushOutcome: Equatable, Sendable { + case published(wantsInterrupt: Bool) + case revoked +} + +/// Host-side complexity and memory limits applied while resolving one descriptor chain. +/// +/// These are deliberately independent of protocol-specific limits. A backend may impose a much +/// smaller request size after parsing, while this boundary prevents malformed guest rings from +/// creating unbounded descriptor walks, segment arrays, or host allocations first. +public struct VirtqueueLimits: Equatable, Sendable { + public static let hardenedDefault = VirtqueueLimits() + + public var maximumDescriptorCount: UInt64 + public var maximumSegmentCount: UInt64 + public var maximumSegmentBytes: UInt64 + public var maximumTotalBytes: UInt64 + + public init( + maximumDescriptorCount: UInt64 = 512, + maximumSegmentCount: UInt64 = 256, + maximumSegmentBytes: UInt64 = 64 * 1_024 * 1_024, + maximumTotalBytes: UInt64 = 64 * 1_024 * 1_024 + ) { + self.maximumDescriptorCount = maximumDescriptorCount + self.maximumSegmentCount = maximumSegmentCount + self.maximumSegmentBytes = maximumSegmentBytes + self.maximumTotalBytes = maximumTotalBytes } } @@ -57,41 +312,151 @@ public struct VirtqueueChain { /// device never race on ring indices in the single-CPU configuration; SMP adds explicit fences /// at the used-index publish below. public final class Virtqueue { + public static let maximumSize: UInt64 = 256 + public private(set) var size: UInt16 = 0 public private(set) var ready = false + public private(set) var negotiatedFeatures: UInt64 = 0 private var descriptorTable: UInt64 = 0 private var availRing: UInt64 = 0 private var usedRing: UInt64 = 0 private var lastAvailIndex: UInt16 = 0 private var usedIndex: UInt16 = 0 private let memory: GuestMemory + private let limits: VirtqueueLimits + private let leaseAuthority = VirtqueueLeaseAuthority() private struct DescriptorFlags { static let next: UInt16 = 1 static let write: UInt16 = 2 static let indirect: UInt16 = 4 + static let known = next | write | indirect } - public init(memory: GuestMemory) { + private struct TraversalState { + var descriptorCount: UInt64 = 0 + var segmentCount: UInt64 = 0 + var totalBytes: UInt64 = 0 + var containsZeroLengthDescriptor = false + + mutating func recordDescriptor(limits: VirtqueueLimits) throws { + guard descriptorCount < limits.maximumDescriptorCount else { + throw VMError.unexpectedExit("virtqueue descriptor limit exceeded") + } + descriptorCount += 1 + } + + mutating func recordSegment(byteCount: UInt64, limits: VirtqueueLimits) throws { + guard segmentCount < limits.maximumSegmentCount else { + throw VMError.unexpectedExit("virtqueue segment limit exceeded") + } + guard byteCount <= limits.maximumSegmentBytes else { + throw VMError.unexpectedExit("virtqueue segment byte limit exceeded") + } + let (nextTotal, overflow) = totalBytes.addingReportingOverflow(byteCount) + guard !overflow, nextTotal <= limits.maximumTotalBytes else { + throw VMError.unexpectedExit("virtqueue total byte limit exceeded") + } + segmentCount += 1 + totalBytes = nextTotal + } + } + + public init(memory: GuestMemory, limits: VirtqueueLimits = .hardenedDefault) { self.memory = memory + self.limits = limits + } + + public var currentLease: VirtqueueLease { leaseAuthority.current } + public var generation: UInt64 { currentLease.generation } + + public func isLeaseValid(_ lease: VirtqueueLease) -> Bool { + leaseAuthority.validates(lease) + } + + public func isLeaseValid(_ chain: VirtqueueChain) -> Bool { + isLeaseValid(chain.lease) } - public func configure(size: UInt16, descriptorTable: UInt64, availRing: UInt64, usedRing: UInt64) { + /// Applies the transport-negotiated ring features and revokes chains parsed under an older + /// feature contract. Unsupported backend-specific bits are harmless to this parser. + public func setNegotiatedFeatures(_ features: UInt64) { + guard negotiatedFeatures != features else { return } + leaseAuthority.invalidate() + negotiatedFeatures = features + } + + /// Applies one complete split-ring layout. Invalid guest layouts leave the queue disabled. + @discardableResult + public func configure( + untrustedSize requestedSize: UInt64, + descriptorTable: UInt64, + availRing: UInt64, + usedRing: UInt64 + ) -> Bool { + leaseAuthority.invalidate() + guard let size = UInt16(exactly: requestedSize), + Self.isValidSize(requestedSize), + descriptorTable % 16 == 0, + availRing % 2 == 0, + usedRing % 4 == 0, + let descriptorBytes = Self.multiplied(requestedSize, by: 16), + let availElementsBytes = Self.multiplied(requestedSize, by: 2), + let availBytes = Self.added(4, to: availElementsBytes), + let usedElementsBytes = Self.multiplied(requestedSize, by: 8), + let usedBytes = Self.added(4, to: usedElementsBytes), + memory.contains(descriptorTable, count: descriptorBytes), + memory.contains(availRing, count: availBytes), + memory.contains(usedRing, count: usedBytes) else { + invalidateConfiguration() + return false + } self.size = size self.descriptorTable = descriptorTable self.availRing = availRing self.usedRing = usedRing + return true } - public func setReady(_ isReady: Bool) { + /// Source-compatible entry point for trusted callers that already hold the transport-sized + /// queue value. It still runs every layout and power-of-two validation above. + @discardableResult + public func configure( + size: UInt16, + descriptorTable: UInt64, + availRing: UInt64, + usedRing: UInt64 + ) -> Bool { + configure( + untrustedSize: UInt64(size), + descriptorTable: descriptorTable, + availRing: availRing, + usedRing: usedRing + ) + } + + @discardableResult + public func setReady(_ isReady: Bool) -> Bool { + leaseAuthority.invalidate() + guard !isReady || Self.isValidSize(UInt64(size)) else { + ready = false + return false + } ready = isReady if isReady { lastAvailIndex = 0 usedIndex = 0 } + return true } public func reset() { + leaseAuthority.invalidate() + negotiatedFeatures = 0 + invalidateConfiguration() + } + + private func invalidateConfiguration() { ready = false size = 0 descriptorTable = 0 @@ -102,9 +467,20 @@ public final class Virtqueue { } public var hasPending: Bool { - guard ready, size > 0 else { return false } - let availIndex = (try? memory.read(UInt16.self, at: availRing + 2)) ?? lastAvailIndex - return availIndex != lastAvailIndex + ((try? pendingCount()) ?? 0) > 0 + } + + /// Returns the validated number of available entries. Unlike the compatibility `hasPending` + /// property, this preserves malformed ring state as an error for hardened device backends. + public func pendingCount() throws -> UInt16 { + guard ready, size > 0 else { return 0 } + let indexAddress = try checkedAdd(availRing, 2, "available-index address") + let availIndex = try memory.read(UInt16.self, at: indexAddress) + let pending = availIndex &- lastAvailIndex + guard pending <= size else { + throw VMError.unexpectedExit("virtqueue available ring overrun") + } + return pending } /// Resolves the next available descriptor without consuming it. @@ -122,75 +498,203 @@ public final class Virtqueue { private func nextChain(consume: Bool) throws -> VirtqueueChain? { guard ready, size > 0 else { return nil } - let availIndex = try memory.read(UInt16.self, at: availRing + 2) - guard availIndex != lastAvailIndex else { return nil } + let lease = currentLease + let availIndexAddress = try checkedAdd(availRing, 2, "available-index address") + let availIndex = try memory.read(UInt16.self, at: availIndexAddress) + let pending = availIndex &- lastAvailIndex + guard pending > 0 else { return nil } + guard pending <= size else { + throw VMError.unexpectedExit("virtqueue available ring overrun") + } let slot = UInt64(lastAvailIndex % size) - let head = try memory.read(UInt16.self, at: availRing + 4 + slot * 2) + let slotOffset = try checkedMultiply(slot, 2, "available-ring slot offset") + let ringOffset = try checkedAdd(4, slotOffset, "available-ring element offset") + let headAddress = try checkedAdd(availRing, ringOffset, "available-ring element address") + let head = try memory.read(UInt16.self, at: headAddress) if consume { lastAvailIndex &+= 1 } var segments = [VirtqueueSegment]() - try walkChain(startingAt: head, table: descriptorTable, tableSize: size, into: &segments, depth: 0) - return VirtqueueChain(head: head, segments: segments) + var traversal = TraversalState() + try walkChain( + startingAt: head, + table: descriptorTable, + tableSize: UInt64(size), + into: &segments, + insideIndirectTable: false, + traversal: &traversal + ) + guard isLeaseValid(lease) else { + throw VMError.unexpectedExit("virtqueue changed while resolving descriptor chain") + } + return VirtqueueChain( + head: head, + segments: segments, + containsZeroLengthDescriptor: traversal.containsZeroLengthDescriptor, + lease: lease, + leaseAuthority: leaseAuthority + ) } private func walkChain( startingAt first: UInt16, table: UInt64, - tableSize: UInt16, + tableSize: UInt64, into segments: inout [VirtqueueSegment], - depth: Int + insideIndirectTable: Bool, + traversal: inout TraversalState ) throws { - guard depth < 4 else { - throw VMError.unexpectedExit("virtqueue indirect descriptor nesting too deep") + guard tableSize > 0 else { + throw VMError.unexpectedExit("virtqueue descriptor table is empty") } - var index = first - var hops = 0 + var index = UInt64(first) + var hops: UInt64 = 0 while true { - guard hops <= Int(tableSize), index < tableSize else { + guard hops < tableSize, index < tableSize else { throw VMError.unexpectedExit("virtqueue descriptor chain out of bounds") } hops += 1 - let base = table + UInt64(index) * 16 + try traversal.recordDescriptor(limits: limits) + let descriptorOffset = try checkedMultiply(index, 16, "descriptor-table offset") + let base = try checkedAdd(table, descriptorOffset, "descriptor address") let address = try memory.read(UInt64.self, at: base) - let length = try memory.read(UInt32.self, at: base + 8) - let flags = try memory.read(UInt16.self, at: base + 12) - let next = try memory.read(UInt16.self, at: base + 14) + let length = try memory.read( + UInt32.self, + at: checkedAdd(base, 8, "descriptor length address") + ) + let flags = try memory.read( + UInt16.self, + at: checkedAdd(base, 12, "descriptor flags address") + ) + let next = try memory.read( + UInt16.self, + at: checkedAdd(base, 14, "descriptor next address") + ) + guard flags & ~DescriptorFlags.known == 0 else { + throw VMError.unexpectedExit("virtqueue descriptor contains unknown flags") + } if flags & DescriptorFlags.indirect != 0 { - let entries = UInt16(length / 16) - try walkChain(startingAt: 0, table: address, tableSize: entries, into: &segments, depth: depth + 1) - } else if length > 0 { - let pointer = try memory.hostPointer(at: address, count: UInt64(length)) - segments.append(VirtqueueSegment( - pointer: pointer, - length: Int(length), - isDeviceWritable: flags & DescriptorFlags.write != 0 - )) + guard negotiatedFeatures & VirtqueueFeature.indirectDescriptors != 0 else { + throw VMError.unexpectedExit( + "virtqueue indirect descriptor feature was not negotiated" + ) + } + guard !insideIndirectTable else { + throw VMError.unexpectedExit("virtqueue nested indirect descriptor") + } + guard flags & DescriptorFlags.next == 0 else { + throw VMError.unexpectedExit("virtqueue indirect descriptor also sets NEXT") + } + guard length > 0, length % 16 == 0, address % 16 == 0 else { + throw VMError.unexpectedExit("virtqueue indirect table has invalid layout") + } + let entryCount = UInt64(length) / 16 + guard entryCount <= limits.maximumDescriptorCount else { + throw VMError.unexpectedExit("virtqueue indirect table exceeds descriptor limit") + } + _ = try memory.hostPointer(at: address, count: UInt64(length)) + try walkChain( + startingAt: 0, + table: address, + tableSize: entryCount, + into: &segments, + insideIndirectTable: true, + traversal: &traversal + ) + break + } else { + if length == 0 { + traversal.containsZeroLengthDescriptor = true + } else { + let byteCount = UInt64(length) + try traversal.recordSegment(byteCount: byteCount, limits: limits) + guard let hostLength = Int(exactly: length) else { + throw VMError.unexpectedExit("virtqueue segment length is not host-representable") + } + let pointer = try memory.hostPointer(at: address, count: UInt64(length)) + segments.append(VirtqueueSegment( + pointer: pointer, + length: hostLength, + isDeviceWritable: flags & DescriptorFlags.write != 0 + )) + } } - if flags & DescriptorFlags.indirect != 0 || flags & DescriptorFlags.next == 0 { break } - index = next + guard flags & DescriptorFlags.next != 0 else { break } + index = UInt64(next) } } /// Returns whether the guest asked for an interrupt for this completion. @discardableResult public func push(_ chain: VirtqueueChain, written: Int) throws -> Bool { - guard ready, size > 0 else { return false } - let slot = UInt64(usedIndex % size) - try memory.write(UInt32(chain.head), at: usedRing + 4 + slot * 8) - try memory.write(UInt32(written), at: usedRing + 8 + slot * 8) - usedIndex &+= 1 - OSMemoryBarrier() // used entries visible before the index publish - try memory.write(usedIndex, at: usedRing + 2) - let availFlags = try memory.read(UInt16.self, at: availRing) - return availFlags & 1 == 0 // VRING_AVAIL_F_NO_INTERRUPT + switch try pushOutcome(chain, written: written) { + case .published(let wantsInterrupt): return wantsInterrupt + case .revoked: return false + } + } + + /// Publishes one used-ring completion while preserving lifecycle revocation as a typed outcome. + /// New asynchronous backends should use this API so reset/reconfiguration is observable rather + /// than conflated with `VRING_AVAIL_F_NO_INTERRUPT`. + public func pushOutcome( + _ chain: VirtqueueChain, + written: Int + ) throws -> VirtqueuePushOutcome { + // A backend may finish after the guest resets or reconfigures the queue. Preserve the + // safe no-op completion contract while refusing to publish that obsolete chain into either + // the replacement queue or a different queue. The typed API exposes that revocation. + let publication = try leaseAuthority.withValidLease(chain.lease) { + guard ready, size > 0 else { return false } + guard let written = UInt32(exactly: written) else { + throw VMError.unexpectedExit("virtqueue used length is not representable") + } + let slot = UInt64(usedIndex % size) + let slotOffset = try checkedMultiply(slot, 8, "used-ring slot offset") + let elementOffset = try checkedAdd(4, slotOffset, "used-ring element offset") + let elementAddress = try checkedAdd(usedRing, elementOffset, "used-ring element address") + try memory.write(UInt32(chain.head), at: elementAddress) + try memory.write(written, at: checkedAdd(elementAddress, 4, "used-ring length address")) + usedIndex &+= 1 + OSMemoryBarrier() // used entries visible before the index publish + try memory.write(usedIndex, at: checkedAdd(usedRing, 2, "used-index address")) + let availFlags = try memory.read(UInt16.self, at: availRing) + return availFlags & 1 == 0 // VRING_AVAIL_F_NO_INTERRUPT + } + guard let publication else { return .revoked } + return .published(wantsInterrupt: publication) + } + + private static func isValidSize(_ size: UInt64) -> Bool { + size > 0 && size <= maximumSize && size & (size - 1) == 0 + } + + private static func added(_ lhs: UInt64, to rhs: UInt64) -> UInt64? { + let (result, overflow) = rhs.addingReportingOverflow(lhs) + return overflow ? nil : result + } + + private static func multiplied(_ lhs: UInt64, by rhs: UInt64) -> UInt64? { + let (result, overflow) = lhs.multipliedReportingOverflow(by: rhs) + return overflow ? nil : result + } + + private func checkedAdd(_ lhs: UInt64, _ rhs: UInt64, _ context: String) throws -> UInt64 { + guard let result = Self.added(rhs, to: lhs) else { + throw VMError.unexpectedExit("virtqueue \(context) overflow") + } + return result + } + + private func checkedMultiply(_ lhs: UInt64, _ rhs: UInt64, _ context: String) throws -> UInt64 { + guard let result = Self.multiplied(lhs, by: rhs) else { + throw VMError.unexpectedExit("virtqueue \(context) overflow") + } + return result } } -extension VirtqueueSegment: @unchecked Sendable {} -extension VirtqueueChain: @unchecked Sendable {} extension Virtqueue: @unchecked Sendable {} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VsockServiceAdmission.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VsockServiceAdmission.swift new file mode 100644 index 00000000..7bb31a3c --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VsockServiceAdmission.swift @@ -0,0 +1,524 @@ +import Foundation + +/// Stable service identities for every host resource reachable through the VM's vsock device. +/// +/// The identity is intentionally not a guest-selected port number. A malicious or compromised +/// peer must not be able to manufacture new accounting buckets and evade a per-service ceiling. +public enum VirtioVsockService: String, CaseIterable, Hashable, Sendable { + /// Short-lived host RPCs to the guest agent, including USB capability/mutation calls. + case agentRPC + /// The private host Unix socket that exposes the guest agent control endpoint. + case agentSocket + /// The private dataplane socket whose authenticated preamble selects a guest port. + case agentForward + case docker + case fileEvents + case hostAI + case sshAgent + case shell + case usbip +} + +public enum VirtioVsockServiceAdmissionConfigurationError: Error, Equatable, Sendable { + case invalidAggregateLimit(Int) + case invalidDefaultServiceLimit(Int) + case invalidServiceLimit(service: VirtioVsockService, limit: Int) + case serviceLimitExceedsAggregate(service: VirtioVsockService, limit: Int, aggregate: Int) +} + +/// Immutable capacity policy shared by every service on one vsock device. +public struct VirtioVsockServiceAdmissionLimits: Equatable, Sendable { + public static let hardenedDefault = VirtioVsockServiceAdmissionLimits( + maximumSessionsTotal: 64, + defaultMaximumSessionsPerService: 16, + serviceOverrides: [ + .agentRPC: 8, + .fileEvents: 8, + .sshAgent: 8, + .shell: 8, + .usbip: 8, + ], + validated: () + ) + + public let maximumSessionsTotal: Int + public let defaultMaximumSessionsPerService: Int + public let serviceOverrides: [VirtioVsockService: Int] + + public init( + maximumSessionsTotal: Int, + defaultMaximumSessionsPerService: Int, + serviceOverrides: [VirtioVsockService: Int] = [:] + ) throws { + guard (1...256).contains(maximumSessionsTotal) else { + throw VirtioVsockServiceAdmissionConfigurationError.invalidAggregateLimit( + maximumSessionsTotal + ) + } + guard (1...maximumSessionsTotal).contains(defaultMaximumSessionsPerService) else { + throw VirtioVsockServiceAdmissionConfigurationError.invalidDefaultServiceLimit( + defaultMaximumSessionsPerService + ) + } + for (service, limit) in serviceOverrides { + guard limit > 0 else { + throw VirtioVsockServiceAdmissionConfigurationError.invalidServiceLimit( + service: service, + limit: limit + ) + } + guard limit <= maximumSessionsTotal else { + throw VirtioVsockServiceAdmissionConfigurationError.serviceLimitExceedsAggregate( + service: service, + limit: limit, + aggregate: maximumSessionsTotal + ) + } + } + self.init( + maximumSessionsTotal: maximumSessionsTotal, + defaultMaximumSessionsPerService: defaultMaximumSessionsPerService, + serviceOverrides: serviceOverrides, + validated: () + ) + } + + public func maximumSessions(for service: VirtioVsockService) -> Int { + serviceOverrides[service] ?? defaultMaximumSessionsPerService + } + + private init( + maximumSessionsTotal: Int, + defaultMaximumSessionsPerService: Int, + serviceOverrides: [VirtioVsockService: Int], + validated: Void + ) { + self.maximumSessionsTotal = maximumSessionsTotal + self.defaultMaximumSessionsPerService = defaultMaximumSessionsPerService + self.serviceOverrides = serviceOverrides + } +} + +/// Typed refusal returned before a host fd, relay thread, or guest connection is admitted. +public enum VirtioVsockServiceAdmissionError: Error, Equatable, Sendable { + case serviceCapacityReached(service: VirtioVsockService, limit: Int) + case aggregateCapacityReached(limit: Int) + case deviceResetting + case deviceQuiesced + case lifecycleRevoked(service: VirtioVsockService) +} + +/// Observable admission state. Counts include reservations that have passed policy but have not yet +/// published their stop callback, so the snapshot never understates resources already committed. +public struct VirtioVsockServiceAdmissionSnapshot: Equatable, Sendable { + public let activeSessionsTotal: Int + public let activeSessionsByService: [VirtioVsockService: Int] + public let serviceCapacityRejections: [VirtioVsockService: UInt64] + public let aggregateCapacityRejections: UInt64 + public let resettingRejections: UInt64 + public let quiescedRejections: UInt64 + public let resetRevocations: UInt64 + public let terminalRevocations: UInt64 + public let latePublicationRejections: UInt64 + public let completedSessions: UInt64 + public let generation: UInt64 + public let isResetting: Bool + public let isQuiesced: Bool +} + +/// A two-phase reservation prevents reset, stop, or a concurrent cap crossing from publishing an +/// unowned late session. It is internal because production callers must use the service-labelled +/// listener/connect APIs on VirtioVsock rather than manually handling admission tokens. +struct VirtioVsockServiceReservation: Sendable { + fileprivate let id: UUID + fileprivate let service: VirtioVsockService + fileprivate let generation: UInt64 +} + +/// Exact ownership of one published service session. Close/deinit is idempotent and generation +/// checked; reset may revoke the authority first, in which case a late close is harmless. +final class VirtioVsockServiceLease: @unchecked Sendable { + private let lock = NSLock() + private weak var authority: VirtioVsockServiceAdmissionAuthority? + private var id: UUID? + private let generation: UInt64 + + fileprivate init( + authority: VirtioVsockServiceAdmissionAuthority, + id: UUID, + generation: UInt64 + ) { + self.authority = authority + self.id = id + self.generation = generation + } + + func close() { + lock.lock() + let ownedID = id + id = nil + let authority = authority + self.authority = nil + lock.unlock() + if let ownedID { + authority?.release(id: ownedID, generation: generation) + } + } + + /// Replaces the provisional transport-close callback with the owning service session's stronger + /// stop action. False means reset/quiesce already revoked the generation. + func replaceStopAction(_ action: @escaping @Sendable () -> Void) -> Bool { + lock.lock() + guard let id else { + lock.unlock() + return false + } + let authority = authority + lock.unlock() + return authority?.replaceStopAction( + id: id, + generation: generation, + action: action + ) ?? false + } + + deinit { + close() + } +} + +/// Per-VM authority shared by guest-initiated listeners, host-initiated connections, and the local +/// Unix relay frontends. It owns no service object, only bounded reservations and idempotent stop +/// callbacks; bridge lifecycles remain responsible for unregistering listeners and draining work. +final class VirtioVsockServiceAdmissionAuthority: @unchecked Sendable { + private enum Phase { + case active + case resetting + case quiesced + } + + private struct ReservationRecord { + let service: VirtioVsockService + let generation: UInt64 + } + + private struct SessionRecord { + let service: VirtioVsockService + let generation: UInt64 + var requestStop: @Sendable () -> Void + } + + private let lock = NSLock() + private let limits: VirtioVsockServiceAdmissionLimits + private var phase: Phase = .active + private var generation: UInt64 = 1 + private var reservations = [UUID: ReservationRecord]() + private var sessions = [UUID: SessionRecord]() + private var serviceCapacityRejections = [VirtioVsockService: UInt64]() + private var aggregateCapacityRejections: UInt64 = 0 + private var resettingRejections: UInt64 = 0 + private var quiescedRejections: UInt64 = 0 + private var resetRevocations: UInt64 = 0 + private var terminalRevocations: UInt64 = 0 + private var latePublicationRejections: UInt64 = 0 + private var completedSessions: UInt64 = 0 + + init(limits: VirtioVsockServiceAdmissionLimits) { + self.limits = limits + } + + func reserve(_ service: VirtioVsockService) throws -> VirtioVsockServiceReservation { + lock.lock() + defer { lock.unlock() } + switch phase { + case .active: + break + case .resetting: + increment(&resettingRejections) + throw VirtioVsockServiceAdmissionError.deviceResetting + case .quiesced: + increment(&quiescedRejections) + throw VirtioVsockServiceAdmissionError.deviceQuiesced + } + + let activeTotal = reservations.count + sessions.count + guard activeTotal < limits.maximumSessionsTotal else { + increment(&aggregateCapacityRejections) + throw VirtioVsockServiceAdmissionError.aggregateCapacityReached( + limit: limits.maximumSessionsTotal + ) + } + let serviceTotal = countLocked(service: service) + let serviceLimit = limits.maximumSessions(for: service) + guard serviceTotal < serviceLimit else { + var count = serviceCapacityRejections[service] ?? 0 + increment(&count) + serviceCapacityRejections[service] = count + throw VirtioVsockServiceAdmissionError.serviceCapacityReached( + service: service, + limit: serviceLimit + ) + } + let id = UUID() + reservations[id] = ReservationRecord(service: service, generation: generation) + return VirtioVsockServiceReservation( + id: id, + service: service, + generation: generation + ) + } + + func cancel(_ reservation: VirtioVsockServiceReservation) { + lock.lock() + if reservations[reservation.id]?.generation == reservation.generation, + reservations[reservation.id]?.service == reservation.service { + reservations.removeValue(forKey: reservation.id) + } + lock.unlock() + } + + func publish( + _ reservation: VirtioVsockServiceReservation, + requestStop: @escaping @Sendable () -> Void + ) -> VirtioVsockServiceLease? { + lock.lock() + guard case .active = phase, + generation == reservation.generation, + let record = reservations.removeValue(forKey: reservation.id), + record.generation == reservation.generation, + record.service == reservation.service else { + // Remove an exact stale reservation if reset has not already done so. + if reservations[reservation.id]?.generation == reservation.generation { + reservations.removeValue(forKey: reservation.id) + } + increment(&latePublicationRejections) + lock.unlock() + return nil + } + sessions[reservation.id] = SessionRecord( + service: reservation.service, + generation: reservation.generation, + requestStop: requestStop + ) + lock.unlock() + return VirtioVsockServiceLease( + authority: self, + id: reservation.id, + generation: reservation.generation + ) + } + + func beginReset() { + revoke(terminal: false) + } + + func finishReset() { + lock.lock() + if case .resetting = phase { phase = .active } + lock.unlock() + } + + func quiesce() { + revoke(terminal: true) + } + + var snapshot: VirtioVsockServiceAdmissionSnapshot { + lock.lock() + defer { lock.unlock() } + var activeByService = [VirtioVsockService: Int]() + for record in reservations.values { + activeByService[record.service, default: 0] += 1 + } + for record in sessions.values { + activeByService[record.service, default: 0] += 1 + } + return VirtioVsockServiceAdmissionSnapshot( + activeSessionsTotal: reservations.count + sessions.count, + activeSessionsByService: activeByService, + serviceCapacityRejections: serviceCapacityRejections, + aggregateCapacityRejections: aggregateCapacityRejections, + resettingRejections: resettingRejections, + quiescedRejections: quiescedRejections, + resetRevocations: resetRevocations, + terminalRevocations: terminalRevocations, + latePublicationRejections: latePublicationRejections, + completedSessions: completedSessions, + generation: generation, + isResetting: { + if case .resetting = phase { return true } + return false + }(), + isQuiesced: { + if case .quiesced = phase { return true } + return false + }() + ) + } + + fileprivate func release(id: UUID, generation: UInt64) { + lock.lock() + if sessions[id]?.generation == generation { + sessions.removeValue(forKey: id) + increment(&completedSessions) + } + lock.unlock() + } + + fileprivate func replaceStopAction( + id: UUID, + generation: UInt64, + action: @escaping @Sendable () -> Void + ) -> Bool { + lock.lock() + defer { lock.unlock() } + guard var record = sessions[id], record.generation == generation else { + return false + } + record.requestStop = action + sessions[id] = record + return true + } + + private func revoke(terminal: Bool) { + let stopActions: [@Sendable () -> Void] + lock.lock() + if case .quiesced = phase { + lock.unlock() + return + } + phase = terminal ? .quiesced : .resetting + generation &+= 1 + if generation == 0 { generation = 1 } + let revokedCount = UInt64(reservations.count + sessions.count) + if terminal { + addClamped(revokedCount, to: &terminalRevocations) + } else { + addClamped(revokedCount, to: &resetRevocations) + } + stopActions = sessions.values.map(\.requestStop) + reservations.removeAll(keepingCapacity: true) + sessions.removeAll(keepingCapacity: true) + lock.unlock() + + // Never execute a service callback while holding admission state: every callback is allowed + // to close a VsockConnection, which re-enters the device's transport lifecycle. + for requestStop in stopActions { requestStop() } + } + + private func countLocked(service: VirtioVsockService) -> Int { + reservations.values.reduce(0) { $0 + ($1.service == service ? 1 : 0) } + + sessions.values.reduce(0) { $0 + ($1.service == service ? 1 : 0) } + } + + private func increment(_ value: inout UInt64) { + if value < UInt64.max { value += 1 } + } + + private func addClamped(_ amount: UInt64, to value: inout UInt64) { + let (sum, overflow) = value.addingReportingOverflow(amount) + value = overflow ? UInt64.max : sum + } +} + +/// Holds a service lease for exactly as long as its underlying transport stream remains owned. +/// Reset/quiesce closes the underlying stream via the authority's stop callback; a late wrapper +/// close is harmless because the lease generation has already been revoked. +final class ServiceOwnedVsockConnection: VsockConnection, @unchecked Sendable { + private let lock = NSLock() + private var connection: VsockConnection? + private var lease: VirtioVsockServiceLease? + + init(connection: VsockConnection, lease: VirtioVsockServiceLease) { + self.connection = connection + self.lease = lease + } + + var isPeerClosed: Bool { + let connection = currentConnection + let closed = connection?.isPeerClosed ?? true + if closed { releaseLease() } + return closed + } + + func read(into buffer: UnsafeMutableRawBufferPointer) throws -> Int { + guard let connection = currentConnection else { return 0 } + let count = try connection.read(into: buffer) + if count == 0, connection.isPeerClosed { releaseLease() } + return count + } + + func write(_ bytes: [UInt8]) throws { + guard let connection = currentConnection else { + throw VsockConnectionWriteError.connectionClosed + } + do { + try connection.write(bytes) + } catch { + if connection.isPeerClosed { releaseLease() } + throw error + } + } + + func write(_ bytes: [UInt8], timeoutNanoseconds: UInt64?) throws { + guard let connection = currentConnection else { + throw VsockConnectionWriteError.connectionClosed + } + do { + try connection.write(bytes, timeoutNanoseconds: timeoutNanoseconds) + } catch { + if connection.isPeerClosed { releaseLease() } + throw error + } + } + + func waitForReadable(timeoutNanoseconds: UInt64?) -> Bool { + guard let connection = currentConnection else { return true } + let ready = connection.waitForReadable(timeoutNanoseconds: timeoutNanoseconds) + if connection.isPeerClosed { releaseLease() } + return ready + } + + func shutdownSend() { + currentConnection?.shutdownSend() + } + + func close() { + let ownedConnection: VsockConnection? + let ownedLease: VirtioVsockServiceLease? + lock.lock() + ownedConnection = connection + ownedLease = lease + connection = nil + lease = nil + lock.unlock() + ownedConnection?.close() + ownedLease?.close() + } + + func replaceServiceStopAction( + _ action: @escaping @Sendable () -> Void + ) -> Bool { + lock.lock() + let lease = lease + lock.unlock() + return lease?.replaceStopAction(action) ?? false + } + + deinit { + close() + } + + private var currentConnection: VsockConnection? { + lock.lock() + defer { lock.unlock() } + return connection + } + + private func releaseLease() { + lock.lock() + let ownedLease = lease + lease = nil + lock.unlock() + ownedLease?.close() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VsockUnixRelay.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VsockUnixRelay.swift index 4a89b7ed..84d3d5e8 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VsockUnixRelay.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VsockUnixRelay.swift @@ -5,16 +5,25 @@ import Foundation /// error so required engine endpoints can fail startup with an actionable reason instead of /// degrading into a later timeout. public enum UnixSocketListenerError: Error, Equatable, CustomStringConvertible, Sendable { + case invalidAbsolutePath(path: String) case pathTooLong(path: String, utf8ByteCount: Int, maximumUTF8ByteCount: Int) case embeddedNull(path: String) + case untrustedExistingNode(path: String) + case endpointInUse(path: String) case systemCall(operation: String, path: String, code: Int32) public var description: String { switch self { + case let .invalidAbsolutePath(path): + return "unix socket path must be absolute: \(path)" case let .pathTooLong(path, actual, maximum): return "unix socket path is \(actual) UTF-8 bytes (maximum \(maximum)): \(path)" case let .embeddedNull(path): return "unix socket path contains a NUL byte: \(path)" + case let .untrustedExistingNode(path): + return "refusing to replace a non-socket or non-owned unix endpoint: \(path)" + case let .endpointInUse(path): + return "refusing to replace a live unix endpoint: \(path)" case let .systemCall(operation, path, code): let reason = String(cString: strerror(code)) return "cannot \(operation) unix socket \(path): errno \(code) (\(reason))" @@ -23,10 +32,35 @@ public enum UnixSocketListenerError: Error, Equatable, CustomStringConvertible, } /// The one unix⇄vsock byte relay, shared by every bridge that serves a unix socket in front of a -/// guest vsock stream (`DockerSocketBridge`, `AgentVsockForward`). Both directions preserve +/// guest vsock stream (`GuestVsockSocketBridge`, `DockerSocketBridge`, `AgentVsockForward`). +/// Both directions preserve /// half-close: a client SHUT_WR becomes a vsock SEND-only shutdown, and the guest's send-EOF /// becomes a SHUT_WR back to the client — a full close in either spot truncates docker attach. enum VsockUnixRelay { + struct SocketPathIdentity: Equatable, Sendable { + let device: dev_t + let inode: ino_t + let generation: UInt32 + let birthTimeSeconds: Int64 + let birthTimeNanoseconds: Int64 + } + + struct OwnedListener: Sendable { + let descriptor: Int32 + let pathIdentity: SocketPathIdentity + } + + private enum ExistingEndpointProbe { + case live + case stale + case indeterminate(Int32) + } + + /// Serializes Dory-owned pathname publication and retirement. Darwin has no conditional-unlink + /// syscall, so the identity check and unlink must share this lock with every in-process bind to + /// prevent one bridge's cleanup from racing another bridge's replacement publication. + private static let socketPathMutationLock = NSLock() + /// Darwin's `sockaddr_un.sun_path` includes its trailing NUL. Validate UTF-8 bytes rather than /// Swift characters: a multibyte path that looks short can still overflow the kernel field. static let maximumSocketPathByteCount: Int = { @@ -36,6 +70,9 @@ enum VsockUnixRelay { static func validateSocketPath(_ socketPath: String) throws { let pathBytes = Array(socketPath.utf8) + guard socketPath.hasPrefix("/") else { + throw UnixSocketListenerError.invalidAbsolutePath(path: socketPath) + } guard !pathBytes.contains(0) else { throw UnixSocketListenerError.embeddedNull(path: socketPath) } @@ -48,16 +85,72 @@ enum VsockUnixRelay { } } - static func makeListener(socketPath: String, mode: mode_t? = nil) throws -> Int32 { + /// Publishes a listener and captures the exact filesystem socket identity while the descriptor + /// is still live. Owners use that identity to avoid deleting a replacement endpoint during + /// asynchronous teardown. + static func makeOwnedListener( + socketPath: String, + mode: mode_t? = nil + ) throws -> OwnedListener { + socketPathMutationLock.lock() + defer { socketPathMutationLock.unlock() } try validateSocketPath(socketPath) let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { throw UnixSocketListenerError.systemCall(operation: "create", path: socketPath, code: errno) } - guard unlink(socketPath) == 0 || errno == ENOENT else { + guard fcntl(fd, F_SETFD, FD_CLOEXEC) == 0 else { + let code = errno + close(fd) + throw UnixSocketListenerError.systemCall( + operation: "set close-on-exec for", + path: socketPath, + code: code + ) + } + var stale = stat() + if lstat(socketPath, &stale) == 0 { + guard stale.st_mode & S_IFMT == S_IFSOCK, + stale.st_uid == geteuid(), + let staleIdentity = socketPathIdentity(at: socketPath) else { + close(fd) + throw UnixSocketListenerError.untrustedExistingNode(path: socketPath) + } + switch probeExistingListener(socketPath) { + case .live: + close(fd) + throw UnixSocketListenerError.endpointInUse(path: socketPath) + case .indeterminate(let code): + close(fd) + throw UnixSocketListenerError.systemCall( + operation: "prove stale", + path: socketPath, + code: code + ) + case .stale: + break + } + guard socketPathIdentity(at: socketPath) == staleIdentity else { + close(fd) + throw UnixSocketListenerError.untrustedExistingNode(path: socketPath) + } + guard unlink(socketPath) == 0 else { + let code = errno + close(fd) + throw UnixSocketListenerError.systemCall( + operation: "remove stale", + path: socketPath, + code: code + ) + } + } else if errno != ENOENT { let code = errno close(fd) - throw UnixSocketListenerError.systemCall(operation: "remove stale", path: socketPath, code: code) + throw UnixSocketListenerError.systemCall( + operation: "inspect stale", + path: socketPath, + code: code + ) } var address = sockaddr_un() address.sun_family = sa_family_t(AF_UNIX) @@ -78,40 +171,270 @@ enum VsockUnixRelay { close(fd) throw UnixSocketListenerError.systemCall(operation: "bind", path: socketPath, code: code) } + guard let pathIdentity = socketPathIdentity(at: socketPath) else { + let code = errno == 0 ? EIO : errno + close(fd) + throw UnixSocketListenerError.systemCall( + operation: "inspect bound", + path: socketPath, + code: code + ) + } guard Darwin.listen(fd, 64) == 0 else { let code = errno - // Remove our pathname while the descriptor still owns the bound socket. Closing first - // would let another process bind a replacement that this cleanup could then unlink. - unlink(socketPath) + unlinkSocketIfOwnedLocked(socketPath, identity: pathIdentity) close(fd) throw UnixSocketListenerError.systemCall(operation: "listen on", path: socketPath, code: code) } if let mode, chmod(socketPath, mode) != 0 { let code = errno - // As above, retire the pathname before releasing the descriptor to avoid deleting a - // replacement socket in the close-to-unlink window. - unlink(socketPath) + unlinkSocketIfOwnedLocked(socketPath, identity: pathIdentity) close(fd) throw UnixSocketListenerError.systemCall(operation: "chmod", path: socketPath, code: code) } - return fd + guard socketPathIdentity(at: socketPath) == pathIdentity else { + unlinkSocketIfOwnedLocked(socketPath, identity: pathIdentity) + close(fd) + throw UnixSocketListenerError.systemCall( + operation: "retain identity of", + path: socketPath, + code: ESTALE + ) + } + return OwnedListener(descriptor: fd, pathIdentity: pathIdentity) } - /// Relays until both directions finish, then tears everything down. Takes ownership of both ends. - static func serve(client: Int32, connection: VsockConnection) { - defer { - connection.close() - close(client) - } - let group = DispatchGroup() - group.enter() - let box = ConnectionBox(connection) - Thread.detachNewThread { - pumpVsockToClient(from: box.connection, to: client) - group.leave() - } - pumpClientToVsock(from: client, to: connection) - group.wait() + /// A same-uid socket is not stale merely because a pathname already exists. Only the kernel's + /// explicit no-listener outcomes authorize removal; a live listener, a full backlog, and every + /// ambiguous probe result fail closed. + private static func probeExistingListener(_ socketPath: String) -> ExistingEndpointProbe { + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { return .indeterminate(errno) } + defer { close(descriptor) } + guard fcntl(descriptor, F_SETFD, FD_CLOEXEC) == 0 else { + return .indeterminate(errno) + } + let statusFlags = fcntl(descriptor, F_GETFL) + guard statusFlags >= 0, + fcntl(descriptor, F_SETFL, statusFlags | O_NONBLOCK) == 0 else { + return .indeterminate(errno) + } + var noSigpipe: Int32 = 1 + guard setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size) + ) == 0 else { + return .indeterminate(errno) + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketPath.utf8) + withUnsafeMutableBytes(of: &address.sun_path) { destination in + pathBytes.withUnsafeBytes { source in + destination.baseAddress!.copyMemory( + from: source.baseAddress!, + byteCount: pathBytes.count + ) + } + } + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + if result == 0 { return .live } + switch errno { + case ECONNREFUSED, ENOENT: + return .stale + default: + return .indeterminate(errno) + } + } + + @discardableResult + static func makeNonBlocking(_ descriptor: Int32) -> Bool { + let flags = fcntl(descriptor, F_GETFL) + return flags >= 0 && fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0 + } + + /// Removes only the filesystem node captured for this listener. The listener descriptor must + /// remain open until after this call; that ordering narrows replacement races and ensures a + /// completion signal means pathname ownership has already been surrendered. + static func retireOwnedListener(_ listener: OwnedListener, socketPath: String) { + socketPathMutationLock.lock() + defer { socketPathMutationLock.unlock() } + unlinkSocketIfOwnedLocked(socketPath, identity: listener.pathIdentity) + close(listener.descriptor) + } + + @discardableResult + private static func unlinkSocketIfOwnedLocked( + _ socketPath: String, + identity: SocketPathIdentity + ) -> Bool { + guard let current = socketPathIdentity(at: socketPath) else { + return errno == ENOENT + } + guard current == identity else { return false } + return unlink(socketPath) == 0 || errno == ENOENT + } + + private static func socketPathIdentity(at socketPath: String) -> SocketPathIdentity? { + var info = stat() + guard lstat(socketPath, &info) == 0, + info.st_mode & S_IFMT == S_IFSOCK, + info.st_uid == geteuid() else { + return nil + } + return SocketPathIdentity( + device: info.st_dev, + inode: info.st_ino, + generation: info.st_gen, + birthTimeSeconds: Int64(info.st_birthtimespec.tv_sec), + birthTimeNanoseconds: Int64(info.st_birthtimespec.tv_nsec) + ) + } + + /// One cancel-safe relay. Normal execution keeps the established half-close contract; an + /// explicit bridge stop uses full shutdown only to retire the session. The client descriptor is + /// closed by the relay after both pumps finish, never by the stopping thread, so descriptor reuse + /// cannot redirect a late close or shutdown to an unrelated file. + final class RelaySession: @unchecked Sendable { + private let lock = NSLock() + private var client: Int32? + private var connection: VsockConnection? + private var prepareConnection: (@Sendable (Int32) -> VsockConnection?)? + private var started = false + private var stopRequested = false + private var completion: (@Sendable () -> Void)? + + init( + client: Int32, + connection: VsockConnection, + completion: @escaping @Sendable () -> Void = {} + ) { + self.client = client + self.connection = connection + self.prepareConnection = nil + self.completion = completion + } + + /// Owns an accepted descriptor before a guest connection necessarily exists. The bounded + /// listener uses this for protocol admission (notably AgentVsockForward's preamble): stop() + /// can shutdown the client and wake that preparation without racing its eventual close. + /// `prepareConnection` borrows the descriptor; RelaySession remains its only close owner. + init( + client: Int32, + prepareConnection: @escaping @Sendable (Int32) -> VsockConnection?, + completion: @escaping @Sendable () -> Void = {} + ) { + self.client = client + self.connection = nil + self.prepareConnection = prepareConnection + self.completion = completion + } + + func run() { + let descriptor: Int32 + let existingConnection: VsockConnection? + let preparation: (@Sendable (Int32) -> VsockConnection?)? + lock.lock() + guard !started, let client else { + lock.unlock() + return + } + started = true + descriptor = client + existingConnection = connection + preparation = prepareConnection + prepareConnection = nil + lock.unlock() + + guard let preparedConnection = existingConnection ?? preparation?(descriptor) else { + finish() + return + } + lock.lock() + if connection == nil { connection = preparedConnection } + let shouldRelay = !stopRequested + lock.unlock() + guard shouldRelay else { + finish() + return + } + + let group = DispatchGroup() + group.enter() + let box = ConnectionBox(preparedConnection) + Thread.detachNewThread { + pumpVsockToClient(from: box.connection, to: descriptor) + group.leave() + } + pumpClientToVsock(from: descriptor, to: preparedConnection) + group.wait() + finish() + } + + func requestStop() { + lock.lock() + guard let client, !stopRequested else { + lock.unlock() + return + } + stopRequested = true + // Keep the descriptor allocated until both pumps have observed shutdown. Holding the + // lifecycle lock makes this syscall mutually exclusive with finish's final close. + _ = shutdown(client, SHUT_RDWR) + connection?.close() + lock.unlock() + } + + /// Used only when bridge shutdown wins the race before the relay thread is published. + func discardBeforeStart() { + let descriptor: Int32? + let callback: (@Sendable () -> Void)? + lock.lock() + guard !started else { + lock.unlock() + requestStop() + return + } + descriptor = client + client = nil + stopRequested = true + callback = completion + completion = nil + prepareConnection = nil + connection?.close() + connection = nil + if let descriptor { close(descriptor) } + lock.unlock() + callback?() + } + + private func finish() { + let descriptor: Int32? + let callback: (@Sendable () -> Void)? + lock.lock() + descriptor = client + client = nil + callback = completion + completion = nil + prepareConnection = nil + connection?.close() + connection = nil + if let descriptor { close(descriptor) } + lock.unlock() + callback?() + } } private final class ConnectionBox: @unchecked Sendable { diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/X86BootPlan.swift b/Packages/ContainerizationEngine/Sources/DoryHV/X86BootPlan.swift index 04d9f21a..aab22f87 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/X86BootPlan.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/X86BootPlan.swift @@ -30,6 +30,8 @@ public enum X86GuestLayout { public static let rtcBase: UInt64 = 0x70 public static let virtioBase: UInt64 = 0xD000_0000 public static let virtioSlotSize: UInt64 = 0x1000 + /// The Hypervisor.framework IOAPIC exposes pins 0...23; virtio starts at pin 16. + public static let virtioSlotCount = 8 public static let virtioFirstIRQ: UInt8 = 16 public static let ramBase: UInt64 = 0x0010_0000 public static let mmioHoleBase: UInt64 = virtioBase @@ -37,6 +39,7 @@ public enum X86GuestLayout { public static let pvhCommandLine: UInt64 = 0x0009_1000 public static let pvhModules: UInt64 = 0x0009_2000 public static let pvhMemoryMap: UInt64 = 0x0009_3000 + public static let initrd: UInt64 = 0x1000_0000 public static let mpFloatingPointer: UInt64 = 0x000F_0000 public static let mpConfigurationTable: UInt64 = 0x000F_1000 public static let daxWindowBase: UInt64 = 0xC_0000_0000 @@ -63,6 +66,27 @@ public enum X86BootPlanBuilder { irq: UInt8(truncatingIfNeeded: UInt32(X86GuestLayout.virtioFirstIRQ) + UInt32(slot)) ) } + return build( + baseCommandLine: baseCommandLine, + memoryBytes: memoryBytes, + virtioDevices: virtioDevices + ) + } + + /// Builds every x86 boot surface from explicit occupied slots. Sorting here is defensive: the + /// machine ownership table already returns canonical slot order, but callers constructing a + /// diagnostic or golden plan must receive identical command-line and MPTABLE inputs regardless + /// of attachment order. + static func build( + baseCommandLine: String = "root=/dev/vda rw panic=0", + memoryBytes: UInt64, + virtioDevices: [X86VirtioMMIODevice] + ) -> X86BootPlan { + let virtioDevices = virtioDevices.sorted { lhs, rhs in + if lhs.slot != rhs.slot { return lhs.slot < rhs.slot } + if lhs.baseAddress != rhs.baseAddress { return lhs.baseAddress < rhs.baseAddress } + return lhs.irq < rhs.irq + } let commandLine = commandLine(baseCommandLine: baseCommandLine, virtioDevices: virtioDevices) return X86BootPlan( commandLine: commandLine, diff --git a/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/DoryHVUSBShim.m b/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/DoryHVUSBShim.m index 4a4953f6..f98a24b2 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/DoryHVUSBShim.m +++ b/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/DoryHVUSBShim.m @@ -14,6 +14,20 @@ BOOL DoryIOUSBHostSendDeviceRequest(IOUSBHostObject *object, error:error]; } +BOOL DoryIOUSBHostEnqueueDeviceRequest(IOUSBHostObject *object, + IOUSBDeviceRequest request, + NSMutableData *data, + NSTimeInterval timeout, + NSError **error, + DoryIOUSBHostCompletionHandler completionHandler) +{ + return [object enqueueDeviceRequest:request + data:data + completionTimeout:timeout + error:error + completionHandler:completionHandler]; +} + BOOL DoryIOUSBHostAbortDeviceRequests(IOUSBHostObject *object, IOUSBHostAbortOption option, NSError **error) diff --git a/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/include/DoryHVUSBShim.h b/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/include/DoryHVUSBShim.h index 661fe442..60a87f5e 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/include/DoryHVUSBShim.h +++ b/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/include/DoryHVUSBShim.h @@ -11,6 +11,15 @@ BOOL DoryIOUSBHostSendDeviceRequest(IOUSBHostObject *object, NSTimeInterval timeout, NSError *_Nullable *_Nullable error); +typedef void (^DoryIOUSBHostCompletionHandler)(IOReturn status, NSUInteger bytesTransferred); + +BOOL DoryIOUSBHostEnqueueDeviceRequest(IOUSBHostObject *object, + IOUSBDeviceRequest request, + NSMutableData *_Nullable data, + NSTimeInterval timeout, + NSError *_Nullable *_Nullable error, + DoryIOUSBHostCompletionHandler completionHandler); + BOOL DoryIOUSBHostAbortDeviceRequests(IOUSBHostObject *object, IOUSBHostAbortOption option, NSError *_Nullable *_Nullable error); diff --git a/Packages/ContainerizationEngine/Sources/DoryRendererWorkerContracts/Exports.swift b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerContracts/Exports.swift new file mode 100644 index 00000000..a33c731c --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerContracts/Exports.swift @@ -0,0 +1,3 @@ +// Preserve the public package/Xcode product identity while the daemon and runner share one +// implementation of the renderer bootstrap, receipt, command, and XPC wire protocol. +@_exported import DoryRendererWorkerWireContracts diff --git a/Packages/ContainerizationEngine/Sources/DoryRendererWorkerMetalTransport/DoryRendererWorkerXPC.swift b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerMetalTransport/DoryRendererWorkerXPC.swift new file mode 100644 index 00000000..8b9c4425 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerMetalTransport/DoryRendererWorkerXPC.swift @@ -0,0 +1,54 @@ +import DoryRendererWorkerContracts +import Foundation +import Metal + +/// Complete Objective-C/XPC surface for one renderer generation. Exact binary frames and bounded +/// `FileHandle` arrays remain the control/data plane; the only Objective-C graphics object admitted +/// is Metal's secure-coding cross-process texture handle. A foreign renderer pointer never crosses +/// process identity. +@objc(DoryRendererWorkerXPCProtocol) +public protocol DoryRendererWorkerXPCProtocol: NSObjectProtocol { + func bootstrap(_ request: Data, withReply reply: @escaping (Data) -> Void) + func exchange( + _ frame: Data, + descriptors: [FileHandle], + withReply reply: @escaping (Data, [FileHandle], MTLSharedTextureHandle?) -> Void + ) +} + +/// Constructs the single transport interface used by both authenticated peers. Explicit class +/// allowlists prevent Foundation from widening either descriptor or Metal-handle authority. +public enum DoryRendererWorkerXPCInterface { + public static func make() -> NSXPCInterface { + let interface = NSXPCInterface(with: DoryRendererWorkerXPCProtocol.self) + let descriptorClasses = NSSet( + objects: NSArray.self, + FileHandle.self + ) as! Set + let textureHandleClasses = NSSet( + objects: MTLSharedTextureHandle.self + ) as! Set + let exchangeSelector = #selector( + DoryRendererWorkerXPCProtocol.exchange(_:descriptors:withReply:) + ) + interface.setClasses( + descriptorClasses, + for: exchangeSelector, + argumentIndex: 1, + ofReply: false + ) + interface.setClasses( + descriptorClasses, + for: exchangeSelector, + argumentIndex: 1, + ofReply: true + ) + interface.setClasses( + textureHandleClasses, + for: exchangeSelector, + argumentIndex: 2, + ofReply: true + ) + return interface + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryRendererWorkerServiceCore/DoryRendererWorkerService.swift b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerServiceCore/DoryRendererWorkerService.swift new file mode 100644 index 00000000..0bef065b --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerServiceCore/DoryRendererWorkerService.swift @@ -0,0 +1,479 @@ +import Darwin +import DoryGuestMemoryShim +import DoryRendererWorkerContracts +import DoryRendererWorkerMetalTransport +import Foundation +import Metal + +public enum DoryRendererWorkerBackendExecution: @unchecked Sendable { + case success( + payload: Data, + descriptors: [FileHandle], + sharedTextureHandle: MTLSharedTextureHandle? = nil + ) + case rejected + case outcomeUnknown +} + +/// Typed, path-free boundary between the foreign renderer backend and the XPC service. Production +/// implementations must collapse implementation details into one of these audited stages; the +/// service never serializes an arbitrary `Error` or foreign-library string. +public enum DoryRendererWorkerBackendActivationError: Error, Equatable, Sendable { + case artifactAuthority + case rendererInitialization + case venusCapability + case venusContext + case virgl2Capability + case virgl2Context + case sharedMemoryExport + case fenceExport + case capabilityReceipt + + var failureCode: DoryRendererWorkerRPCFailureCode { + switch self { + case .artifactAuthority: + .bootstrapArtifactAuthorityFailed + case .rendererInitialization: + .bootstrapRendererInitializationFailed + case .venusCapability: + .bootstrapVenusCapabilityFailed + case .venusContext: + .bootstrapVenusContextFailed + case .virgl2Capability: + .bootstrapVirgl2CapabilityFailed + case .virgl2Context: + .bootstrapVirgl2ContextFailed + case .sharedMemoryExport: + .bootstrapSharedMemoryExportFailed + case .fenceExport: + .bootstrapFenceExportFailed + case .capabilityReceipt: + .bootstrapCapabilityReceiptFailed + } + } +} + +/// Foreign-renderer adapter owned exclusively by the worker process. `execute` is a bounded +/// admission call: it may enqueue descriptor-backed work but may never synchronously wait for GPU +/// completion or copy a command stream/frame into XPC Data. Implementations may not retain an +/// input FileHandle beyond the call unless they duplicate it and make that lifetime part of the +/// resource generation they own. +public protocol DoryRendererWorkerBackend: AnyObject, Sendable { + func activate( + bootstrap: DoryRendererWorkerBootstrap + ) throws -> DoryRendererCapabilityReceipt + func execute( + command: DoryRendererWorkerCommand, + descriptors: [FileHandle] + ) throws -> DoryRendererWorkerBackendExecution + func invalidate() +} + +/// Safe default for a packaged service before the audited foreign backend is linked. It returns an +/// authenticated diagnostic receipt with no acceleration claim; the service immediately closes +/// command admission. This executable can therefore never make a software or legacy renderer look +/// like the requested production tuple. +public final class DoryRendererWorkerFailClosedBackend: + DoryRendererWorkerBackend, + @unchecked Sendable +{ + public init() {} + + public func activate( + bootstrap: DoryRendererWorkerBootstrap + ) throws -> DoryRendererCapabilityReceipt { + try DoryRendererCapabilityReceipt( + accepting: bootstrap, + features: [.isolatedSignedWorker], + capsets: [] + ) + } + + public func execute( + command _: DoryRendererWorkerCommand, + descriptors _: [FileHandle] + ) throws -> DoryRendererWorkerBackendExecution { + .rejected + } + + public func invalidate() {} +} + +/// One-shot, one-workspace service state machine. Malformed runner traffic, an uncertain backend +/// outcome, or an incomplete activation receipt permanently revokes this process generation. +public final class DoryRendererWorkerService: @unchecked Sendable { + private enum State { + case awaitingBootstrap + case bootstrapping + case active(DoryRendererWorkerBootstrap) + case failed + } + + private enum DescriptorIdentity: Hashable { + case filesystem(device: UInt64, inode: UInt64) + case guestMemory(Data) + } + + private enum ExchangeAdmission { + case admitted(DoryRendererWorkerBootstrap) + case rejected(DoryRendererWorkerRPCFailureCode) + } + + private struct MutableMetrics { + var xpcBatchCount: UInt64 = 0 + var xpcControlBytes: UInt64 = 0 + var descriptorBackedCommandBytes: UInt64 = 0 + var totalAdmissionLatencyNanoseconds: UInt64 = 0 + var maximumAdmissionLatencyNanoseconds: UInt64 = 0 + var maximumQueueDepth = 0 + var backpressureRejections: UInt64 = 0 + var replayRejections: UInt64 = 0 + } + + private let backend: any DoryRendererWorkerBackend + private let lock = NSLock() + private let executionQueue = DispatchQueue( + label: "dev.dory.renderer-worker.admission", + qos: .userInteractive + ) + private var state: State = .awaitingBootstrap + private var pendingCommands = 0 + private var metrics = MutableMetrics() + /// Accessed only on executionQueue. Strict increase gives replay protection without an + /// attacker-controlled, generation-long Set of request identities. + private var highestAdmittedRequestID: UInt64 = 0 + + public init(backend: any DoryRendererWorkerBackend) { + self.backend = backend + } + + public func bootstrap(exactBytes: Data) -> Data { + let claimed = lock.withLock { + guard case .awaitingBootstrap = state else { return false } + state = .bootstrapping + return true + } + guard claimed else { + return failure(.bootstrapAlreadyAttempted) + } + let bootstrap: DoryRendererWorkerBootstrap + do { + bootstrap = try DoryRendererWorkerBootstrapCodec.decode(exactBytes) + } catch { + failGeneration() + return failure(.invalidEnvelope) + } + do { + let receipt: DoryRendererCapabilityReceipt + do { + receipt = try backend.activate(bootstrap: bootstrap) + } catch let error as DoryRendererWorkerBackendActivationError { + throw error + } catch { + throw DoryRendererWorkerBackendActivationError.capabilityReceipt + } + let receiptBytes = DoryRendererCapabilityReceiptCodec.encode(receipt) + do { + _ = try DoryRendererCapabilityReceiptCodec.decode( + receiptBytes, + accepting: bootstrap + ) + } catch { + throw DoryRendererWorkerBackendActivationError.capabilityReceipt + } + guard receipt.productionAccelerationIsAdmissible else { + failGeneration() + return try DoryRendererWorkerRPCResultCodec.encode( + .success(payload: receiptBytes, descriptorCount: 0) + ) + } + highestAdmittedRequestID = 0 + lock.withLock { state = .active(bootstrap) } + return try DoryRendererWorkerRPCResultCodec.encode( + .success(payload: receiptBytes, descriptorCount: 0) + ) + } catch let error as DoryRendererWorkerBackendActivationError { + failGeneration() + return failure(error.failureCode) + } catch { + failGeneration() + return failure(.bootstrapRejected) + } + } + + public func exchange( + exactFrame: Data, + descriptors: [FileHandle] + ) -> ( + result: Data, + descriptors: [FileHandle], + sharedTextureHandle: MTLSharedTextureHandle? + ) { + let enqueuedAt = DispatchTime.now().uptimeNanoseconds + let admission = reserveExchange(controlByteCount: exactFrame.count) + guard case .admitted(let bootstrap) = admission else { + guard case .rejected(let code) = admission else { + return (failure(.internalFailure), [], nil) + } + return (failure(code), [], nil) + } + defer { releaseExchange() } + return executionQueue.sync { + recordAdmissionLatency(since: enqueuedAt) + return executeAdmitted( + exactFrame: exactFrame, + descriptors: descriptors, + bootstrap: bootstrap + ) + } + } + + public func metricsSnapshot() -> DoryRendererWorkerServiceMetrics { + lock.withLock { + DoryRendererWorkerServiceMetrics( + xpcBatchCount: metrics.xpcBatchCount, + xpcControlBytes: metrics.xpcControlBytes, + descriptorBackedCommandBytes: metrics.descriptorBackedCommandBytes, + totalAdmissionLatencyNanoseconds: metrics.totalAdmissionLatencyNanoseconds, + maximumAdmissionLatencyNanoseconds: metrics.maximumAdmissionLatencyNanoseconds, + currentQueueDepth: pendingCommands, + maximumQueueDepth: metrics.maximumQueueDepth, + backpressureRejections: metrics.backpressureRejections, + replayRejections: metrics.replayRejections, + scanoutCopyBytes: 0 + ) + } + } + + public func invalidate() { + executionQueue.sync { failGeneration() } + } + + private func executeAdmitted( + exactFrame: Data, + descriptors: [FileHandle], + bootstrap: DoryRendererWorkerBootstrap + ) -> ( + result: Data, + descriptors: [FileHandle], + sharedTextureHandle: MTLSharedTextureHandle? + ) { + guard case .active(let current) = lock.withLock({ state }), + current.generation == bootstrap.generation else { + return (failure(.capabilityUnavailable), [], nil) + } + do { + let command = try DoryRendererWorkerCommandCodec.decode( + exactFrame, + limits: bootstrap.limits + ) + guard command.generation == bootstrap.generation else { + return (failure(.staleGeneration), [], nil) + } + guard command.deadlineUptimeNanoseconds > DispatchTime.now().uptimeNanoseconds else { + return (failure(.deadlineExpired), [], nil) + } + try command.validateOutOfBandDescriptorCount(descriptors.count) + try validateDescriptors(descriptors, references: command.sharedRegions) + guard command.requestID > highestAdmittedRequestID else { + recordReplayRejection() + failGeneration() + return (failure(.protocolViolation), [], nil) + } + highestAdmittedRequestID = command.requestID + if command.operation == .submit3D { + recordDescriptorBackedCommandBytes(command.sharedRegions[0].length) + } + switch try backend.execute(command: command, descriptors: descriptors) { + case let .success(payload, replyDescriptors, sharedTextureHandle): + guard replyDescriptors.count <= Int(UInt16.max) else { + failGeneration() + return (failure(.protocolViolation), [], nil) + } + guard sharedTextureHandle == nil || ( + command.operation == .acquireScanoutLease && replyDescriptors.isEmpty + ) else { + failGeneration() + return (failure(.protocolViolation), [], nil) + } + let frame = try DoryRendererWorkerRPCResultCodec.encode( + .success( + payload: payload, + descriptorCount: UInt16(replyDescriptors.count) + ), + maximumPayloadBytes: bootstrap.limits.maximumCommandBytes + ) + return (frame, replyDescriptors, sharedTextureHandle) + case .rejected: + return (failure(.commandRejected), [], nil) + case .outcomeUnknown: + failGeneration() + return (failure(.outcomeUnknown), [], nil) + } + } catch { + failGeneration() + return (failure(.protocolViolation), [], nil) + } + } + + private func reserveExchange(controlByteCount: Int) -> ExchangeAdmission { + lock.withLock { + switch state { + case .awaitingBootstrap, .bootstrapping: + return .rejected(.bootstrapRequired) + case .failed: + return .rejected(.capabilityUnavailable) + case .active(let bootstrap): + guard pendingCommands < bootstrap.limits.maximumInFlightCommands else { + metrics.backpressureRejections = Self.saturatingAdd( + metrics.backpressureRejections, + 1 + ) + return .rejected(.resourceExhausted) + } + pendingCommands += 1 + metrics.maximumQueueDepth = max(metrics.maximumQueueDepth, pendingCommands) + metrics.xpcBatchCount = Self.saturatingAdd(metrics.xpcBatchCount, 1) + metrics.xpcControlBytes = Self.saturatingAdd( + metrics.xpcControlBytes, + UInt64(controlByteCount) + ) + return .admitted(bootstrap) + } + } + } + + private func releaseExchange() { + lock.withLock { + if pendingCommands > 0 { pendingCommands -= 1 } + } + } + + private func recordAdmissionLatency(since start: UInt64) { + let now = DispatchTime.now().uptimeNanoseconds + let latency = now >= start ? now - start : 0 + lock.withLock { + metrics.totalAdmissionLatencyNanoseconds = Self.saturatingAdd( + metrics.totalAdmissionLatencyNanoseconds, + latency + ) + metrics.maximumAdmissionLatencyNanoseconds = max( + metrics.maximumAdmissionLatencyNanoseconds, + latency + ) + } + } + + private func recordDescriptorBackedCommandBytes(_ byteCount: UInt64) { + lock.withLock { + metrics.descriptorBackedCommandBytes = Self.saturatingAdd( + metrics.descriptorBackedCommandBytes, + byteCount + ) + } + } + + private func recordReplayRejection() { + lock.withLock { + metrics.replayRejections = Self.saturatingAdd(metrics.replayRejections, 1) + } + } + + private static func saturatingAdd(_ lhs: UInt64, _ rhs: UInt64) -> UInt64 { + let (sum, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? UInt64.max : sum + } + + private func validateDescriptors( + _ descriptors: [FileHandle], + references: [DoryRendererSharedRegionReference] + ) throws { + var identities = Set() + let metadata = Dictionary(grouping: references, by: \.descriptorIndex) + for (index, descriptor) in descriptors.enumerated() { + guard let descriptorReferences = metadata[UInt16(index)], + let reference = descriptorReferences.first, + descriptorReferences.allSatisfy({ + $0.declaredFileSize == reference.declaredFileSize + }) else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + let fd = descriptor.fileDescriptor + guard fd >= 0 else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + var status = stat() + guard fstat(fd, &status) == 0, + status.st_nlink == 0, + status.st_size >= 0, + UInt64(status.st_size) == reference.declaredFileSize else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + let descriptorIdentity: DescriptorIdentity + switch status.st_mode & S_IFMT { + case S_IFREG: + descriptorIdentity = .filesystem( + device: UInt64(status.st_dev), + inode: UInt64(status.st_ino) + ) + case 0: + guard descriptorReferences.allSatisfy({ + $0.offset >= DoryGuestMemoryBackingDataOffset() + }) else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + var identity = DoryGuestMemoryBackingIdentity() + guard DoryReadGuestMemoryBackingIdentity( + fd, + reference.declaredFileSize, + &identity + ) == 1 else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + descriptorIdentity = .guestMemory( + withUnsafeBytes(of: &identity) { Data($0) } + ) + default: + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + let openFlags = fcntl(fd, F_GETFL) + guard openFlags >= 0 else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + let accessMode = openFlags & O_ACCMODE + switch reference.access { + case .readOnly: + guard accessMode == O_RDONLY else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + case .readWrite: + guard accessMode == O_RDWR else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + } + let descriptorFlags = fcntl(fd, F_GETFD) + guard descriptorFlags >= 0, + fcntl(fd, F_SETFD, descriptorFlags | FD_CLOEXEC) == 0 else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + guard identities.insert(descriptorIdentity).inserted else { + throw DoryRendererWorkerContractError.duplicateSharedRegionIdentity + } + } + } + + private func failGeneration() { + let shouldInvalidate = lock.withLock { + guard case .failed = state else { + state = .failed + return true + } + return false + } + if shouldInvalidate { backend.invalidate() } + } + + private func failure(_ code: DoryRendererWorkerRPCFailureCode) -> Data { + (try? DoryRendererWorkerRPCResultCodec.encode(.failure(code))) ?? Data() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryRendererWorkerServiceCore/DoryRendererWorkerServiceMetrics.swift b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerServiceCore/DoryRendererWorkerServiceMetrics.swift new file mode 100644 index 00000000..dbc8242d --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerServiceCore/DoryRendererWorkerServiceMetrics.swift @@ -0,0 +1,40 @@ +/// Monotonic worker-boundary evidence. Command-stream bytes count descriptor-backed submit3D +/// regions, while XPC control bytes count only the bounded metadata frame. Accelerated scanout is +/// inadmissible if a later backend ever records a copied byte; the current service has no API that +/// can turn a copy into a production capability. +public struct DoryRendererWorkerServiceMetrics: Equatable, Sendable { + public let xpcBatchCount: UInt64 + public let xpcControlBytes: UInt64 + public let descriptorBackedCommandBytes: UInt64 + public let totalAdmissionLatencyNanoseconds: UInt64 + public let maximumAdmissionLatencyNanoseconds: UInt64 + public let currentQueueDepth: Int + public let maximumQueueDepth: Int + public let backpressureRejections: UInt64 + public let replayRejections: UInt64 + public let scanoutCopyBytes: UInt64 + + public init( + xpcBatchCount: UInt64, + xpcControlBytes: UInt64, + descriptorBackedCommandBytes: UInt64, + totalAdmissionLatencyNanoseconds: UInt64, + maximumAdmissionLatencyNanoseconds: UInt64, + currentQueueDepth: Int, + maximumQueueDepth: Int, + backpressureRejections: UInt64, + replayRejections: UInt64, + scanoutCopyBytes: UInt64 + ) { + self.xpcBatchCount = xpcBatchCount + self.xpcControlBytes = xpcControlBytes + self.descriptorBackedCommandBytes = descriptorBackedCommandBytes + self.totalAdmissionLatencyNanoseconds = totalAdmissionLatencyNanoseconds + self.maximumAdmissionLatencyNanoseconds = maximumAdmissionLatencyNanoseconds + self.currentQueueDepth = currentQueueDepth + self.maximumQueueDepth = maximumQueueDepth + self.backpressureRejections = backpressureRejections + self.replayRejections = replayRejections + self.scanoutCopyBytes = scanoutCopyBytes + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererForeignSession.swift b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererForeignSession.swift new file mode 100644 index 00000000..5da41a11 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererForeignSession.swift @@ -0,0 +1,731 @@ +import Darwin +import DoryRendererWorkerContracts +import DoryVirglRendererShim +import Foundation +import Metal + +public enum DoryRendererForeignSessionError: Error, Equatable, Sendable { + case openFailed(Int32) + case callFailed(operation: String, status: Int32) + case submitFailed(status: Int32, diagnostic: DoryRendererForeignSubmitFailure?) + case invalidResult(operation: String) +} + +public enum DoryRendererCreateObjectSubtypeDisposition: UInt32, Equatable, Sendable { + case absent = 0 + case present = 1 + case ambiguous = 2 +} + +/// Exact pinned `virgl_object_type` ordinals accepted from a CREATE_OBJECT command header. +public enum DoryRendererVirglObjectType: UInt32, Equatable, Sendable { + case null = 0 + case blend = 1 + case rasterizer = 2 + case depthStencilAlpha = 3 + case shader = 4 + case vertexElements = 5 + case samplerView = 6 + case samplerState = 7 + case surface = 8 + case query = 9 + case streamOutputTarget = 10 + case multisampleSurface = 11 +} + +/// Closed validation reasons emitted only for a failed pinned VirGL surface CREATE_OBJECT. +public enum DoryRendererVirglSurfaceFailureReason: UInt32, Equatable, Sendable { + case none = 0 + case createPayloadTooShort = 1 + case createHandleZero = 2 + case surfaceLengthMismatch = 3 + case resourceMissing = 4 + case resourceGLObjectMissing = 5 + case formatOutOfRange = 6 + case invalidLayerRange = 7 +} + +/// Closed categories admitted from fixed vrend error prefixes. No suffix or renderer text survives. +public enum DoryRendererVirglSubmitPrecursorCategory: UInt32, Equatable, Sendable { + case none = 0 + case shaderCompileFailed = 1 + case tgsiAssignmentFailed = 2 + case geometryShaderUnsupported = 3 + case tessellationShaderUnsupported = 4 + case computeShaderUnsupported = 5 + case invalidExpectedTokenCount = 6 + case expectedLongContinuation = 7 + case invalidContinuationHandle = 8 + case continuationWithoutOriginal = 9 + case mismatchedContinuation = 10 + case oversizedContinuation = 11 +} + +/// Sanitized vrend submit failure captured by the C shim. The command identifier is admitted only +/// after matching the complete name in the pinned static command table. CREATE_OBJECT subtype comes +/// only from the bounded command-header parser; renderer log text and command payloads are discarded. +public struct DoryRendererForeignSubmitFailure: Equatable, Sendable { + public let decoderDiagnosticIsAvailable: Bool + public let contextID: UInt32 + public let commandID: UInt32 + public let status: Int32 + public let createObjectSubtypeDisposition: DoryRendererCreateObjectSubtypeDisposition + public let createObjectSubtype: DoryRendererVirglObjectType? + /// Saturating count of CREATE_OBJECT headers (0...255), never a command-stream length. + public let createObjectCandidateCount: UInt32 + /// Closed low-12-bit set of pinned object subtypes observed in CREATE_OBJECT headers. + public let createObjectSubtypeMask: UInt32 + public let surfaceFailureReason: DoryRendererVirglSurfaceFailureReason + public let precursorCategory: DoryRendererVirglSubmitPrecursorCategory + + public init( + contextID: UInt32, + commandID: UInt32, + status: Int32, + createObjectSubtypeDisposition: DoryRendererCreateObjectSubtypeDisposition = .absent, + createObjectSubtype: DoryRendererVirglObjectType? = nil, + createObjectCandidateCount: UInt32 = 0, + createObjectSubtypeMask: UInt32 = 0, + precursorCategory: DoryRendererVirglSubmitPrecursorCategory = .none + ) { + self.init( + decoderDiagnosticIsAvailable: true, + failedCommandLocationIsExact: true, + contextID: contextID, + commandID: commandID, + status: status, + createObjectSubtypeDisposition: createObjectSubtypeDisposition, + createObjectSubtype: createObjectSubtype, + createObjectCandidateCount: createObjectCandidateCount == 0 + ? (createObjectSubtype == nil ? 0 : 1) + : createObjectCandidateCount, + createObjectSubtypeMask: createObjectSubtypeMask == 0 + ? createObjectSubtype.map { 1 << $0.rawValue } ?? 0 + : createObjectSubtypeMask, + // Only the C shim's exact-tuple sanitizer may admit a surface reason. + surfaceFailureReason: .none, + precursorCategory: precursorCategory + ) + } + + private init( + decoderDiagnosticIsAvailable: Bool, + failedCommandLocationIsExact: Bool, + contextID: UInt32, + commandID: UInt32, + status: Int32, + createObjectSubtypeDisposition: DoryRendererCreateObjectSubtypeDisposition, + createObjectSubtype: DoryRendererVirglObjectType?, + createObjectCandidateCount: UInt32, + createObjectSubtypeMask: UInt32, + surfaceFailureReason: DoryRendererVirglSurfaceFailureReason, + precursorCategory: DoryRendererVirglSubmitPrecursorCategory + ) { + self.decoderDiagnosticIsAvailable = decoderDiagnosticIsAvailable + self.contextID = decoderDiagnosticIsAvailable ? contextID : 0 + self.commandID = decoderDiagnosticIsAvailable ? commandID : 0 + self.status = decoderDiagnosticIsAvailable ? status : 0 + if decoderDiagnosticIsAvailable && commandID == 1 { + let candidateSummaryIsValid = createObjectCandidateCount <= 255 && + createObjectSubtypeMask & ~0x0fff == 0 + if createObjectSubtypeDisposition == .present, + let createObjectSubtype, + candidateSummaryIsValid, + createObjectCandidateCount > 0, + createObjectSubtypeMask & (1 << createObjectSubtype.rawValue) != 0 { + self.createObjectSubtypeDisposition = .present + self.createObjectSubtype = createObjectSubtype + } else { + self.createObjectSubtypeDisposition = + createObjectSubtypeDisposition == .present || !candidateSummaryIsValid + ? .ambiguous + : createObjectSubtypeDisposition + self.createObjectSubtype = nil + } + self.createObjectCandidateCount = candidateSummaryIsValid + ? createObjectCandidateCount + : 0 + self.createObjectSubtypeMask = candidateSummaryIsValid + ? createObjectSubtypeMask + : 0 + } else { + self.createObjectSubtypeDisposition = .absent + self.createObjectSubtype = nil + self.createObjectCandidateCount = 0 + self.createObjectSubtypeMask = 0 + } + self.surfaceFailureReason = decoderDiagnosticIsAvailable && + failedCommandLocationIsExact && + commandID == 1 && status == EINVAL && self.createObjectSubtype == .surface + ? surfaceFailureReason + : .none + self.precursorCategory = precursorCategory + } + + init?(sanitizing diagnostic: DoryVirglRendererSubmitDiagnostic) { + let decoderDiagnosticIsAvailable = diagnostic.valid == 1 + let precursorCategory = DoryRendererVirglSubmitPrecursorCategory( + rawValue: diagnostic.precursor_category + ) ?? .none + guard decoderDiagnosticIsAvailable || precursorCategory != .none else { return nil } + + let rawDisposition = DoryRendererCreateObjectSubtypeDisposition( + rawValue: diagnostic.create_object_subtype_disposition + ) ?? .ambiguous + let objectType = rawDisposition == .present + ? DoryRendererVirglObjectType(rawValue: diagnostic.create_object_subtype) + : nil + let surfaceFailureReason = DoryRendererVirglSurfaceFailureReason( + rawValue: diagnostic.surface_failure_reason + ) ?? .none + self.init( + decoderDiagnosticIsAvailable: decoderDiagnosticIsAvailable, + failedCommandLocationIsExact: + diagnostic.failed_command_location_disposition == 1, + contextID: diagnostic.context_id, + commandID: diagnostic.command_id, + status: diagnostic.status, + createObjectSubtypeDisposition: rawDisposition, + createObjectSubtype: objectType, + createObjectCandidateCount: diagnostic.create_object_candidate_count, + createObjectSubtypeMask: diagnostic.create_object_subtype_mask, + surfaceFailureReason: surfaceFailureReason, + precursorCategory: precursorCategory + ) + } +} + +public struct DoryRendererForeignCapset: Equatable, Sendable { + public let id: UInt32 + public let maximumVersion: UInt32 + public let bytes: Data + + public init(id: UInt32, maximumVersion: UInt32, bytes: Data) { + self.id = id + self.maximumVersion = maximumVersion + self.bytes = bytes + } +} + +public struct DoryRendererForeignBlobCreate: Equatable, Sendable { + public let resourceID: UInt32 + public let contextID: UInt32 + public let payload: DoryRendererBlobCreatePayload + + public init( + resourceID: UInt32, + contextID: UInt32, + payload: DoryRendererBlobCreatePayload + ) { + self.resourceID = resourceID + self.contextID = contextID + self.payload = payload + } +} + +public struct DoryRendererForeignResource3DCreate: Equatable, Sendable { + public let resourceID: UInt32 + public let payload: DoryRendererResource3DCreatePayload + + public init(resourceID: UInt32, payload: DoryRendererResource3DCreatePayload) { + self.resourceID = resourceID + self.payload = payload + } +} + +public struct DoryRendererForeignResourceInfo: Equatable, Sendable { + public let resourceID: UInt32 + public let format: UInt32 + public let width: UInt32 + public let height: UInt32 + public let flags: UInt32 + public let stride: UInt32 + + public init( + resourceID: UInt32, + format: UInt32, + width: UInt32, + height: UInt32, + flags: UInt32, + stride: UInt32 + ) { + self.resourceID = resourceID + self.format = format + self.width = width + self.height = height + self.flags = flags + self.stride = stride + } +} + +public struct DoryRendererForeignExportedBlob: Sendable { + public let type: UInt32 + public let ownedFileDescriptor: Int32 + + public init(type: UInt32, ownedFileDescriptor: Int32) { + self.type = type + self.ownedFileDescriptor = ownedFileDescriptor + } +} + +public protocol DoryRendererForeignSession: AnyObject, Sendable { + func capset(id: UInt32) throws -> DoryRendererForeignCapset + func createContext(id: UInt32, capsetID: UInt32, name: String) throws + func destroyContext(id: UInt32) + func attachResource(contextID: UInt32, resourceID: UInt32) + func detachResource(contextID: UInt32, resourceID: UInt32) + func submit(contextID: UInt32, bytes: UnsafeRawPointer, dwordCount: UInt32) throws + func createBlob( + _ resource: DoryRendererForeignBlobCreate, + iovecs: UnsafePointer?, + iovecCount: UInt32 + ) throws + func createResource3D(_ resource: DoryRendererForeignResource3DCreate) throws + func attachBacking( + resourceID: UInt32, + iovecs: UnsafePointer, + iovecCount: UInt32 + ) throws + func detachBacking(resourceID: UInt32) + func unrefResource(id: UInt32) + func mapInfo(resourceID: UInt32) throws -> UInt32 + func exportBlob(resourceID: UInt32) throws -> DoryRendererForeignExportedBlob + func resourceInfo(resourceID: UInt32) throws -> DoryRendererForeignResourceInfo + func acquireScanoutMetalTexture( + resourceID: UInt32, + width: UInt32, + height: UInt32, + virglFormat: UInt32, + stride: UInt32, + offset: UInt32 + ) throws -> any MTLTexture + func transfer( + toHost: Bool, + resourceID: UInt32, + contextID: UInt32, + payload: DoryRendererTransfer3DPayload, + iovecs: UnsafePointer?, + iovecCount: UInt32 + ) throws + func createFence( + contextID: UInt32, + flags: UInt32, + ringIndex: UInt32, + fenceID: UInt64 + ) throws + /// Creates a classic VirGL ctx0 fence. The shim owns the lossless mapping from this guest + /// 64-bit identity to virglrenderer's collision-safe 32-bit callback token. + func createGlobalFence(fenceID: UInt64) throws + func exportFence(fenceID: UInt64) throws -> Int32 + /// Borrowed renderer event descriptor when threaded sync is available. Darwin may legitimately + /// strip that hint; nil selects the backend's bounded timer pump. The backend never closes a + /// returned descriptor; `invalidate` releases the underlying renderer authority. + func pollDescriptor() throws -> Int32? + func poll() + func invalidate() +} + +public protocol DoryRendererForeignSessionCreating: Sendable { + func create( + attestation: DoryRendererArtifactAttestation + ) throws -> any DoryRendererForeignSession +} + +public struct DoryRendererCForeignSessionFactory: + DoryRendererForeignSessionCreating, + Sendable +{ + public init() {} + + public func create( + attestation _: DoryRendererArtifactAttestation + ) throws -> any DoryRendererForeignSession { + try DoryRendererCForeignSession() + } +} + +private final class DoryRendererCForeignSession: + DoryRendererForeignSession, + @unchecked Sendable +{ + private var session: OpaquePointer? + + init() throws { + var opened: OpaquePointer? + let status = DoryVirglRendererSessionCreate(&opened) + guard status == 0, let opened else { + throw DoryRendererForeignSessionError.openFailed(status) + } + session = opened + } + + deinit { invalidate() } + + func capset(id: UInt32) throws -> DoryRendererForeignCapset { + let session = try requiredSession() + var maximumVersion: UInt32 = 0 + var byteCount = 0 + try Self.check( + DoryVirglRendererGetCapset( + session, + id, + &maximumVersion, + nil, + 0, + &byteCount + ), + "virgl_renderer_get_cap_set" + ) + guard byteCount > 0, + byteCount <= DoryRendererCapsetAttestation.maximumCapsetBytes else { + throw DoryRendererForeignSessionError.invalidResult(operation: "capset-size") + } + var data = Data(count: byteCount) + let status = data.withUnsafeMutableBytes { + DoryVirglRendererGetCapset( + session, + id, + &maximumVersion, + $0.baseAddress, + $0.count, + &byteCount + ) + } + try Self.check(status, "virgl_renderer_fill_caps") + guard data.count == byteCount else { + throw DoryRendererForeignSessionError.invalidResult(operation: "capset-size") + } + return DoryRendererForeignCapset( + id: id, + maximumVersion: maximumVersion, + bytes: data + ) + } + + func createContext(id: UInt32, capsetID: UInt32, name: String) throws { + let session = try requiredSession() + let bytes = Array(name.utf8) + let status = bytes.withUnsafeBufferPointer { buffer in + DoryVirglRendererContextCreate( + session, + id, + capsetID, + buffer.baseAddress.map { UnsafeRawPointer($0).assumingMemoryBound(to: CChar.self) }, + buffer.count + ) + } + try Self.check(status, "virgl_renderer_context_create_with_flags") + } + + func destroyContext(id: UInt32) { + guard let session else { return } + DoryVirglRendererContextDestroy(session, id) + } + + func attachResource(contextID: UInt32, resourceID: UInt32) { + guard let session else { return } + DoryVirglRendererContextAttachResource(session, contextID, resourceID) + } + + func detachResource(contextID: UInt32, resourceID: UInt32) { + guard let session else { return } + DoryVirglRendererContextDetachResource(session, contextID, resourceID) + } + + func submit(contextID: UInt32, bytes: UnsafeRawPointer, dwordCount: UInt32) throws { + var diagnostic = DoryVirglRendererSubmitDiagnostic() + let status = DoryVirglRendererSubmit( + try requiredSession(), + contextID, + bytes, + dwordCount, + &diagnostic + ) + guard status == 0 else { + let failure = DoryRendererForeignSubmitFailure(sanitizing: diagnostic) + throw DoryRendererForeignSessionError.submitFailed( + status: status, + diagnostic: failure + ) + } + } + + func createBlob( + _ resource: DoryRendererForeignBlobCreate, + iovecs: UnsafePointer?, + iovecCount: UInt32 + ) throws { + var arguments = DoryVirglRendererBlobCreateArguments( + resource_handle: resource.resourceID, + context_id: resource.contextID, + blob_memory: resource.payload.blobMemory, + blob_flags: resource.payload.blobFlags, + blob_id: resource.payload.blobID, + size: resource.payload.size, + iovecs: iovecs, + iovec_count: iovecCount + ) + try Self.check( + DoryVirglRendererBlobCreate(try requiredSession(), &arguments), + "virgl_renderer_resource_create_blob" + ) + } + + func createResource3D(_ resource: DoryRendererForeignResource3DCreate) throws { + var arguments = DoryVirglRendererResource3DCreateArguments( + handle: resource.resourceID, + target: resource.payload.target, + format: resource.payload.format, + bind: resource.payload.bind, + width: resource.payload.width, + height: resource.payload.height, + depth: resource.payload.depth, + array_size: resource.payload.arraySize, + last_level: resource.payload.lastLevel, + samples: resource.payload.samples, + flags: resource.payload.flags + ) + try Self.check( + DoryVirglRendererResource3DCreate(try requiredSession(), &arguments), + "virgl_renderer_resource_create" + ) + } + + func attachBacking( + resourceID: UInt32, + iovecs: UnsafePointer, + iovecCount: UInt32 + ) throws { + try Self.check( + DoryVirglRendererResourceAttachBacking( + try requiredSession(), + resourceID, + iovecs, + iovecCount + ), + "virgl_renderer_resource_attach_iov" + ) + } + + func detachBacking(resourceID: UInt32) { + guard let session else { return } + DoryVirglRendererResourceDetachBacking(session, resourceID) + } + + func unrefResource(id: UInt32) { + guard let session else { return } + DoryVirglRendererResourceUnref(session, id) + } + + func mapInfo(resourceID: UInt32) throws -> UInt32 { + var mapInfo: UInt32 = 0 + try Self.check( + DoryVirglRendererResourceGetMapInfo( + try requiredSession(), + resourceID, + &mapInfo + ), + "virgl_renderer_resource_get_map_info" + ) + return mapInfo + } + + func exportBlob(resourceID: UInt32) throws -> DoryRendererForeignExportedBlob { + var type: UInt32 = 0 + var fileDescriptor: Int32 = -1 + try Self.check( + DoryVirglRendererResourceExportBlob( + try requiredSession(), + resourceID, + &type, + &fileDescriptor + ), + "virgl_renderer_resource_export_blob" + ) + guard fileDescriptor >= 0 else { + throw DoryRendererForeignSessionError.invalidResult(operation: "export-blob-fd") + } + return DoryRendererForeignExportedBlob( + type: type, + ownedFileDescriptor: fileDescriptor + ) + } + + func resourceInfo(resourceID: UInt32) throws -> DoryRendererForeignResourceInfo { + var info = DoryVirglRendererResourceInfo() + try Self.check( + DoryVirglRendererResourceGetInfo( + try requiredSession(), + resourceID, + &info + ), + "virgl_renderer_resource_get_info" + ) + return DoryRendererForeignResourceInfo( + resourceID: info.handle, + format: info.virgl_format, + width: info.width, + height: info.height, + flags: info.flags, + stride: info.stride + ) + } + + func acquireScanoutMetalTexture( + resourceID: UInt32, + width: UInt32, + height: UInt32, + virglFormat: UInt32, + stride: UInt32, + offset: UInt32 + ) throws -> any MTLTexture { + var retainedTexture: UnsafeMutableRawPointer? + try Self.check( + DoryVirglRendererResourceAcquireScanoutMetalTexture( + try requiredSession(), + resourceID, + width, + height, + virglFormat, + stride, + offset, + &retainedTexture + ), + "virgl_renderer_create_handle_for_scanout" + ) + guard let retainedTexture else { + throw DoryRendererForeignSessionError.invalidResult( + operation: "metal-scanout-texture" + ) + } + let object = Unmanaged.fromOpaque(retainedTexture).takeRetainedValue() + guard let texture = object as? any MTLTexture else { + throw DoryRendererForeignSessionError.invalidResult( + operation: "metal-scanout-texture-type" + ) + } + return texture + } + + func transfer( + toHost: Bool, + resourceID: UInt32, + contextID: UInt32, + payload: DoryRendererTransfer3DPayload, + iovecs: UnsafePointer?, + iovecCount: UInt32 + ) throws { + var box = DoryVirglRendererBox( + x: payload.x, + y: payload.y, + z: payload.z, + width: payload.width, + height: payload.height, + depth: payload.depth + ) + let status = toHost + ? DoryVirglRendererTransferToHost( + try requiredSession(), + resourceID, + contextID, + payload.level, + payload.stride, + payload.layerStride, + &box, + payload.offset, + iovecs, + iovecCount + ) + : DoryVirglRendererTransferFromHost( + try requiredSession(), + resourceID, + contextID, + payload.level, + payload.stride, + payload.layerStride, + &box, + payload.offset, + iovecs, + iovecCount + ) + try Self.check( + status, + toHost ? "virgl_renderer_transfer_write_iov" : "virgl_renderer_transfer_read_iov" + ) + } + + func createFence( + contextID: UInt32, + flags: UInt32, + ringIndex: UInt32, + fenceID: UInt64 + ) throws { + try Self.check( + DoryVirglRendererCreateContextFence( + try requiredSession(), + contextID, + flags, + ringIndex, + fenceID + ), + "virgl_renderer_context_create_fence" + ) + } + + func createGlobalFence(fenceID: UInt64) throws { + try Self.check( + DoryVirglRendererCreateGlobalFence( + try requiredSession(), + fenceID + ), + "virgl_renderer_create_fence" + ) + } + + func exportFence(fenceID: UInt64) throws -> Int32 { + let descriptor = DoryVirglRendererGetFenceFileDescriptor( + try requiredSession(), + fenceID + ) + guard descriptor >= 0 else { + throw DoryRendererForeignSessionError.invalidResult(operation: "export-fence-fd") + } + return descriptor + } + + func pollDescriptor() throws -> Int32? { + let descriptor = DoryVirglRendererGetPollFileDescriptor(try requiredSession()) + guard descriptor >= 0 else { return nil } + guard fcntl(descriptor, F_GETFD) >= 0 else { + throw DoryRendererForeignSessionError.invalidResult(operation: "renderer-poll-fd") + } + return descriptor + } + + func poll() { + guard let session else { return } + DoryVirglRendererPoll(session) + } + + func invalidate() { + guard let existing = session else { return } + session = nil + DoryVirglRendererSessionDestroy(existing) + } + + private func requiredSession() throws -> OpaquePointer { + guard let session else { + throw DoryRendererForeignSessionError.invalidResult(operation: "closed-session") + } + return session + } + + private static func check(_ status: Int32, _ operation: String) throws { + guard status == 0 else { + throw DoryRendererForeignSessionError.callFailed( + operation: operation, + status: status + ) + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererProductionArtifactVerifier.swift b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererProductionArtifactVerifier.swift new file mode 100644 index 00000000..57152725 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererProductionArtifactVerifier.swift @@ -0,0 +1,256 @@ +import CryptoKit +import Darwin +import DoryRendererWorkerContracts +import Foundation + +public enum DoryRendererProductionArtifactError: Error, Equatable, Sendable { + case invalidBundleLayout + case artifactUnavailable(String) + case executableDigestMismatch +} + +/// Path-free proof that the running worker's exact executable bytes match the daemon-admitted +/// immutable bootstrap. The daemon and packager own canonical inventory verification; the +/// sandboxed worker owns only its signed XPC bundle and never reaches into the parent runner. +public struct DoryRendererArtifactAttestation: Equatable, Sendable { + public let candidateInventory: DoryRendererArtifactDigest + public let rendererWorkerExecutable: DoryRendererArtifactDigest + + public init( + candidateInventory: DoryRendererArtifactDigest, + rendererWorkerExecutable: DoryRendererArtifactDigest + ) { + self.candidateInventory = candidateInventory + self.rendererWorkerExecutable = rendererWorkerExecutable + } +} + +public protocol DoryRendererProductionArtifactVerifying: Sendable { + func verify( + bootstrap: DoryRendererWorkerBootstrap + ) throws -> DoryRendererArtifactAttestation +} + +/// Verifies the exact final worker executable. No path is read from XPC or the environment. Every +/// directory component is opened with `O_NOFOLLOW`; the worker is a bounded regular file whose +/// bytes match bootstrap authority minted from the already-verified candidate inventory. +public struct DoryRendererProductionArtifactVerifier: + DoryRendererProductionArtifactVerifying, + Sendable +{ + public static let maximumArtifactBytes = + DoryRendererProductionInventory.maximumArtifactBytes + public static let workerBundleExecutableRelativePath = "MacOS/DoryRendererWorker" + private let contentsRoot: URL + private let executableRelativePath: String + + /// Production constructor. The XPC bundle must be nested exactly at + /// `Runner.app/Contents/XPCServices/Worker.xpc`; a standalone SwiftPM executable has no + /// production artifact authority and therefore fails closed here. + public init(bundle: Bundle = .main) throws { + guard bundle.bundleURL.pathExtension == "xpc", + let executableURL = bundle.executableURL else { + throw DoryRendererProductionArtifactError.invalidBundleLayout + } + let xpcServices = bundle.bundleURL.deletingLastPathComponent() + guard xpcServices.lastPathComponent == "XPCServices" else { + throw DoryRendererProductionArtifactError.invalidBundleLayout + } + let runnerContents = xpcServices.deletingLastPathComponent().standardizedFileURL + let root = bundle.bundleURL.appendingPathComponent( + "Contents", + isDirectory: true + ).standardizedFileURL + guard runnerContents.lastPathComponent == "Contents", + root.lastPathComponent == "Contents", + let relative = Self.relativePath(of: executableURL, below: root) else { + throw DoryRendererProductionArtifactError.invalidBundleLayout + } + contentsRoot = root + executableRelativePath = relative + } + + /// Test/packaging constructor. Paths remain fixed below one injected Contents root; callers + /// cannot use this constructor through the XPC wire contract. + public init(contentsRoot: URL, executableRelativePath: String) throws { + guard Self.validRelativePath(executableRelativePath) else { + throw DoryRendererProductionArtifactError.invalidBundleLayout + } + self.contentsRoot = contentsRoot.standardizedFileURL + self.executableRelativePath = executableRelativePath + } + + public func verify( + bootstrap: DoryRendererWorkerBootstrap + ) throws -> DoryRendererArtifactAttestation { + let rootDescriptor = open( + contentsRoot.path, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + guard rootDescriptor >= 0 else { + throw DoryRendererProductionArtifactError.invalidBundleLayout + } + defer { close(rootDescriptor) } + + let expectedPath = Self.workerBundleExecutableRelativePath + guard executableRelativePath == expectedPath else { + throw DoryRendererProductionArtifactError.invalidBundleLayout + } + let executable = try Self.openSecureRegularFile( + relativePath: expectedPath, + rootDescriptor: rootDescriptor, + maximumBytes: Self.maximumArtifactBytes, + label: "renderer worker executable" + ) + defer { close(executable.fileDescriptor) } + let executableDigest = try Self.hashFileData( + artifact: executable + ) + guard executableDigest == bootstrap.artifacts.rendererWorkerExecutable.bytes else { + throw DoryRendererProductionArtifactError.executableDigestMismatch + } + + return DoryRendererArtifactAttestation( + candidateInventory: bootstrap.artifacts.candidateInventory, + rendererWorkerExecutable: bootstrap.artifacts.rendererWorkerExecutable + ) + } + + private struct OpenedArtifact { + let fileDescriptor: Int32 + let byteCount: UInt64 + let identity: FileIdentity + } + + private struct FileIdentity: Equatable { + let device: dev_t + let inode: ino_t + let size: off_t + let modificationSeconds: Int + let modificationNanoseconds: Int + let changeSeconds: Int + let changeNanoseconds: Int + + init(_ status: stat) { + device = status.st_dev + inode = status.st_ino + size = status.st_size + modificationSeconds = status.st_mtimespec.tv_sec + modificationNanoseconds = status.st_mtimespec.tv_nsec + changeSeconds = status.st_ctimespec.tv_sec + changeNanoseconds = status.st_ctimespec.tv_nsec + } + } + + private static func openSecureRegularFile( + relativePath: String, + rootDescriptor: Int32, + maximumBytes: UInt64, + label: String + ) throws -> OpenedArtifact { + guard validRelativePath(relativePath) else { + throw DoryRendererProductionArtifactError.artifactUnavailable(label) + } + let parts = relativePath.split(separator: "/").map(String.init) + var directory = fcntl(rootDescriptor, F_DUPFD_CLOEXEC, 0) + guard directory >= 0 else { + throw DoryRendererProductionArtifactError.artifactUnavailable(label) + } + for part in parts.dropLast() { + let next = openat( + directory, + part, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW + ) + close(directory) + guard next >= 0 else { + throw DoryRendererProductionArtifactError.artifactUnavailable(label) + } + directory = next + } + let descriptor = openat( + directory, + parts.last!, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK + ) + close(directory) + guard descriptor >= 0 else { + throw DoryRendererProductionArtifactError.artifactUnavailable(label) + } + var status = stat() + guard fstat(descriptor, &status) == 0, + (status.st_mode & S_IFMT) == S_IFREG, + status.st_nlink == 1, + status.st_size > 0, + UInt64(status.st_size) <= maximumBytes, + status.st_mode & (S_IWGRP | S_IWOTH) == 0 else { + close(descriptor) + throw DoryRendererProductionArtifactError.artifactUnavailable(label) + } + return OpenedArtifact( + fileDescriptor: descriptor, + byteCount: UInt64(status.st_size), + identity: FileIdentity(status) + ) + } + + private static func hashFileData( + artifact: OpenedArtifact + ) throws -> Data { + let fileDescriptor = artifact.fileDescriptor + let byteCount = artifact.byteCount + guard lseek(fileDescriptor, 0, SEEK_SET) == 0 else { + throw DoryRendererProductionArtifactError.artifactUnavailable( + "renderer worker executable" + ) + } + var remaining = byteCount + var hasher = SHA256() + var buffer = [UInt8](repeating: 0, count: 1_024 * 1_024) + while remaining > 0 { + let requested = min(buffer.count, Int(remaining)) + let count = buffer.withUnsafeMutableBytes { + Darwin.read(fileDescriptor, $0.baseAddress, requested) + } + if count < 0, errno == EINTR { continue } + guard count > 0 else { + throw DoryRendererProductionArtifactError.artifactUnavailable( + "renderer worker executable" + ) + } + hasher.update(data: Data(buffer[0.. 0, + UInt64(status.st_size) == artifact.byteCount, + status.st_mode & (S_IWGRP | S_IWOTH) == 0, + FileIdentity(status) == artifact.identity else { + throw DoryRendererProductionArtifactError.artifactUnavailable( + "renderer worker executable" + ) + } + } + + private static func validRelativePath(_ path: String) -> Bool { + guard !path.isEmpty, !path.hasPrefix("/"), !path.hasSuffix("/") else { return false } + let parts = path.split(separator: "/", omittingEmptySubsequences: false) + return parts.allSatisfy { !$0.isEmpty && $0 != "." && $0 != ".." } + } + + private static func relativePath(of url: URL, below root: URL) -> String? { + let rootPath = root.standardizedFileURL.path + let candidate = url.standardizedFileURL.path + guard candidate.hasPrefix(rootPath + "/") else { return nil } + let relative = String(candidate.dropFirst(rootPath.count + 1)) + return validRelativePath(relative) ? relative : nil + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererWorkerVirglBackend.swift b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererWorkerVirglBackend.swift new file mode 100644 index 00000000..f1388178 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerVirglBackend/DoryRendererWorkerVirglBackend.swift @@ -0,0 +1,1761 @@ +import Darwin +import DoryRendererWorkerContracts +import DoryRendererWorkerServiceCore +import DoryVirglRendererShim +import Foundation +import Metal +import OSLog + +public protocol DoryRendererScanoutAlignmentProviding: Sendable { + func minimumLinearTextureAlignment( + pixelFormat: DoryRendererScanoutPixelFormat + ) -> UInt32? +} + +public struct DoryRendererSystemMetalAlignmentProvider: + DoryRendererScanoutAlignmentProviding, + Sendable +{ + public init() {} + + public func minimumLinearTextureAlignment( + pixelFormat: DoryRendererScanoutPixelFormat + ) -> UInt32? { + guard let device = MTLCreateSystemDefaultDevice() else { return nil } + let format: MTLPixelFormat = switch pixelFormat { + case .bgra8Unorm: .bgra8Unorm + case .rgba8Unorm: .rgba8Unorm + } + let alignment = device.minimumLinearTextureAlignment(for: format) + guard alignment >= 4, alignment <= 65_536, + alignment.nonzeroBitCount == 1 else { return nil } + return UInt32(alignment) + } +} + +public struct DoryRendererWorkerVirglBackendSnapshot: Equatable, Sendable { + public let isActive: Bool + public let contextCount: Int + public let resourceCount: Int + public let liveScanoutLeaseCount: Int +} + +/// Closed worker-side failure vocabulary. Raw foreign error text is never logged or returned. +enum DoryRendererForeignExecutionFailureStage: String, Equatable, Sendable { + case foreignSessionOpen = "foreign-session-open" + case foreignCall = "foreign-call" + case foreignResultValidation = "foreign-result-validation" + case backendInternal = "backend-internal" +} + +enum DoryRendererForeignSessionErrorCase: String, Equatable, Sendable { + case openFailed = "open-failed" + case callFailed = "call-failed" + case submitFailed = "submit-failed" + case invalidResult = "invalid-result" + case unexpected = "unexpected" +} + +/// Exact allowlist of operation labels constructed by the production foreign-session adapter. +/// Unknown strings collapse to `unclassified` and never cross the diagnostic boundary. +enum DoryRendererForeignOperationLabel: String, Equatable, Sendable { + case sessionOpen = "session-open" + case getCapset = "virgl_renderer_get_cap_set" + case fillCaps = "virgl_renderer_fill_caps" + case capsetSize = "capset-size" + case createContext = "virgl_renderer_context_create_with_flags" + case submit3D = "virgl_renderer_submit_cmd2" + case createBlob = "virgl_renderer_resource_create_blob" + case createResource3D = "virgl_renderer_resource_create" + case attachBacking = "virgl_renderer_resource_attach_iov" + case mapInfo = "virgl_renderer_resource_get_map_info" + case exportBlob = "virgl_renderer_resource_export_blob" + case exportBlobDescriptor = "export-blob-fd" + case resourceInfo = "virgl_renderer_resource_get_info" + case acquireScanoutTexture = "virgl_renderer_create_handle_for_scanout" + case scanoutTextureResult = "metal-scanout-texture" + case scanoutTextureType = "metal-scanout-texture-type" + case transferToHost = "virgl_renderer_transfer_write_iov" + case transferFromHost = "virgl_renderer_transfer_read_iov" + case createContextFence = "virgl_renderer_context_create_fence" + case createGlobalFence = "virgl_renderer_create_fence" + case exportFenceDescriptor = "export-fence-fd" + case rendererPollDescriptor = "renderer-poll-fd" + case closedSession = "closed-session" + case nonSHMExport = "non-shm-export" + case shmExportStat = "shm-export-stat" + case shmExportBounds = "shm-export-bounds" + case shmExportMapping = "shm-export-mapping" + case shmExportUnmapping = "shm-export-unmapping" + case shmExportCloseOnExec = "shm-export-cloexec" + case resourceGeneration = "resource-generation" + case typedOperationPayload = "typed-operation-payload" + case unclassified +} + +struct DoryRendererForeignExecutionFailureDiagnostic: Equatable, Sendable { + let operation: DoryRendererWorkerOperation + let requestID: UInt64 + let stage: DoryRendererForeignExecutionFailureStage + let errorCase: DoryRendererForeignSessionErrorCase + let foreignOperation: DoryRendererForeignOperationLabel + let statusIsAvailable: Bool + let status: Int32 + let submitDiagnosticIsAvailable: Bool + let virglDecoderDiagnosticIsAvailable: Bool + let submitContextID: UInt32 + let virglCommandID: UInt32 + let virglCommandStatus: Int32 + let createObjectSubtypeDisposition: DoryRendererCreateObjectSubtypeDisposition + let createObjectSubtype: DoryRendererVirglObjectType? + let createObjectCandidateCount: UInt32 + let createObjectSubtypeMask: UInt32 + let surfaceFailureReason: DoryRendererVirglSurfaceFailureReason + let virglPrecursorCategory: DoryRendererVirglSubmitPrecursorCategory + let elapsedNanoseconds: UInt64 +} + +/// virglrenderer keeps its current EGL/GL context in pthread-local state. A serial DispatchQueue +/// preserves ordering but may execute successive blocks on different pthreads, so it cannot own +/// that context. This lane gives the complete foreign-renderer lifetime one persistent native +/// thread: initialization, every command, explicit polling, inspection, and teardown. +private final class DoryRendererForeignExecutionLane: @unchecked Sendable { + private enum LaneError: Error { + case stopped + } + + private final class Submission: @unchecked Sendable { + private let condition = NSCondition() + private let operation: () throws -> Output + private var result: Result? + + init(operation: @escaping () throws -> Output) { + self.operation = operation + } + + func run() { + let completed = Result { try operation() } + condition.lock() + result = completed + condition.broadcast() + condition.unlock() + } + + func wait() throws -> Output { + condition.lock() + while result == nil { condition.wait() } + let completed = result! + condition.unlock() + return try completed.get() + } + } + + private final class State: @unchecked Sendable { + private let condition = NSCondition() + private var pending = [() -> Void]() + private var owner: pthread_t? + private var started = false + private var stopping = false + private var exited = false + + func run() { + condition.lock() + owner = pthread_self() + started = true + condition.broadcast() + condition.unlock() + + while let operation = next() { + autoreleasepool(invoking: operation) + } + } + + func waitUntilStarted() { + condition.lock() + while !started { condition.wait() } + condition.unlock() + } + + func isOwnerThread() -> Bool { + condition.lock() + let matches = owner.map { pthread_equal(pthread_self(), $0) != 0 } ?? false + condition.unlock() + return matches + } + + func enqueue(_ operation: @escaping () -> Void) -> Bool { + condition.lock() + defer { condition.unlock() } + guard !stopping else { return false } + pending.append(operation) + condition.signal() + return true + } + + func stopAndWait() { + condition.lock() + stopping = true + condition.broadcast() + if owner.map({ pthread_equal(pthread_self(), $0) != 0 }) != true { + while !exited { condition.wait() } + } + condition.unlock() + } + + private func next() -> (() -> Void)? { + condition.lock() + while pending.isEmpty, !stopping { condition.wait() } + guard !pending.isEmpty else { + exited = true + condition.broadcast() + condition.unlock() + return nil + } + let operation = pending.removeFirst() + condition.unlock() + return operation + } + } + + private let state: State + private let thread: Thread + + init() { + let state = State() + self.state = state + let thread = Thread { state.run() } + thread.name = "dory-renderer.foreign-owner" + thread.qualityOfService = .userInteractive + self.thread = thread + thread.start() + state.waitUntilStarted() + } + + deinit { state.stopAndWait() } + + func sync(_ operation: @escaping () throws -> Output) throws -> Output { + if state.isOwnerThread() { return try operation() } + let submission = Submission(operation: operation) + guard state.enqueue({ submission.run() }) else { throw LaneError.stopped } + return try submission.wait() + } +} + +/// Production renderer backend. Activation is one atomic transition: exact bundle bytes are +/// verified, the static dual renderer is opened, real VirGL2 and Venus contexts are exercised, +/// VirGL's shareable Metal texture and Venus SHM paths are imported, and callback-backed fences are +/// observed before a complete receipt can exist. Any uncertain result tears down the entire process +/// generation; there is no software, frame-copy, or synchronous runtime-completion fallback. +public final class DoryRendererWorkerVirglBackend: + DoryRendererWorkerBackend, + @unchecked Sendable +{ + private static let maximumContexts = 4_096 + private static let maximumResources = 65_536 + private static let preflightVirgl2ContextID: UInt32 = 0xffff_fff0 + private static let preflightVenusContextID: UInt32 = 0xffff_fff1 + private static let preflightResourceID: UInt32 = 0xffff_fff2 + private static let preflightVirgl2ResourceID: UInt32 = 0xffff_fff3 + private static let preflightVirgl2BufferResourceID: UInt32 = 0xffff_fff4 + private static let preflightVirgl2Resource2DID: UInt32 = 0xffff_fff5 + private static let preflightVirgl2SurfaceObjectID: UInt32 = 0xffff_fff6 + private static let preflightGlobalFenceID: UInt64 = 0x1_0000_00f1 + private static let preflightVirgl2FenceID: UInt64 = 0xffff_ffff_ffff_ffef + private static let preflightVenusFenceID: UInt64 = 0xffff_ffff_ffff_fff0 + private static let preflightFenceTimeoutMilliseconds: Int32 = 3_000 + private static let logger = Logger( + subsystem: "dev.dory.renderer-worker", + category: "scanout" + ) + private static let executionLogger = Logger( + subsystem: "dev.dory.renderer-worker", + category: "execution" + ) + + private enum State { + case cold + case active(ActiveState) + case failed + } + + private final class ActiveState { + let bootstrap: DoryRendererWorkerBootstrap + let session: any DoryRendererForeignSession + var contexts = [UInt32: UInt32]() + var resources = [UInt32: ResourceState]() + var lastResourceGenerations = [UInt32: UInt64]() + var leases = [UUID: ScanoutLeaseState]() + var loggedScanoutRejections = Set() + var pollDriver: PollDriver? + + init( + bootstrap: DoryRendererWorkerBootstrap, + session: any DoryRendererForeignSession + ) { + self.bootstrap = bootstrap + self.session = session + } + } + + /// virglrenderer uses this descriptor for non-callback event retirement even with asynchronous + /// fence callbacks enabled. Darwin can strip the thread-sync hint and return no descriptor; in + /// that mode a bounded timer supplies the same required pump. The source never owns or closes a + /// borrowed descriptor. + private final class PollDriver: @unchecked Sendable { + private let cancellationLock = NSLock() + private var cancelled = false + private let readSource: DispatchSourceRead? + private let timerSource: DispatchSourceTimer? + + init( + descriptor: Int32?, + handler: @escaping @Sendable () -> Void + ) { + let queue = DispatchQueue( + label: "dev.dory.renderer-worker.foreign-events", + qos: .userInteractive + ) + if let descriptor { + let source = DispatchSource.makeReadSource( + fileDescriptor: descriptor, + queue: queue + ) + readSource = source + timerSource = nil + source.setEventHandler(handler: handler) + source.resume() + } else { + let source = DispatchSource.makeTimerSource(queue: queue) + readSource = nil + timerSource = source + source.schedule( + deadline: .now() + .milliseconds(4), + repeating: .milliseconds(4), + leeway: .milliseconds(1) + ) + source.setEventHandler(handler: handler) + source.resume() + } + } + + func cancel() { + cancellationLock.lock() + defer { cancellationLock.unlock() } + guard !cancelled else { return } + cancelled = true + readSource?.setEventHandler {} + timerSource?.setEventHandler {} + readSource?.cancel() + timerSource?.cancel() + } + + deinit { cancel() } + } + + private struct PreflightResult { + let capsets: [DoryRendererForeignCapset] + let pollDescriptor: Int32? + } + + private final class ResourceState { + let generation: UInt64 + let blobSize: UInt64? + let resource3DBind: UInt32? + var backing: OwnedBacking? + var attachedContexts = Set() + var mapped = false + var liveLeaseIDs = Set() + + init( + generation: UInt64, + blobSize: UInt64?, + resource3DBind: UInt32?, + backing: OwnedBacking? + ) { + self.generation = generation + self.blobSize = blobSize + self.resource3DBind = resource3DBind + self.backing = backing + } + } + + private struct ScanoutLeaseState { + let resourceID: UInt32 + let resourceGeneration: UInt64 + let releaseToken: DoryRendererScanoutReleaseToken + let sharedTextureHandle: MTLSharedTextureHandle? + } + + private let verifier: any DoryRendererProductionArtifactVerifying + private let sessionFactory: any DoryRendererForeignSessionCreating + private let alignmentProvider: any DoryRendererScanoutAlignmentProviding + private let executionLane = DoryRendererForeignExecutionLane() + private let lock = NSLock() + private var state: State = .cold + + public convenience init() throws { + try self.init( + verifier: DoryRendererProductionArtifactVerifier(), + sessionFactory: DoryRendererCForeignSessionFactory(), + alignmentProvider: DoryRendererSystemMetalAlignmentProvider() + ) + } + + public init( + verifier: any DoryRendererProductionArtifactVerifying, + sessionFactory: any DoryRendererForeignSessionCreating, + alignmentProvider: any DoryRendererScanoutAlignmentProviding + ) { + self.verifier = verifier + self.sessionFactory = sessionFactory + self.alignmentProvider = alignmentProvider + } + + deinit { invalidate() } + + public func activate( + bootstrap: DoryRendererWorkerBootstrap + ) throws -> DoryRendererCapabilityReceipt { + try executionLane.sync { [self] in + try activateOnExecutionLane(bootstrap: bootstrap) + } + } + + private func activateOnExecutionLane( + bootstrap: DoryRendererWorkerBootstrap + ) throws -> DoryRendererCapabilityReceipt { + lock.lock() + defer { lock.unlock() } + guard case .cold = state else { + throw DoryRendererWorkerBackendActivationError.capabilityReceipt + } + do { + let attestation: DoryRendererArtifactAttestation + do { + attestation = try verifier.verify(bootstrap: bootstrap) + } catch { + throw DoryRendererWorkerBackendActivationError.artifactAuthority + } + let session: any DoryRendererForeignSession + do { + session = try sessionFactory.create(attestation: attestation) + } catch { + throw DoryRendererWorkerBackendActivationError.rendererInitialization + } + do { + let preflight = try Self.preflight(session: session) + let receiptCapsets: [DoryRendererCapsetAttestation] + do { + receiptCapsets = try preflight.capsets.map { capset in + try DoryRendererCapsetAttestation( + id: capset.id, + maximumVersion: capset.maximumVersion, + data: capset.bytes + ) + } + } catch { + throw DoryRendererWorkerBackendActivationError.capabilityReceipt + } + let receipt: DoryRendererCapabilityReceipt + do { + receipt = try DoryRendererCapabilityReceipt( + accepting: bootstrap, + features: .productionAcceleration, + capsets: receiptCapsets + ) + } catch { + throw DoryRendererWorkerBackendActivationError.capabilityReceipt + } + let active = ActiveState(bootstrap: bootstrap, session: session) + state = .active(active) + active.pollDriver = PollDriver( + descriptor: preflight.pollDescriptor, + handler: { [weak self] in self?.pollForeignEvents() } + ) + return receipt + } catch { + session.invalidate() + throw error + } + } catch { + state = .failed + throw error + } + } + + public func execute( + command: DoryRendererWorkerCommand, + descriptors: [FileHandle] + ) throws -> DoryRendererWorkerBackendExecution { + try executionLane.sync { [self] in + try executeOnExecutionLane(command: command, descriptors: descriptors) + } + } + + private func executeOnExecutionLane( + command: DoryRendererWorkerCommand, + descriptors: [FileHandle] + ) throws -> DoryRendererWorkerBackendExecution { + let startedAt = DispatchTime.now().uptimeNanoseconds + lock.lock() + defer { lock.unlock() } + guard case .active(let active) = state else { return .rejected } + do { + return try executeLocked( + command: command, + descriptors: descriptors, + active: active + ) + } catch is DoryRendererWorkerContractError { + throw errorForProtocolViolation() + } catch { + let now = DispatchTime.now().uptimeNanoseconds + let diagnostic = Self.executionFailureDiagnostic( + command: command, + error: error, + elapsedNanoseconds: now >= startedAt ? now - startedAt : 0 + ) + Self.executionLogger.error( + "command-outcome-unknown operation=\(diagnostic.operation.rawValue, privacy: .public) request=\(diagnostic.requestID, privacy: .public) stage=\(diagnostic.stage.rawValue, privacy: .public) error-case=\(diagnostic.errorCase.rawValue, privacy: .public) foreign-operation=\(diagnostic.foreignOperation.rawValue, privacy: .public) status-present=\(diagnostic.statusIsAvailable, privacy: .public) status=\(diagnostic.status, privacy: .public) submit-diagnostic-present=\(diagnostic.submitDiagnosticIsAvailable, privacy: .public) virgl-decoder-diagnostic-present=\(diagnostic.virglDecoderDiagnosticIsAvailable, privacy: .public) submit-context=\(diagnostic.submitContextID, privacy: .public) virgl-command=\(diagnostic.virglCommandID, privacy: .public) virgl-command-status=\(diagnostic.virglCommandStatus, privacy: .public) create-object-subtype-disposition=\(diagnostic.createObjectSubtypeDisposition.rawValue, privacy: .public) create-object-subtype-present=\(diagnostic.createObjectSubtype != nil, privacy: .public) create-object-subtype=\(diagnostic.createObjectSubtype?.rawValue ?? 0, privacy: .public) create-object-candidate-count=\(diagnostic.createObjectCandidateCount, privacy: .public) create-object-subtype-mask=\(diagnostic.createObjectSubtypeMask, privacy: .public) surface-failure-reason=\(diagnostic.surfaceFailureReason.rawValue, privacy: .public) virgl-precursor-category=\(diagnostic.virglPrecursorCategory.rawValue, privacy: .public) elapsed-ns=\(diagnostic.elapsedNanoseconds, privacy: .public)" + ) + teardownLocked(active) + state = .failed + return .outcomeUnknown + } + } + + static func executionFailureDiagnostic( + command: DoryRendererWorkerCommand, + error: any Error, + elapsedNanoseconds: UInt64 + ) -> DoryRendererForeignExecutionFailureDiagnostic { + let stage: DoryRendererForeignExecutionFailureStage + let errorCase: DoryRendererForeignSessionErrorCase + let foreignOperation: DoryRendererForeignOperationLabel + let statusIsAvailable: Bool + let status: Int32 + let submitDiagnostic: DoryRendererForeignSubmitFailure? + switch error as? DoryRendererForeignSessionError { + case .openFailed(let value): + stage = .foreignSessionOpen + errorCase = .openFailed + foreignOperation = .sessionOpen + statusIsAvailable = true + status = value + submitDiagnostic = nil + case .callFailed(let operation, let value): + stage = .foreignCall + errorCase = .callFailed + foreignOperation = DoryRendererForeignOperationLabel(rawValue: operation) + ?? .unclassified + statusIsAvailable = true + status = value + submitDiagnostic = nil + case .submitFailed(let value, let diagnostic): + stage = .foreignCall + errorCase = .submitFailed + foreignOperation = .submit3D + statusIsAvailable = true + status = value + submitDiagnostic = diagnostic + case .invalidResult(let operation): + stage = .foreignResultValidation + errorCase = .invalidResult + foreignOperation = DoryRendererForeignOperationLabel(rawValue: operation) + ?? .unclassified + statusIsAvailable = false + status = 0 + submitDiagnostic = nil + case nil: + stage = .backendInternal + errorCase = .unexpected + foreignOperation = .unclassified + statusIsAvailable = false + status = 0 + submitDiagnostic = nil + } + return DoryRendererForeignExecutionFailureDiagnostic( + operation: command.operation, + requestID: command.requestID, + stage: stage, + errorCase: errorCase, + foreignOperation: foreignOperation, + statusIsAvailable: statusIsAvailable, + status: status, + submitDiagnosticIsAvailable: submitDiagnostic != nil, + virglDecoderDiagnosticIsAvailable: + submitDiagnostic?.decoderDiagnosticIsAvailable ?? false, + submitContextID: submitDiagnostic?.contextID ?? 0, + virglCommandID: submitDiagnostic?.commandID ?? 0, + virglCommandStatus: submitDiagnostic?.status ?? 0, + createObjectSubtypeDisposition: + submitDiagnostic?.createObjectSubtypeDisposition ?? .absent, + createObjectSubtype: submitDiagnostic?.createObjectSubtype, + createObjectCandidateCount: submitDiagnostic?.createObjectCandidateCount ?? 0, + createObjectSubtypeMask: submitDiagnostic?.createObjectSubtypeMask ?? 0, + surfaceFailureReason: submitDiagnostic?.surfaceFailureReason ?? .none, + virglPrecursorCategory: submitDiagnostic?.precursorCategory ?? .none, + elapsedNanoseconds: elapsedNanoseconds + ) + } + + public func invalidate() { + _ = try? executionLane.sync { [self] in + lock.lock() + defer { lock.unlock() } + if case .active(let active) = state { teardownLocked(active) } + state = .failed + } + } + + public func snapshot() -> DoryRendererWorkerVirglBackendSnapshot { + let unavailable = DoryRendererWorkerVirglBackendSnapshot( + isActive: false, + contextCount: 0, + resourceCount: 0, + liveScanoutLeaseCount: 0 + ) + return (try? executionLane.sync { [self] in + lock.withLock { + switch state { + case .cold, .failed: + return unavailable + case .active(let active): + return DoryRendererWorkerVirglBackendSnapshot( + isActive: true, + contextCount: active.contexts.count, + resourceCount: active.resources.count, + liveScanoutLeaseCount: active.leases.count + ) + } + } + }) ?? unavailable + } + + private func executeLocked( + command: DoryRendererWorkerCommand, + descriptors: [FileHandle], + active: ActiveState + ) throws -> DoryRendererWorkerBackendExecution { + switch command.operation { + case .createContext: + guard active.contexts.count < Self.maximumContexts, + active.contexts[command.contextID] == nil else { return .rejected } + let payload = try DoryRendererContextCreatePayload.decode(command.payload) + guard payload.capsetID == 2 || payload.capsetID == 4 else { return .rejected } + try active.session.createContext( + id: command.contextID, + capsetID: payload.capsetID, + name: payload.name + ) + active.contexts[command.contextID] = payload.capsetID + return .success(payload: Data(), descriptors: []) + + case .createResource3D: + guard active.resources.count < Self.maximumResources, + active.resources[command.resourceID] == nil else { return .rejected } + let payload = try DoryRendererResource3DCreatePayload.decode( + command.payload, + maximumReferencedBytes: active.bootstrap.limits.maximumReferencedBytes + ) + try active.session.createResource3D( + DoryRendererForeignResource3DCreate( + resourceID: command.resourceID, + payload: payload + ) + ) + let generation = try nextResourceGeneration( + resourceID: command.resourceID, + active: active + ) + active.resources[command.resourceID] = ResourceState( + generation: generation, + blobSize: nil, + resource3DBind: payload.bind, + backing: nil + ) + return .success(payload: Self.encodeUInt64(generation), descriptors: []) + + case .destroyContext: + guard active.contexts.removeValue(forKey: command.contextID) != nil else { + return .rejected + } + for (resourceID, resource) in active.resources + where resource.attachedContexts.remove(command.contextID) != nil { + // The local state proved both identities before this void foreign call. + // Detaching first also makes retained backing teardown deterministic. + active.session.detachResource( + contextID: command.contextID, + resourceID: resourceID + ) + } + active.session.destroyContext(id: command.contextID) + return .success(payload: Data(), descriptors: []) + + case .attachResource: + guard active.contexts[command.contextID] != nil, + let resource = matchingResource(command, active: active) else { + return .rejected + } + // Linux GEM open can emit the same context/resource attach more than once. QEMU + // forwards every request and virglrenderer treats an already-attached resource as a + // successful no-op, so preserve that public lifecycle contract while still proving + // both identities and the authenticated resource generation above. + guard resource.attachedContexts.insert(command.contextID).inserted else { + return .success(payload: Data(), descriptors: []) + } + active.session.attachResource( + contextID: command.contextID, + resourceID: command.resourceID + ) + return .success(payload: Data(), descriptors: []) + + case .detachResource: + guard active.contexts[command.contextID] != nil, + let resource = matchingResource(command, active: active) else { + return .rejected + } + // The matching Linux GEM close path and virglrenderer detach callback are likewise + // idempotent. Do not turn harmless duplicate cleanup into a guest-visible GPU fault. + guard resource.attachedContexts.remove(command.contextID) != nil else { + return .success(payload: Data(), descriptors: []) + } + active.session.detachResource( + contextID: command.contextID, + resourceID: command.resourceID + ) + return .success(payload: Data(), descriptors: []) + + case .submit3D: + guard active.contexts[command.contextID] != nil, + let region = command.sharedRegions.first else { return .rejected } + let mapping = try OwnedBacking( + regions: [region], + descriptors: descriptors + ) + guard let baseAddress = mapping.iovecs.pointee.iov_base else { return .rejected } + try active.session.submit( + contextID: command.contextID, + bytes: UnsafeRawPointer(baseAddress), + dwordCount: UInt32(region.length / 4) + ) + return .success(payload: Data(), descriptors: []) + + case .createBlob: + guard active.resources.count < Self.maximumResources, + active.resources[command.resourceID] == nil, + command.contextID == 0 || active.contexts[command.contextID] != nil else { + return .rejected + } + let payload = try DoryRendererBlobCreatePayload.decode(command.payload) + guard payload.size <= active.bootstrap.limits.maximumReferencedBytes else { + return .rejected + } + let backing = command.sharedRegions.isEmpty + ? nil + : try OwnedBacking(regions: command.sharedRegions, descriptors: descriptors) + try active.session.createBlob( + DoryRendererForeignBlobCreate( + resourceID: command.resourceID, + contextID: command.contextID, + payload: payload + ), + iovecs: backing?.iovecs, + iovecCount: backing?.count ?? 0 + ) + let generation = try nextResourceGeneration( + resourceID: command.resourceID, + active: active + ) + active.resources[command.resourceID] = ResourceState( + generation: generation, + blobSize: payload.size, + resource3DBind: nil, + backing: backing + ) + return .success(payload: Self.encodeUInt64(generation), descriptors: []) + + case .attachBacking: + guard let resource = matchingResource(command, active: active), + resource.backing == nil, + !resource.mapped, + resource.liveLeaseIDs.isEmpty else { return .rejected } + let backing = try OwnedBacking( + regions: command.sharedRegions, + descriptors: descriptors + ) + try active.session.attachBacking( + resourceID: command.resourceID, + iovecs: backing.iovecs, + iovecCount: backing.count + ) + resource.backing = backing + return .success(payload: Data(), descriptors: []) + + case .detachBacking: + guard let resource = matchingResource(command, active: active), + resource.backing != nil, + !resource.mapped, + resource.liveLeaseIDs.isEmpty else { return .rejected } + active.session.detachBacking(resourceID: command.resourceID) + resource.backing = nil + return .success(payload: Data(), descriptors: []) + + case .unrefResource: + guard let resource = matchingResource(command, active: active), + resource.attachedContexts.isEmpty, + !resource.mapped, + resource.liveLeaseIDs.isEmpty else { return .rejected } + if resource.backing != nil { + // virglrenderer borrows these iovecs. Keep OwnedBacking (and its mmap regions) + // alive through the foreign detach, then revoke that memory authority before the + // resource handle is unreferenced or becomes eligible for same-ID reuse. + active.session.detachBacking(resourceID: command.resourceID) + resource.backing = nil + } + active.session.unrefResource(id: command.resourceID) + active.resources.removeValue(forKey: command.resourceID) + return .success(payload: Data(), descriptors: []) + + case .mapBlob: + guard let resource = matchingResource(command, active: active), + let blobSize = resource.blobSize, + !resource.mapped else { return .rejected } + let mapInfo = try active.session.mapInfo(resourceID: command.resourceID) & 0x0f + let exported = try active.session.exportBlob(resourceID: command.resourceID) + let validated = try Self.validateExportedSHM( + exported, + minimumBytes: blobSize, + maximumBytes: active.bootstrap.limits.maximumReferencedBytes + ) + do { + let lease = try DoryRendererBlobMappingLease( + workerGeneration: active.bootstrap.generation, + resourceID: command.resourceID, + resourceGeneration: resource.generation, + sharedRegionID: DoryRendererSharedRegionID.random(), + descriptorIndex: 0, + mapInfo: mapInfo, + declaredFileSize: validated.fileSize, + mappingByteCount: blobSize, + limits: active.bootstrap.limits + ) + resource.mapped = true + return .success( + payload: DoryRendererBlobMappingLeaseCodec.encode(lease), + descriptors: [FileHandle( + fileDescriptor: validated.fileDescriptor, + closeOnDealloc: true + )] + ) + } catch { + close(validated.fileDescriptor) + throw error + } + + case .unmapBlob: + guard let resource = matchingResource(command, active: active), + resource.mapped else { return .rejected } + // `mapBlob` exported an owned SHM descriptor; it did not call the process-local + // `virgl_renderer_resource_map`, so no foreign unmap state exists to release here. + resource.mapped = false + return .success(payload: Data(), descriptors: []) + + case .transferToHost3D, .transferFromHost3D: + guard matchingResource(command, active: active) != nil, + command.contextID == 0 || active.contexts[command.contextID] != nil else { + return .rejected + } + let payload = try DoryRendererTransfer3DPayload.decode( + command.payload, + operation: command.operation + ) + let backing = command.sharedRegions.isEmpty + ? nil + : try OwnedBacking(regions: command.sharedRegions, descriptors: descriptors) + try active.session.transfer( + toHost: command.operation == .transferToHost3D, + resourceID: command.resourceID, + contextID: command.contextID, + payload: payload, + iovecs: backing?.iovecs, + iovecCount: backing?.count ?? 0 + ) + return .success(payload: Data(), descriptors: []) + + case .createFence: + let payload = try DoryRendererFencePayload.decode(command.payload) + let hasAuthorizedTimeline = payload.isContextTimeline + ? command.contextID != 0 && active.contexts[command.contextID] != nil + : command.contextID == 0 || active.contexts[command.contextID] != nil + guard hasAuthorizedTimeline else { + // Context-timeline fences require an authenticated live context. Global fences + // order ctx0 resource mutations or a live submit context, but never a stale one. + return .rejected + } + if payload.isContextTimeline { + try active.session.createFence( + contextID: command.contextID, + // The worker bit is protocol metadata. Virgl bit 0 means MERGEABLE, not + // "context timeline", so it must not be forwarded into the foreign ABI. + flags: 0, + ringIndex: payload.ringIndex, + fenceID: payload.fenceID + ) + } else { + try active.session.createGlobalFence(fenceID: payload.fenceID) + } + let brokerDescriptor = try active.session.exportFence(fenceID: payload.fenceID) + return .success( + payload: payload.encoded, + descriptors: [FileHandle( + fileDescriptor: brokerDescriptor, + closeOnDealloc: true + )] + ) + + case .acquireScanoutLease: + guard let resource = matchingResource(command, active: active), + // Dumb-KMS RESOURCE_CREATE_2D scanouts are valid without a VirGL context. + // Blob scanouts remain context-owned, while classic resources must still pass + // the generation, SCANOUT-bind, resource-info, and Metal checks below. + resource.blobSize == nil || !resource.attachedContexts.isEmpty, + active.leases.count < active.bootstrap.limits.maximumLiveScanoutLeases else { + return .rejected + } + let payload = try DoryRendererScanoutAcquirePayload.decode(command.payload) + let pixelFormat: DoryRendererScanoutPixelFormat = switch payload.virglFormat { + case 1: .bgra8Unorm + case 67: .rgba8Unorm + default: throw DoryRendererWorkerContractError.invalidOperationPayload( + operation: .acquireScanoutLease + ) + } + if resource.blobSize == nil { + return try acquireSharedTextureScanout( + command: command, + payload: payload, + pixelFormat: pixelFormat, + resource: resource, + active: active + ) + } + guard let blobSize = resource.blobSize else { return .rejected } + guard let rowAlignment = alignmentProvider.minimumLinearTextureAlignment( + pixelFormat: pixelFormat + ), rowAlignment >= 4, + rowAlignment <= 65_536, + rowAlignment.nonzeroBitCount == 1 else { + logScanoutRejection( + active: active, + resourceID: command.resourceID, + reason: "invalid-metal-row-alignment" + ) + return .rejected + } + // Venus blob resources have no pipe_resource, so virgl_renderer_resource_get_info + // intentionally returns no dimensions or stride. The authenticated VMM supplies the + // SET_SCANOUT_BLOB layout; this process independently proves it fits the exact worker + // allocation and exported SHM object below. + let (minimumStride, minimumStrideOverflow) = UInt64(payload.width) + .multipliedReportingOverflow(by: pixelFormat.bytesPerPixel) + guard !minimumStrideOverflow, + UInt64(payload.stride) >= minimumStride, + payload.stride.isMultiple(of: rowAlignment), + payload.storageOffset.isMultiple(of: rowAlignment) else { + logScanoutRejection( + active: active, + resourceID: command.resourceID, + reason: "invalid-stride expected-min=\(minimumStride) alignment=" + + "\(rowAlignment) actual=\(payload.stride) offset=" + + "\(payload.storageOffset)" + ) + return .rejected + } + let storageOffset = UInt64(payload.storageOffset) + let (leaseBytes, leaseOverflow) = UInt64(payload.stride) + .multipliedReportingOverflow(by: UInt64(payload.height)) + guard !leaseOverflow, + leaseBytes > 0, + leaseBytes <= active.bootstrap.limits.maximumScanoutBytes else { + logScanoutRejection( + active: active, + resourceID: command.resourceID, + reason: "invalid-lease-size bytes=\(leaseBytes)" + ) + return .rejected + } + let (requiredFileBytes, requiredFileBytesOverflow) = storageOffset + .addingReportingOverflow(leaseBytes) + guard !requiredFileBytesOverflow, + requiredFileBytes <= blobSize, + requiredFileBytes <= active.bootstrap.limits.maximumScanoutBytes else { + logScanoutRejection( + active: active, + resourceID: command.resourceID, + reason: "scanout-exceeds-blob required=\(requiredFileBytes) blob=\(blobSize)" + ) + return .rejected + } + let exported = try active.session.exportBlob(resourceID: command.resourceID) + let validated = try Self.validateExportedSHM( + exported, + minimumBytes: requiredFileBytes, + maximumBytes: active.bootstrap.limits.maximumScanoutBytes + ) + do { + let leaseID = try DoryRendererScanoutLeaseID(rawValue: UUID()) + let releaseToken = try DoryRendererScanoutReleaseToken(rawValue: UUID()) + let lease = try DoryRendererScanoutLease( + workerGeneration: active.bootstrap.generation, + resourceID: command.resourceID, + resourceGeneration: resource.generation, + leaseID: leaseID, + releaseToken: releaseToken, + sharedRegionID: DoryRendererSharedRegionID.random(), + sharedMemoryDescriptorIndex: 0, + synchronization: .managedGuestProducerCompleteFlush, + pixelFormat: pixelFormat, + yOriginTop: true, + width: payload.width, + height: payload.height, + stride: payload.stride, + rowAlignment: rowAlignment, + storageOffset: storageOffset, + declaredFileSize: validated.fileSize, + leaseByteCount: leaseBytes, + limits: active.bootstrap.limits + ) + active.leases[leaseID.rawValue] = ScanoutLeaseState( + resourceID: command.resourceID, + resourceGeneration: resource.generation, + releaseToken: releaseToken, + sharedTextureHandle: nil + ) + resource.liveLeaseIDs.insert(leaseID.rawValue) + return .success( + payload: DoryRendererScanoutLeaseCodec.encode(lease), + descriptors: [ + FileHandle( + fileDescriptor: validated.fileDescriptor, + closeOnDealloc: true + ) + ] + ) + } catch { + close(validated.fileDescriptor) + throw error + } + + case .releaseScanoutLease: + guard let resource = matchingResource(command, active: active) else { + return .rejected + } + let releaseToken = try DoryRendererScanoutReleaseToken.decodeCommandPayload( + command.payload + ) + guard let lease = active.leases.first(where: { + $0.value.resourceID == command.resourceID && + $0.value.resourceGeneration == command.resourceGeneration && + $0.value.releaseToken == releaseToken + }), + resource.liveLeaseIDs.remove(lease.key) != nil else { return .rejected } + active.leases.removeValue(forKey: lease.key) + return .success(payload: Data(), descriptors: []) + + case .resetAfterDeviceQuiesce: + let reset = try DoryRendererResetPayload.decode(command.payload) + guard reset.successorGeneration > active.bootstrap.generation.rawValue else { + return .rejected + } + teardownLocked(active) + state = .failed + return .success(payload: reset.encoded, descriptors: []) + } + } + + private func acquireSharedTextureScanout( + command: DoryRendererWorkerCommand, + payload: DoryRendererScanoutAcquirePayload, + pixelFormat: DoryRendererScanoutPixelFormat, + resource: ResourceState, + active: ActiveState + ) throws -> DoryRendererWorkerBackendExecution { + guard let bind = resource.resource3DBind, + bind & UInt32(DORY_VIRGL_RENDERER_RESOURCE_BIND_SCANOUT) != 0 else { + return .rejected + } + let (minimumStride, strideOverflow) = payload.width.multipliedReportingOverflow(by: 4) + guard !strideOverflow, + payload.stride == minimumStride, + payload.storageOffset == 0 else { return .rejected } + let info = try active.session.resourceInfo(resourceID: command.resourceID) + guard info.resourceID == command.resourceID, + Self.canonicalScanoutFormat(info.format) == payload.virglFormat, + info.width == payload.width, + info.height == payload.height, + info.flags & ~UInt32(1) == 0 else { return .rejected } + + let texture = try active.session.acquireScanoutMetalTexture( + resourceID: command.resourceID, + width: payload.width, + height: payload.height, + virglFormat: payload.virglFormat, + stride: payload.stride, + offset: payload.storageOffset + ) + let expectedMetalFormat: MTLPixelFormat = switch pixelFormat { + case .bgra8Unorm: .bgra8Unorm + case .rgba8Unorm: .rgba8Unorm + } + guard texture.textureType == .type2D, + texture.pixelFormat == expectedMetalFormat, + texture.width == Int(payload.width), + texture.height == Int(payload.height), + texture.depth == 1, + texture.arrayLength == 1, + texture.mipmapLevelCount == 1, + texture.sampleCount == 1, + texture.storageMode == .private, + texture.usage.contains(.shaderRead), + let sharedTextureHandle = texture.makeSharedTextureHandle() else { + return .rejected + } + + let leaseID = try DoryRendererScanoutLeaseID(rawValue: UUID()) + let releaseToken = try DoryRendererScanoutReleaseToken(rawValue: UUID()) + let lease = try DoryRendererSharedTextureScanoutLease( + workerGeneration: active.bootstrap.generation, + resourceID: command.resourceID, + resourceGeneration: resource.generation, + leaseID: leaseID, + releaseToken: releaseToken, + synchronization: .managedGuestProducerCompleteFlush, + pixelFormat: pixelFormat, + yOriginTop: info.flags & 1 != 0, + width: payload.width, + height: payload.height, + limits: active.bootstrap.limits + ) + active.leases[leaseID.rawValue] = ScanoutLeaseState( + resourceID: command.resourceID, + resourceGeneration: resource.generation, + releaseToken: releaseToken, + sharedTextureHandle: sharedTextureHandle + ) + resource.liveLeaseIDs.insert(leaseID.rawValue) + return .success( + payload: DoryRendererSharedTextureScanoutLeaseCodec.encode(lease), + descriptors: [], + sharedTextureHandle: sharedTextureHandle + ) + } + + /// Virtio's XRGB/XBGR formats differ from their alpha variants only in whether scanout + /// consumes the high byte. The VMM deliberately carries the alpha-equivalent Metal format + /// across XPC, while virglrenderer reports the resource's original guest format. Compare both + /// sides in that same closed color-layout vocabulary; no other format is admitted. + private static func canonicalScanoutFormat(_ virglFormat: UInt32) -> UInt32? { + switch virglFormat { + case 1, 2: 1 + case 67, 68: 67 + default: nil + } + } + + private static func preflight( + session: any DoryRendererForeignSession + ) throws -> PreflightResult { + let pollDescriptor: Int32? + do { + pollDescriptor = try session.pollDescriptor() + } catch { + throw DoryRendererWorkerBackendActivationError.fenceExport + } + let virgl2: DoryRendererForeignCapset + do { + virgl2 = try session.capset(id: 2) + } catch { + throw DoryRendererWorkerBackendActivationError.virgl2Capability + } + guard virgl2.maximumVersion > 0, !virgl2.bytes.isEmpty else { + throw DoryRendererWorkerBackendActivationError.virgl2Capability + } + let venus: DoryRendererForeignCapset + do { + venus = try session.capset(id: 4) + } catch { + throw DoryRendererWorkerBackendActivationError.venusCapability + } + // `virgl_renderer_get_cap_set` deliberately reports Venus at outer version zero. The + // returned payload carries the Venus wire/XML/spec versions and is the capability proof. + guard venus.maximumVersion == 0, !venus.bytes.isEmpty else { + throw DoryRendererWorkerBackendActivationError.venusCapability + } + + let resource2DBackingByteCount = 4 * 4 * 4 + let resource2DBacking = UnsafeMutableRawPointer.allocate( + byteCount: resource2DBackingByteCount, + alignment: 16 + ) + resource2DBacking.initializeMemory( + as: UInt8.self, + repeating: 0xa5, + count: resource2DBackingByteCount + ) + defer { resource2DBacking.deallocate() } + + var virgl2Created = false + var virgl2BufferCreated = false + var virgl2Resource2DCreated = false + var virgl2Resource2DBackingAttached = false + var virgl2ResourceCreated = false + var venusCreated = false + var blobCreated = false + defer { + if blobCreated { session.unrefResource(id: preflightResourceID) } + if virgl2ResourceCreated { + session.detachResource( + contextID: preflightVirgl2ContextID, + resourceID: preflightVirgl2ResourceID + ) + session.unrefResource(id: preflightVirgl2ResourceID) + } + if virgl2Resource2DCreated { + session.detachResource( + contextID: preflightVirgl2ContextID, + resourceID: preflightVirgl2Resource2DID + ) + if virgl2Resource2DBackingAttached { + session.detachBacking(resourceID: preflightVirgl2Resource2DID) + } + session.unrefResource(id: preflightVirgl2Resource2DID) + } + if virgl2BufferCreated { + session.unrefResource(id: preflightVirgl2BufferResourceID) + } + if venusCreated { session.destroyContext(id: preflightVenusContextID) } + if virgl2Created { session.destroyContext(id: preflightVirgl2ContextID) } + } + do { + try session.createContext( + id: preflightVirgl2ContextID, + capsetID: 2, + name: "dory-preflight-virgl2" + ) + virgl2Created = true + // Linux creates ordinary PIPE_BUFFER resources immediately after desktop readiness. + // This exact allocation reaches vrend's glGenBuffersARB path, so it must succeed on + // the persistent owner pthread before this worker may advertise VirGL2. + let buffer = try DoryRendererResource3DCreatePayload( + target: 0, // Pinned Gallium ABI: PIPE_BUFFER. + format: 64, // Pinned virgl ABI: VIRGL_FORMAT_R8_UNORM. + bind: 1 << 4, // Pinned Gallium ABI: PIPE_BIND_VERTEX_BUFFER. + width: 4_096, + height: 1, + depth: 1, + arraySize: 1, + lastLevel: 0, + samples: 0, + flags: 0 + ) + try session.createResource3D( + DoryRendererForeignResource3DCreate( + resourceID: preflightVirgl2BufferResourceID, + payload: buffer + ) + ) + virgl2BufferCreated = true + // Match Dory's RESOURCE_CREATE_2D renderer translation exactly, then prove that the + // resulting resource is visible in the VirGL context by creating and destroying a + // surface object. This catches a silently ignored context attachment before VirGL2 is + // advertised to Linux. + let resource2D = try DoryRendererResource3DCreatePayload( + target: 2, // Pinned Gallium ABI: PIPE_TEXTURE_2D. + format: UInt32(DORY_VIRGL_RENDERER_FORMAT_BGRA8_UNORM), + bind: UInt32(DORY_VIRGL_RENDERER_RESOURCE_BIND_RENDER_TARGET) | + UInt32(DORY_VIRGL_RENDERER_RESOURCE_BIND_SCANOUT), + width: 4, + height: 4, + depth: 1, + arraySize: 1, + lastLevel: 0, + samples: 0, + flags: 1 // Pinned virgl ABI: VIRGL_RESOURCE_Y_0_TOP. + ) + try session.createResource3D( + DoryRendererForeignResource3DCreate( + resourceID: preflightVirgl2Resource2DID, + payload: resource2D + ) + ) + virgl2Resource2DCreated = true + session.attachResource( + contextID: preflightVirgl2ContextID, + resourceID: preflightVirgl2Resource2DID + ) + var resource2DBackingIOVec = iovec( + iov_base: resource2DBacking, + iov_len: resource2DBackingByteCount + ) + // Treat a foreign attach error as mutation-uncertain for cleanup: detaching is safe + // before unref and keeps the backing allocation alive through the teardown attempt. + virgl2Resource2DBackingAttached = true + try session.attachBacking( + resourceID: preflightVirgl2Resource2DID, + iovecs: &resource2DBackingIOVec, + iovecCount: 1 + ) + // Match RESOURCE_TRANSFER_TO_HOST_2D: ctx0, natural renderer strides, and the + // resource's retained backing rather than an operation-local iovec override. + try session.transfer( + toHost: true, + resourceID: preflightVirgl2Resource2DID, + contextID: 0, + payload: DoryRendererTransfer3DPayload( + level: 0, + stride: 0, + layerStride: 0, + offset: 0, + x: 0, + y: 0, + z: 0, + width: 4, + height: 4, + depth: 1 + ), + iovecs: nil, + iovecCount: 0 + ) + try submitVirgl2SurfaceLifecycle(session: session) + let resource = try DoryRendererResource3DCreatePayload( + target: 2, + format: UInt32(DORY_VIRGL_RENDERER_FORMAT_BGRA8_UNORM), + bind: UInt32(DORY_VIRGL_RENDERER_RESOURCE_BIND_RENDER_TARGET) | + UInt32(DORY_VIRGL_RENDERER_RESOURCE_BIND_SAMPLER_VIEW) | + UInt32(DORY_VIRGL_RENDERER_RESOURCE_BIND_SCANOUT), + width: 4, + height: 4, + depth: 1, + arraySize: 1, + lastLevel: 0, + samples: 0, + flags: 0 + ) + try session.createResource3D( + DoryRendererForeignResource3DCreate( + resourceID: preflightVirgl2ResourceID, + payload: resource + ) + ) + virgl2ResourceCreated = true + session.attachResource( + contextID: preflightVirgl2ContextID, + resourceID: preflightVirgl2ResourceID + ) + let texture = try session.acquireScanoutMetalTexture( + resourceID: preflightVirgl2ResourceID, + width: 4, + height: 4, + virglFormat: UInt32(DORY_VIRGL_RENDERER_FORMAT_BGRA8_UNORM), + stride: 16, + offset: 0 + ) + guard texture.textureType == .type2D, + texture.pixelFormat == .bgra8Unorm, + texture.width == 4, + texture.height == 4, + texture.depth == 1, + texture.arrayLength == 1, + texture.mipmapLevelCount == 1, + texture.sampleCount == 1, + texture.storageMode == .private, + texture.usage.contains([ + .shaderRead, + .shaderWrite, + .renderTarget, + ]), + let handle = texture.makeSharedTextureHandle(), + let imported = texture.device.makeSharedTexture(handle: handle), + imported.device === texture.device, + imported.pixelFormat == texture.pixelFormat, + imported.width == texture.width, + imported.height == texture.height, + imported.storageMode == .private, + imported.usage == texture.usage else { + throw DoryRendererWorkerBackendActivationError.virgl2Context + } + } catch { + throw DoryRendererWorkerBackendActivationError.virgl2Context + } + do { + try session.createContext( + id: preflightVenusContextID, + capsetID: 4, + name: "dory-preflight-venus" + ) + } catch { + throw DoryRendererWorkerBackendActivationError.venusContext + } + venusCreated = true + let blob: DoryRendererBlobCreatePayload + do { + blob = try DoryRendererBlobCreatePayload( + blobMemory: UInt32(DORY_VIRGL_RENDERER_BLOB_MEMORY_HOST3D), + blobFlags: UInt32(DORY_VIRGL_RENDERER_BLOB_FLAG_MAPPABLE), + // Venus reserves zero for a renderer-allocated, exportable SHM blob. + blobID: 0, + size: UInt64(getpagesize()) + ) + try session.createBlob( + DoryRendererForeignBlobCreate( + resourceID: preflightResourceID, + contextID: preflightVenusContextID, + payload: blob + ), + iovecs: nil, + iovecCount: 0 + ) + } catch { + throw DoryRendererWorkerBackendActivationError.sharedMemoryExport + } + blobCreated = true + let validated: ValidatedSHM + do { + let exported = try session.exportBlob(resourceID: preflightResourceID) + validated = try validateExportedSHM( + exported, + minimumBytes: UInt64(getpagesize()), + maximumBytes: UInt64(getpagesize()) * 16 + ) + } catch { + throw DoryRendererWorkerBackendActivationError.sharedMemoryExport + } + close(validated.fileDescriptor) + + let globalFenceDescriptor: Int32 + do { + // Prove the classic ctx0 callback path preserves a guest identity wider than VirGL's + // 32-bit callback token before a production receipt can advertise acceleration. + try session.createGlobalFence(fenceID: preflightGlobalFenceID) + globalFenceDescriptor = try session.exportFence(fenceID: preflightGlobalFenceID) + defer { close(globalFenceDescriptor) } + try waitForFenceCompletion( + descriptor: globalFenceDescriptor, + session: session + ) + } catch { + throw DoryRendererWorkerBackendActivationError.fenceExport + } + + let virgl2FenceDescriptor: Int32 + do { + try session.createFence( + contextID: preflightVirgl2ContextID, + flags: 0, + ringIndex: 0, + fenceID: preflightVirgl2FenceID + ) + virgl2FenceDescriptor = try session.exportFence(fenceID: preflightVirgl2FenceID) + defer { close(virgl2FenceDescriptor) } + try waitForFenceCompletion( + descriptor: virgl2FenceDescriptor, + session: session + ) + } catch { + throw DoryRendererWorkerBackendActivationError.fenceExport + } + + let venusFenceDescriptor: Int32 + do { + try session.createFence( + contextID: preflightVenusContextID, + flags: 0, + ringIndex: 0, + fenceID: preflightVenusFenceID + ) + venusFenceDescriptor = try session.exportFence(fenceID: preflightVenusFenceID) + defer { close(venusFenceDescriptor) } + try waitForFenceCompletion( + descriptor: venusFenceDescriptor, + session: session + ) + } catch { + throw DoryRendererWorkerBackendActivationError.fenceExport + } + return PreflightResult( + capsets: [virgl2, venus], + pollDescriptor: pollDescriptor + ) + } + + private static func submitVirgl2SurfaceLifecycle( + session: any DoryRendererForeignSession + ) throws { + // Pinned virgl ABI: CREATE_OBJECT/SURFACE has five payload dwords. Destroying the object in + // the same submit keeps activation cleanup complete even before the context is torn down. + let dwords: [UInt32] = [ + (5 << 16) | (8 << 8) | 1, + preflightVirgl2SurfaceObjectID, + preflightVirgl2Resource2DID, + UInt32(DORY_VIRGL_RENDERER_FORMAT_BGRA8_UNORM), + 0, // mip level + 0, // first layer 0, last layer 0 + (1 << 16) | (8 << 8) | 3, + preflightVirgl2SurfaceObjectID, + ] + let byteCount = dwords.count * MemoryLayout.stride + let alignedBytes = UnsafeMutableRawPointer.allocate(byteCount: byteCount, alignment: 8) + defer { alignedBytes.deallocate() } + dwords.withUnsafeBytes { source in + alignedBytes.copyMemory(from: source.baseAddress!, byteCount: byteCount) + } + try session.submit( + contextID: preflightVirgl2ContextID, + bytes: UnsafeRawPointer(alignedBytes), + dwordCount: UInt32(dwords.count) + ) + } + + private static func waitForFenceCompletion( + descriptor: Int32, + session: any DoryRendererForeignSession + ) throws { + guard descriptor >= 0, fcntl(descriptor, F_GETFD) >= 0 else { + throw DoryRendererWorkerBackendActivationError.fenceExport + } + let started = DispatchTime.now().uptimeNanoseconds + let timeoutNanoseconds = UInt64(preflightFenceTimeoutMilliseconds) * 1_000_000 + while true { + session.poll() + var event = pollfd( + fd: descriptor, + events: Int16(POLLIN | POLLHUP), + revents: 0 + ) + let status = Darwin.poll(&event, 1, 5) + if status > 0 { + guard event.revents & Int16(POLLNVAL | POLLERR) == 0, + event.revents & Int16(POLLIN | POLLHUP) != 0 else { + throw DoryRendererWorkerBackendActivationError.fenceExport + } + return + } + if status < 0, errno != EINTR { + throw DoryRendererWorkerBackendActivationError.fenceExport + } + let now = DispatchTime.now().uptimeNanoseconds + guard now >= started, now - started < timeoutNanoseconds else { + throw DoryRendererWorkerBackendActivationError.fenceExport + } + } + } + + private struct ValidatedSHM { + let fileDescriptor: Int32 + let fileSize: UInt64 + } + + private static func validateExportedSHM( + _ exported: DoryRendererForeignExportedBlob, + minimumBytes: UInt64, + maximumBytes: UInt64 + ) throws -> ValidatedSHM { + let descriptor = exported.ownedFileDescriptor + guard exported.type == UInt32(DORY_VIRGL_RENDERER_BLOB_FD_TYPE_SHM), + descriptor >= 0 else { + if descriptor >= 0 { close(descriptor) } + throw DoryRendererForeignSessionError.invalidResult(operation: "non-shm-export") + } + var status = stat() + guard fstat(descriptor, &status) == 0 else { + close(descriptor) + throw DoryRendererForeignSessionError.invalidResult(operation: "shm-export-stat") + } + // Darwin POSIX SHM descriptors report only their access mode (normally 0600), without + // S_IFREG or another S_IF* type bit. An unlinked temporary filesystem file still reports + // S_IFREG. Accept exactly those two shapes, then prove the descriptor has the private, + // bounded, read/write-mappable semantics the zero-copy path requires. + guard DoryRendererSharedMemoryDescriptorPolicy.accepts(mode: status.st_mode), + status.st_nlink == 0, + status.st_size > 0, + UInt64(status.st_size) >= minimumBytes, + UInt64(status.st_size) <= maximumBytes else { + close(descriptor) + throw DoryRendererForeignSessionError.invalidResult(operation: "shm-export-bounds") + } + let probeLength = Swift.min(Int(status.st_size), Int(getpagesize())) + let probe = mmap( + nil, + probeLength, + PROT_READ | PROT_WRITE, + MAP_SHARED, + descriptor, + 0 + ) + guard probe != MAP_FAILED, let probe else { + close(descriptor) + throw DoryRendererForeignSessionError.invalidResult(operation: "shm-export-mapping") + } + guard munmap(probe, probeLength) == 0 else { + close(descriptor) + throw DoryRendererForeignSessionError.invalidResult(operation: "shm-export-unmapping") + } + let descriptorFlags = fcntl(descriptor, F_GETFD) + guard descriptorFlags >= 0, + fcntl(descriptor, F_SETFD, descriptorFlags | FD_CLOEXEC) == 0 else { + close(descriptor) + throw DoryRendererForeignSessionError.invalidResult(operation: "shm-export-cloexec") + } + return ValidatedSHM( + fileDescriptor: descriptor, + fileSize: UInt64(status.st_size) + ) + } + + private func logScanoutRejection( + active: ActiveState, + resourceID: UInt32, + reason: String + ) { + guard active.loggedScanoutRejections.insert(resourceID).inserted else { return } + Self.logger.error( + "scanout-rejected resource=\(resourceID, privacy: .public) reason=\(reason, privacy: .public)" + ) + } + + private func matchingResource( + _ command: DoryRendererWorkerCommand, + active: ActiveState + ) -> ResourceState? { + guard let resource = active.resources[command.resourceID], + resource.generation == command.resourceGeneration else { return nil } + return resource + } + + private func nextResourceGeneration( + resourceID: UInt32, + active: ActiveState + ) throws -> UInt64 { + let previous = active.lastResourceGenerations[resourceID] ?? 0 + let (next, overflow) = previous.addingReportingOverflow(1) + guard !overflow, next != 0 else { + throw DoryRendererForeignSessionError.invalidResult(operation: "resource-generation") + } + active.lastResourceGenerations[resourceID] = next + return next + } + + private func teardownLocked(_ active: ActiveState) { + active.pollDriver?.cancel() + active.pollDriver = nil + active.leases.removeAll() + active.resources.removeAll() + active.contexts.removeAll() + active.session.invalidate() + } + + private func pollForeignEvents() { + _ = try? executionLane.sync { [self] in + lock.lock() + defer { lock.unlock() } + guard case .active(let active) = state else { return } + active.session.poll() + } + } + + private static func encodeUInt64(_ value: UInt64) -> Data { + Swift.withUnsafeBytes(of: value.littleEndian) { Data($0) } + } + + private func errorForProtocolViolation() -> Error { + DoryRendererForeignSessionError.invalidResult(operation: "typed-operation-payload") + } +} + +private final class OwnedBacking { + let iovecs: UnsafeMutablePointer + let count: UInt32 + private let mappings: [OwnedMapping] + + init( + regions: [DoryRendererSharedRegionReference], + descriptors: [FileHandle] + ) throws { + guard !regions.isEmpty, + regions.count <= Int(UInt32.max), + descriptors.count == (regions.map(\.descriptorIndex).max().map { Int($0) + 1 } ?? 0) + else { + throw DoryRendererWorkerContractError.invalidSharedRegionCount( + limit: Int(UInt32.max), + actual: regions.count + ) + } + var created = [OwnedMapping]() + created.reserveCapacity(regions.count) + for region in regions { + let index = Int(region.descriptorIndex) + guard descriptors.indices.contains(index) else { + throw DoryRendererWorkerContractError.descriptorCountMismatch( + expected: index + 1, + actual: descriptors.count + ) + } + created.append(try OwnedMapping( + region: region, + source: descriptors[index].fileDescriptor + )) + } + mappings = created + count = UInt32(created.count) + iovecs = .allocate(capacity: created.count) + for (index, mapping) in created.enumerated() { + iovecs.advanced(by: index).initialize(to: iovec( + iov_base: mapping.regionBase, + iov_len: mapping.regionLength + )) + } + } + + deinit { + iovecs.deinitialize(count: Int(count)) + iovecs.deallocate() + } +} + +private final class OwnedMapping { + let mappingBase: UnsafeMutableRawPointer + let mappingLength: Int + let regionBase: UnsafeMutableRawPointer + let regionLength: Int + + init(region: DoryRendererSharedRegionReference, source: Int32) throws { + let descriptor = fcntl(source, F_DUPFD_CLOEXEC, 0) + guard descriptor >= 0 else { throw POSIXError(.EMFILE) } + defer { close(descriptor) } + let pageSize = UInt64(getpagesize()) + let mappingOffset = region.offset - region.offset % pageSize + let delta = region.offset - mappingOffset + let (byteCount, overflow) = delta.addingReportingOverflow(region.length) + guard !overflow, byteCount <= UInt64(Int.max), mappingOffset <= UInt64(Int64.max), + region.length <= UInt64(Int.max) else { + throw DoryRendererWorkerContractError.invalidSharedRegionBounds + } + let protection = region.access == .readOnly ? PROT_READ : PROT_READ | PROT_WRITE + let mapped = mmap( + nil, + Int(byteCount), + protection, + MAP_SHARED, + descriptor, + off_t(mappingOffset) + ) + guard mapped != MAP_FAILED, let mapped else { throw POSIXError(.ENOMEM) } + mappingBase = mapped + mappingLength = Int(byteCount) + regionBase = mapped.advanced(by: Int(delta)) + regionLength = Int(region.length) + } + + deinit { munmap(mappingBase, mappingLength) } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryRendererWorkerXPCService/DoryRendererWorkerMain.swift b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerXPCService/DoryRendererWorkerMain.swift new file mode 100644 index 00000000..4ad002b2 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryRendererWorkerXPCService/DoryRendererWorkerMain.swift @@ -0,0 +1,98 @@ +import Darwin +import DoryRendererWorkerContracts +import DoryRendererWorkerMetalTransport +import DoryRendererWorkerServiceCore +import DoryRendererWorkerVirglBackend +import Foundation +import Metal +import XPC + +private enum DoryRendererWorkerBackendFactory { + static func make() -> any DoryRendererWorkerBackend { + do { + return try DoryRendererWorkerVirglBackend() + } catch { + // A standalone executable, invalid nested-bundle layout, or missing fixed production + // authority must never silently become an in-process or software renderer. + return DoryRendererWorkerFailClosedBackend() + } + } +} + +private final class DoryRendererWorkerXPCAdapter: + NSObject, + DoryRendererWorkerXPCProtocol +{ + private let service = DoryRendererWorkerService( + backend: DoryRendererWorkerBackendFactory.make() + ) + + func bootstrap(_ request: Data, withReply reply: @escaping (Data) -> Void) { + reply(service.bootstrap(exactBytes: request)) + } + + func exchange( + _ frame: Data, + descriptors: [FileHandle], + withReply reply: @escaping (Data, [FileHandle], MTLSharedTextureHandle?) -> Void + ) { + let result = service.exchange(exactFrame: frame, descriptors: descriptors) + reply(result.result, result.descriptors, result.sharedTextureHandle) + } +} + +private final class DoryRendererWorkerListenerDelegate: + NSObject, + NSXPCListenerDelegate, + @unchecked Sendable +{ + private let admissionLock = NSLock() + private let adapter = DoryRendererWorkerXPCAdapter() + private var acceptedConnection = false + + func listener( + _ listener: NSXPCListener, + shouldAcceptNewConnection connection: NSXPCConnection + ) -> Bool { + guard connection.processIdentifier > 1, + connection.effectiveUserIdentifier == geteuid(), + connection.effectiveGroupIdentifier == getegid() else { + return false + } + let claimed = admissionLock.withLock { + guard !acceptedConnection else { return false } + acceptedConnection = true + return true + } + guard claimed else { return false } + // This service owns one renderer generation, foreign-library state, shared mappings, and + // live scanout/fence leases for the complete accepted connection. Keep launchd from + // idle-killing it between bounded command batches; invalidation terminates the process, so + // there is deliberately no reconnect or transaction-end path. + xpc_transaction_begin() + connection.setCodeSigningRequirement( + DoryRendererWorkerIdentity.runnerCodeSigningRequirement + ) + connection.exportedInterface = DoryRendererWorkerXPCInterface.make() + connection.exportedObject = adapter + connection.interruptionHandler = Self.terminate + connection.invalidationHandler = Self.terminate + connection.activate() + return true + } + + private static func terminate() { + Darwin._exit(EXIT_SUCCESS) + } +} + +@main +private enum DoryRendererWorkerMain { + private static let listenerDelegate = DoryRendererWorkerListenerDelegate() + + static func main() { + let listener = NSXPCListener.service() + listener.delegate = listenerDelegate + listener.resume() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/DoryVirglRendererSession.c b/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/DoryVirglRendererSession.c new file mode 100644 index 00000000..2a8ce1c9 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/DoryVirglRendererSession.c @@ -0,0 +1,2187 @@ +#include "DoryVirglRendererShim.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(DORY_VIRGL_RENDERER_DUAL_METAL) +#include +#include +#endif + +enum { + DORY_VIRGL_RENDERER_CALLBACKS_VERSION = 4, + DORY_VIRGL_RENDERER_LOG_LEVEL_ERROR = 3, + DORY_VIRGL_CREATE_OBJECT_SUBTYPE_ABSENT = 0, + DORY_VIRGL_CREATE_OBJECT_SUBTYPE_PRESENT = 1, + DORY_VIRGL_CREATE_OBJECT_SUBTYPE_AMBIGUOUS = 2, + DORY_VIRGL_FAILED_COMMAND_LOCATION_ABSENT = 0, + DORY_VIRGL_FAILED_COMMAND_LOCATION_PRESENT = 1, + DORY_VIRGL_FAILED_COMMAND_LOCATION_AMBIGUOUS = 2, + DORY_VIRGL_CREATE_OBJECT_CANDIDATE_COUNT_MAX = 255, + DORY_VIRGL_CREATE_OBJECT_SUBTYPE_MAX = 11, + DORY_VIRGL_OBJECT_SURFACE = 8, + DORY_VIRGL_SURFACE_FAILURE_REASON_NONE = 0, + DORY_VIRGL_SURFACE_FAILURE_REASON_MAX = 7, + DORY_VIRGL_PRECURSOR_NONE = 0, + DORY_VIRGL_PRECURSOR_SHADER_COMPILE_FAILED = 1, + DORY_VIRGL_PRECURSOR_TGSI_ASSIGNMENT_FAILED = 2, + DORY_VIRGL_PRECURSOR_GEOMETRY_SHADER_UNSUPPORTED = 3, + DORY_VIRGL_PRECURSOR_TESSELLATION_SHADER_UNSUPPORTED = 4, + DORY_VIRGL_PRECURSOR_COMPUTE_SHADER_UNSUPPORTED = 5, + DORY_VIRGL_PRECURSOR_INVALID_EXPECTED_TOKEN_COUNT = 6, + DORY_VIRGL_PRECURSOR_EXPECTED_LONG_CONTINUATION = 7, + DORY_VIRGL_PRECURSOR_INVALID_CONTINUATION_HANDLE = 8, + DORY_VIRGL_PRECURSOR_CONTINUATION_WITHOUT_ORIGINAL = 9, + DORY_VIRGL_PRECURSOR_MISMATCHED_CONTINUATION = 10, + DORY_VIRGL_PRECURSOR_OVERSIZED_CONTINUATION = 11, +}; + +typedef void (*DoryVirglRendererLogCallback)( + int32_t log_level, + const char *message, + void *user_data +); +typedef void (*DoryVirglRendererFreeDataCallback)(void *user_data); + +/* + * App Sandbox permits POSIX shared-memory names only inside an app group. This short macOS-only + * group is dedicated to the renderer worker and deliberately differs from Dory's data-sharing + * group. virglrenderer's anonymous-file helper reads this fixed integration value when it creates + * timeline and blob SHM; an inherited value must never select a broader group. + */ +static const char dory_renderer_app_sandbox_group[] = + "864H636QW4.dory-renderer"; + +#if defined(DORY_VIRGL_RENDERER_STATIC_LINKED) +extern int32_t virgl_renderer_init( + void *, + int32_t, + DoryVirglRendererCallbacks * +); +extern void virgl_renderer_cleanup(void *); +extern void virgl_renderer_get_cap_set( + uint32_t, + uint32_t *, + uint32_t * +); +extern void virgl_renderer_fill_caps( + uint32_t, + uint32_t, + void * +); +extern int32_t virgl_renderer_context_create_with_flags( + uint32_t, + uint32_t, + uint32_t, + const char * +); +extern void virgl_renderer_context_destroy(uint32_t); +extern void virgl_renderer_ctx_attach_resource( + int32_t, + int32_t +); +extern void virgl_renderer_ctx_detach_resource( + int32_t, + int32_t +); +extern int32_t virgl_renderer_submit_cmd2( + void *, + int32_t, + int32_t, + uint64_t *, + uint32_t +); +extern int32_t virgl_renderer_resource_create_blob( + const DoryVirglRendererBlobCreateArguments * +); +extern int32_t virgl_renderer_resource_create( + DoryVirglRendererResource3DCreateArguments *, + struct iovec *, + uint32_t +); +extern int32_t virgl_renderer_resource_attach_iov( + int32_t, + struct iovec *, + int32_t +); +extern void virgl_renderer_resource_detach_iov( + int32_t, + struct iovec **, + int32_t * +); +extern void virgl_renderer_resource_unref(uint32_t); +extern int32_t virgl_renderer_resource_get_map_info( + uint32_t, + uint32_t * +); +extern int32_t virgl_renderer_resource_export_blob( + uint32_t, + uint32_t *, + int * +); +extern int32_t virgl_renderer_resource_get_info( + int32_t, + DoryVirglRendererResourceInfo * +); +extern int32_t virgl_renderer_create_handle_for_scanout( + uint32_t, + uint32_t, + uint32_t, + uint32_t, + uint32_t, + uint32_t, + uint32_t, + void ** +); +extern void virgl_renderer_release_handle_for_scanout(int32_t, void *); +extern int32_t virgl_renderer_transfer_write_iov( + uint32_t, + uint32_t, + int32_t, + uint32_t, + uint32_t, + DoryVirglRendererBox *, + uint64_t, + struct iovec *, + uint32_t +); +extern int32_t virgl_renderer_transfer_read_iov( + uint32_t, + uint32_t, + uint32_t, + uint32_t, + uint32_t, + DoryVirglRendererBox *, + uint64_t, + struct iovec *, + int32_t +); +extern int32_t virgl_renderer_context_create_fence( + uint32_t, + uint32_t, + uint32_t, + uint64_t +); +extern int32_t virgl_renderer_create_fence(int32_t, uint32_t); +extern int32_t virgl_renderer_get_poll_fd(void); +extern void virgl_renderer_poll(void); +extern void virgl_set_log_callback( + DoryVirglRendererLogCallback, + void *, + DoryVirglRendererFreeDataCallback +); +#endif + +typedef struct DoryVirglRendererFunctions { + int32_t (*initialize)(void *, int32_t, DoryVirglRendererCallbacks *); + void (*cleanup)(void *); + void (*get_cap_set)(uint32_t, uint32_t *, uint32_t *); + void (*fill_caps)(uint32_t, uint32_t, void *); + int32_t (*context_create)(uint32_t, uint32_t, uint32_t, const char *); + void (*context_destroy)(uint32_t); + void (*context_attach_resource)(int32_t, int32_t); + void (*context_detach_resource)(int32_t, int32_t); + int32_t (*submit_cmd2)(void *, int32_t, int32_t, uint64_t *, uint32_t); + int32_t (*blob_create)(const DoryVirglRendererBlobCreateArguments *); + int32_t (*resource_create)( + DoryVirglRendererResource3DCreateArguments *, + struct iovec *, + uint32_t + ); + int32_t (*resource_attach_iov)(int32_t, struct iovec *, int32_t); + void (*resource_detach_iov)(int32_t, struct iovec **, int32_t *); + void (*resource_unref)(uint32_t); + int32_t (*resource_get_map_info)(uint32_t, uint32_t *); + int32_t (*resource_export_blob)(uint32_t, uint32_t *, int *); + int32_t (*resource_get_info)(int32_t, DoryVirglRendererResourceInfo *); + int32_t (*create_handle_for_scanout)( + uint32_t, + uint32_t, + uint32_t, + uint32_t, + uint32_t, + uint32_t, + uint32_t, + void ** + ); + void (*release_handle_for_scanout)(int32_t, void *); + int32_t (*transfer_write_iov)( + uint32_t, + uint32_t, + int32_t, + uint32_t, + uint32_t, + DoryVirglRendererBox *, + uint64_t, + struct iovec *, + uint32_t + ); + int32_t (*transfer_read_iov)( + uint32_t, + uint32_t, + uint32_t, + uint32_t, + uint32_t, + DoryVirglRendererBox *, + uint64_t, + struct iovec *, + int32_t + ); + int32_t (*context_create_fence)(uint32_t, uint32_t, uint32_t, uint64_t); + int32_t (*create_fence)(int32_t, uint32_t); + int32_t (*get_poll_fd)(void); + void (*poll)(void); + void (*set_log_callback)( + DoryVirglRendererLogCallback, + void *, + DoryVirglRendererFreeDataCallback + ); +} DoryVirglRendererFunctions; + +typedef struct DoryVirglRendererFenceCompletion { + uint32_t context_id; + uint32_t ring_index; + uint64_t fence_id; + /* Nonzero only while a global fence is waiting for virglrenderer's 32-bit callback. */ + uint32_t renderer_fence_id; + int read_descriptor; + int write_descriptor; + struct DoryVirglRendererFenceCompletion *next; +} DoryVirglRendererFenceCompletion; + +struct DoryVirglRendererSession { + DoryVirglRendererFunctions functions; + DoryVirglRendererCallbacks callbacks; + pthread_mutex_t fence_lock; + DoryVirglRendererFenceCompletion *fence_head; + DoryVirglRendererFenceCompletion *fence_tail; + uint32_t next_global_renderer_fence_id; + pthread_mutex_t submit_diagnostic_lock; + uint32_t active_submit_context_id; + DoryVirglRendererSubmitDiagnostic submit_diagnostic; + bool fence_lock_initialized; + bool submit_diagnostic_lock_initialized; + bool submit_in_progress; + bool log_callback_installed; + bool renderer_initialized; + bool owns_process_slot; +#if defined(DORY_VIRGL_RENDERER_DUAL_METAL) + pthread_mutex_t angle_context_lock; + bool angle_context_lock_initialized; + EGLDisplay angle_display; + EGLConfig angle_config; + EGLContext angle_share_context; +#endif +}; + +enum { DORY_VIRGL_RENDERER_CONTEXT_COMMAND_COUNT = 64 }; + +/* Pinned `vrend_debug.c` command_names table. The callback stores only the matched ordinal. */ +static const char *const dory_virgl_context_command_names[ + DORY_VIRGL_RENDERER_CONTEXT_COMMAND_COUNT +] = { + "NOP", + "CREATE_OBJECT", + "BIND_OBJECT", + "DESTROY_OBJECT", + "SET_VIEWPORT_STATE", + "SET_FRAMEBUFFER_STATE", + "SET_VERTEX_BUFFERS", + "CLEAR", + "DRAW_VBO", + "RESOURCE_INLINE_WRITE", + "SET_SAMPLER_VIEWS", + "SET_INDEX_BUFFER", + "SET_CONSTANT_BUFFER", + "SET_STENCIL_REF", + "SET_BLEND_COLOR", + "SET_SCISSOR_STATE", + "BLIT", + "RESOURCE_COPY_REGION", + "BIND_SAMPLER_STATES", + "BEGIN_QUERY", + "END_QUERY", + "GET_QUERY_RESULT", + "SET_POLYGON_STIPPLE", + "SET_CLIP_STATE", + "SET_SAMPLE_MASK", + "SET_STREAMOUT_TARGETS", + "SET_RENDER_CONDITION", + "SET_UNIFORM_BUFFER", + "SET_SUB_CTX", + "CREATE_SUB_CTX", + "DESTROY_SUB_CTX", + "BIND_SHADER", + "SET_TESS_STATE", + "SET_MIN_SAMPLES", + "SET_SHADER_BUFFERS", + "SET_SHADER_IMAGES", + "MEMORY_BARRIER", + "LAUNCH_GRID", + "SET_FRAMEBUFFER_STATE_NO_ATTACH", + "TEXTURE_BARRIER", + "SET_ATOMIC_BUFFERS", + "SET_DEBUG_FLAGS", + "GET_QUERY_RESULT_QBO", + "TRANSFER3D", + "END_TRANSFERS", + "COPY_TRANSFER3D", + "SET_TWEAKS", + "CLEAR_TEXTURE", + "PIPE_RESOURCE_CREATE", + "PIPE_RESOURCE_SET_TYPE", + "GET_MEMORY_INFO", + "SEND_STRING_MARKER", + "LINK_SHADER", + "CREATE_VIDEO_CODEC", + "DESTROY_VIDEO_CODEC", + "CREATE_VIDEO_BUFFER", + "DESTROY_VIDEO_BUFFER", + "BEGIN_FRAME", + "DECODE_MACROBLOCK", + "DECODE_BITSTREAM", + "ENCODE_BITSTREAM", + "END_FRAME", + "CLEAR_SURFACE", + "GET_PIPE_RESOURCE_LAYOUT", +}; + +static bool dory_parse_uint32(const char **cursor, uint32_t *value) +{ + const char *current = *cursor; + if (*current < '0' || *current > '9') + return false; + uint64_t parsed = 0; + do { + parsed = parsed * 10U + (uint64_t)(*current - '0'); + if (parsed > UINT32_MAX) + return false; + current++; + } while (*current >= '0' && *current <= '9'); + *cursor = current; + *value = (uint32_t)parsed; + return true; +} + +static bool dory_parse_canonical_uint32(const char **cursor, uint32_t *value) +{ + const char *start = *cursor; + if (!dory_parse_uint32(cursor, value)) + return false; + return start[0] != '0' || *cursor == start + 1; +} + +static bool dory_parse_int32(const char **cursor, int32_t *value) +{ + const char *current = *cursor; + const bool negative = *current == '-'; + if (negative) + current++; + if (*current < '0' || *current > '9') + return false; + uint64_t parsed = 0; + const uint64_t limit = negative ? (uint64_t)INT32_MAX + 1U : (uint64_t)INT32_MAX; + do { + parsed = parsed * 10U + (uint64_t)(*current - '0'); + if (parsed > limit) + return false; + current++; + } while (*current >= '0' && *current <= '9'); + *cursor = current; + *value = negative + ? (parsed == (uint64_t)INT32_MAX + 1U ? INT32_MIN : -(int32_t)parsed) + : (int32_t)parsed; + return true; +} + +static bool dory_parse_canonical_int32(const char **cursor, int32_t *value) +{ + const char *start = *cursor; + if (!dory_parse_int32(cursor, value)) + return false; + const char *digits = start[0] == '-' ? start + 1 : start; + if (digits[0] == '0' && *cursor != digits + 1) + return false; + return start[0] != '-' || *value != 0; +} + +static bool dory_match_context_command( + const char *name, + size_t length, + uint32_t *command_id +) +{ + for (uint32_t index = 0; + index < DORY_VIRGL_RENDERER_CONTEXT_COMMAND_COUNT; + index++) { + const char *candidate = dory_virgl_context_command_names[index]; + if (strlen(candidate) == length && memcmp(candidate, name, length) == 0) { + *command_id = index; + return true; + } + } + return false; +} + +int32_t DoryVirglRendererClassifySubmitDiagnosticMessage( + const char *message, + uint32_t expected_context_id, + DoryVirglRendererSubmitDiagnostic *diagnostic +) +{ + if (message == NULL || expected_context_id == 0 || diagnostic == NULL) + return -EINVAL; + *diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + + /* No general renderer log is accepted. Bound the read before matching the complete grammar. */ + const size_t message_length = strnlen(message, 161); + if (message_length == 161) + return 0; + static const char prefix[] = "context "; + static const char dispatch[] = " failed to dispatch "; + const char *cursor = message; + if (strncmp(cursor, prefix, sizeof(prefix) - 1U) != 0) + return 0; + cursor += sizeof(prefix) - 1U; + + uint32_t context_id = 0; + if (!dory_parse_uint32(&cursor, &context_id) || + context_id != expected_context_id || + strncmp(cursor, dispatch, sizeof(dispatch) - 1U) != 0) + return 0; + cursor += sizeof(dispatch) - 1U; + + const char *command_start = cursor; + while ((*cursor >= 'A' && *cursor <= 'Z') || + (*cursor >= '0' && *cursor <= '9') || + *cursor == '_') + cursor++; + const size_t command_length = (size_t)(cursor - command_start); + if (command_length == 0 || cursor[0] != ':' || cursor[1] != ' ') + return 0; + cursor += 2; + + int32_t status = 0; + if (!dory_parse_int32(&cursor, &status) || cursor[0] != '\n' || cursor[1] != '\0') + return 0; + uint32_t command_id = 0; + if (!dory_match_context_command(command_start, command_length, &command_id)) + return 0; + + diagnostic->valid = 1; + diagnostic->context_id = context_id; + diagnostic->command_id = command_id; + diagnostic->status = status; + return 1; +} + +int32_t DoryVirglRendererClassifyExactSubmitDiagnosticMessage( + const char *message, + uint32_t expected_context_id, + DoryVirglRendererSubmitDiagnostic *diagnostic +) +{ + if (message == NULL || expected_context_id == 0 || diagnostic == NULL) + return -EINVAL; + *diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + + static const char prefix[] = "vrend-dispatch-error context="; + if (strncmp(message, prefix, sizeof(prefix) - 1U) != 0) + return 0; + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_AMBIGUOUS; + + /* The exact generated line is shorter than 161 bytes even at all integer extrema. */ + if (strnlen(message, 161) == 161) + return -EINVAL; + const char *cursor = message + sizeof(prefix) - 1U; + static const char command_literal[] = " command="; + static const char offset_literal[] = " dword-offset="; + static const char ordinal_literal[] = " command-ordinal="; + static const char status_literal[] = " status="; + static const char surface_reason_literal[] = " surface-reason="; + + uint32_t context_id = 0; + uint32_t command_id = 0; + uint32_t dword_offset = 0; + uint32_t command_ordinal = 0; + uint32_t surface_failure_reason = 0; + int32_t status = 0; + if (!dory_parse_canonical_uint32(&cursor, &context_id) || + context_id != expected_context_id || + strncmp(cursor, command_literal, sizeof(command_literal) - 1U) != 0) + return -EINVAL; + cursor += sizeof(command_literal) - 1U; + if (!dory_parse_canonical_uint32(&cursor, &command_id) || + command_id >= DORY_VIRGL_RENDERER_CONTEXT_COMMAND_COUNT || + strncmp(cursor, offset_literal, sizeof(offset_literal) - 1U) != 0) + return -EINVAL; + cursor += sizeof(offset_literal) - 1U; + if (!dory_parse_canonical_uint32(&cursor, &dword_offset) || + strncmp(cursor, ordinal_literal, sizeof(ordinal_literal) - 1U) != 0) + return -EINVAL; + cursor += sizeof(ordinal_literal) - 1U; + if (!dory_parse_canonical_uint32(&cursor, &command_ordinal) || + strncmp(cursor, status_literal, sizeof(status_literal) - 1U) != 0) + return -EINVAL; + cursor += sizeof(status_literal) - 1U; + if (!dory_parse_canonical_int32(&cursor, &status) || + strncmp( + cursor, + surface_reason_literal, + sizeof(surface_reason_literal) - 1U + ) != 0) + return -EINVAL; + cursor += sizeof(surface_reason_literal) - 1U; + if (!dory_parse_canonical_uint32(&cursor, &surface_failure_reason) || + surface_failure_reason > DORY_VIRGL_SURFACE_FAILURE_REASON_MAX || + cursor[0] != '\n' || cursor[1] != '\0') + return -EINVAL; + + diagnostic->valid = 1; + diagnostic->context_id = context_id; + diagnostic->command_id = command_id; + diagnostic->status = status; + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_PRESENT; + diagnostic->failed_command_dword_offset = dword_offset; + diagnostic->failed_command_ordinal = command_ordinal; + diagnostic->surface_failure_reason = surface_failure_reason; + return 1; +} + +int32_t DoryVirglRendererCorrelateCreateObjectSubtype( + const void *command_bytes, + uint32_t dword_count, + DoryVirglRendererSubmitDiagnostic *diagnostic +) +{ + if (command_bytes == NULL || dword_count == 0 || diagnostic == NULL) + return -EINVAL; + diagnostic->create_object_subtype_disposition = + DORY_VIRGL_CREATE_OBJECT_SUBTYPE_ABSENT; + diagnostic->create_object_subtype = 0; + diagnostic->create_object_candidate_count = 0; + diagnostic->create_object_subtype_mask = 0; + + const uint32_t location_disposition = + diagnostic->failed_command_location_disposition; + const uint32_t expected_offset = diagnostic->failed_command_dword_offset; + const uint32_t expected_ordinal = diagnostic->failed_command_ordinal; + if (location_disposition == DORY_VIRGL_FAILED_COMMAND_LOCATION_ABSENT) + diagnostic->surface_failure_reason = DORY_VIRGL_SURFACE_FAILURE_REASON_NONE; + if (location_disposition != DORY_VIRGL_FAILED_COMMAND_LOCATION_ABSENT && + location_disposition != DORY_VIRGL_FAILED_COMMAND_LOCATION_PRESENT) { + const uint32_t precursor = diagnostic->precursor_category; + *diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_AMBIGUOUS; + diagnostic->precursor_category = precursor; + return 0; + } + + uint64_t offset = 0; + uint32_t header_ordinal = 0; + uint32_t match_count = 0; + uint32_t subtype_mask = 0; + bool invalid_subtype = false; + while (offset < dword_count) { + uint32_t header = 0; + memcpy( + &header, + (const uint8_t *)command_bytes + offset * sizeof(uint32_t), + sizeof(header) + ); + const uint32_t command_id = header & 0xffU; + const uint32_t object_type = (header >> 8U) & 0xffU; + const uint32_t payload_dwords = header >> 16U; + const uint64_t next = offset + (uint64_t)payload_dwords + 1U; + if (command_id >= DORY_VIRGL_RENDERER_CONTEXT_COMMAND_COUNT || + next > dword_count) { + if (location_disposition == DORY_VIRGL_FAILED_COMMAND_LOCATION_PRESENT) { + const uint32_t precursor = diagnostic->precursor_category; + *diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_AMBIGUOUS; + diagnostic->precursor_category = precursor; + } + return 0; + } + + if (location_disposition == DORY_VIRGL_FAILED_COMMAND_LOCATION_PRESENT) { + if (offset == expected_offset || header_ordinal == expected_ordinal) { + if (offset != expected_offset || header_ordinal != expected_ordinal || + diagnostic->valid != 1 || command_id != diagnostic->command_id || + (command_id == 1U && + object_type > DORY_VIRGL_CREATE_OBJECT_SUBTYPE_MAX)) { + const uint32_t precursor = diagnostic->precursor_category; + *diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_AMBIGUOUS; + diagnostic->precursor_category = precursor; + return 0; + } + if (command_id == 1U) { + diagnostic->create_object_subtype_disposition = + DORY_VIRGL_CREATE_OBJECT_SUBTYPE_PRESENT; + diagnostic->create_object_subtype = object_type; + diagnostic->create_object_candidate_count = 1; + diagnostic->create_object_subtype_mask = 1U << object_type; + } + if (command_id != 1U || + object_type != DORY_VIRGL_OBJECT_SURFACE || + diagnostic->status != EINVAL || + diagnostic->surface_failure_reason > + DORY_VIRGL_SURFACE_FAILURE_REASON_MAX) + diagnostic->surface_failure_reason = + DORY_VIRGL_SURFACE_FAILURE_REASON_NONE; + return 0; + } + if (offset > expected_offset || header_ordinal > expected_ordinal) { + const uint32_t precursor = diagnostic->precursor_category; + *diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_AMBIGUOUS; + diagnostic->precursor_category = precursor; + return 0; + } + } + + if (command_id == 1U) { + if (match_count < DORY_VIRGL_CREATE_OBJECT_CANDIDATE_COUNT_MAX) + match_count++; + if (object_type <= DORY_VIRGL_CREATE_OBJECT_SUBTYPE_MAX) { + subtype_mask |= 1U << object_type; + } else { + invalid_subtype = true; + } + } + offset = next; + header_ordinal++; + } + + if (location_disposition == DORY_VIRGL_FAILED_COMMAND_LOCATION_PRESENT) { + const uint32_t precursor = diagnostic->precursor_category; + *diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_AMBIGUOUS; + diagnostic->precursor_category = precursor; + return 0; + } + + diagnostic->failed_command_dword_offset = 0; + diagnostic->failed_command_ordinal = 0; + diagnostic->surface_failure_reason = DORY_VIRGL_SURFACE_FAILURE_REASON_NONE; + diagnostic->create_object_candidate_count = match_count; + diagnostic->create_object_subtype_mask = invalid_subtype ? 0 : subtype_mask; + return 0; +} + +int32_t DoryVirglRendererClassifyCreateObjectSubtype( + const void *command_bytes, + uint32_t dword_count, + DoryVirglRendererSubmitDiagnostic *diagnostic +) +{ + if (diagnostic == NULL) + return -EINVAL; + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_ABSENT; + diagnostic->failed_command_dword_offset = 0; + diagnostic->failed_command_ordinal = 0; + return DoryVirglRendererCorrelateCreateObjectSubtype( + command_bytes, + dword_count, + diagnostic + ); +} + +uint32_t DoryVirglRendererClassifySubmitPrecursorMessage(const char *message) +{ + if (message == NULL) + return DORY_VIRGL_PRECURSOR_NONE; +#define DORY_PREFIX_CATEGORY(prefix, category) \ + if (strncmp(message, prefix, sizeof(prefix) - 1U) == 0) \ + return category + DORY_PREFIX_CATEGORY( + "Shader failed to compile\n", + DORY_VIRGL_PRECURSOR_SHADER_COMPILE_FAILED + ); + DORY_PREFIX_CATEGORY( + "Error assigning TGSI\n", + DORY_VIRGL_PRECURSOR_TGSI_ASSIGNMENT_FAILED + ); + DORY_PREFIX_CATEGORY( + "Geometry shader not supported\n", + DORY_VIRGL_PRECURSOR_GEOMETRY_SHADER_UNSUPPORTED + ); + DORY_PREFIX_CATEGORY( + "Tesselation shaders not supported\n", + DORY_VIRGL_PRECURSOR_TESSELLATION_SHADER_UNSUPPORTED + ); + DORY_PREFIX_CATEGORY( + "Compute shaders not supported\n", + DORY_VIRGL_PRECURSOR_COMPUTE_SHADER_UNSUPPORTED + ); + DORY_PREFIX_CATEGORY( + "Invalid expected token count\n", + DORY_VIRGL_PRECURSOR_INVALID_EXPECTED_TOKEN_COUNT + ); + DORY_PREFIX_CATEGORY( + "Expected long shader continuation, got new shader\n", + DORY_VIRGL_PRECURSOR_EXPECTED_LONG_CONTINUATION + ); + DORY_PREFIX_CATEGORY( + "Long shader continuation handle invalid\n", + DORY_VIRGL_PRECURSOR_INVALID_CONTINUATION_HANDLE + ); + DORY_PREFIX_CATEGORY( + "Got continuation without original long shader ", + DORY_VIRGL_PRECURSOR_CONTINUATION_WITHOUT_ORIGINAL + ); + DORY_PREFIX_CATEGORY( + "Got mismatched shader continuation ", + DORY_VIRGL_PRECURSOR_MISMATCHED_CONTINUATION + ); + DORY_PREFIX_CATEGORY( + "Got too large shader continuation ", + DORY_VIRGL_PRECURSOR_OVERSIZED_CONTINUATION + ); +#undef DORY_PREFIX_CATEGORY + return DORY_VIRGL_PRECURSOR_NONE; +} + +static void dory_renderer_log_callback( + int32_t log_level, + const char *message, + void *user_data +) +{ + DoryVirglRendererSession *session = user_data; + if (log_level != DORY_VIRGL_RENDERER_LOG_LEVEL_ERROR || + session == NULL || !session->submit_diagnostic_lock_initialized) + return; + + pthread_mutex_lock(&session->submit_diagnostic_lock); + const bool active = session->submit_in_progress; + const uint32_t expected_context_id = session->active_submit_context_id; + pthread_mutex_unlock(&session->submit_diagnostic_lock); + if (!active) + return; + + const uint32_t precursor = + DoryVirglRendererClassifySubmitPrecursorMessage(message); + DoryVirglRendererSubmitDiagnostic classified = {0}; + const int32_t exact_classification = + DoryVirglRendererClassifyExactSubmitDiagnosticMessage( + message, + expected_context_id, + &classified + ); + const int32_t legacy_classification = exact_classification == 0 + ? DoryVirglRendererClassifySubmitDiagnosticMessage( + message, + expected_context_id, + &classified + ) + : 0; + if (precursor == DORY_VIRGL_PRECURSOR_NONE && + exact_classification == 0 && legacy_classification != 1) + return; + + pthread_mutex_lock(&session->submit_diagnostic_lock); + if (session->submit_in_progress && + session->active_submit_context_id == expected_context_id && + precursor != DORY_VIRGL_PRECURSOR_NONE && + session->submit_diagnostic.precursor_category == DORY_VIRGL_PRECURSOR_NONE) + session->submit_diagnostic.precursor_category = precursor; + if (session->submit_in_progress && + session->active_submit_context_id == expected_context_id && + exact_classification != 0 && + session->submit_diagnostic.failed_command_location_disposition == + DORY_VIRGL_FAILED_COMMAND_LOCATION_ABSENT) { + classified.precursor_category = session->submit_diagnostic.precursor_category; + session->submit_diagnostic = classified; + } else if (session->submit_in_progress && + session->active_submit_context_id == expected_context_id && + legacy_classification == 1 && + session->submit_diagnostic.valid == 0 && + session->submit_diagnostic.failed_command_location_disposition == + DORY_VIRGL_FAILED_COMMAND_LOCATION_ABSENT) { + classified.precursor_category = session->submit_diagnostic.precursor_category; + session->submit_diagnostic = classified; + } + pthread_mutex_unlock(&session->submit_diagnostic_lock); +} + +#if defined(DORY_VIRGL_RENDERER_DUAL_METAL) +typedef struct DoryVirglRendererANGLEContext { + EGLDisplay display; + EGLContext context; + EGLSurface surface; +} DoryVirglRendererANGLEContext; + +static int32_t dory_initialize_angle(DoryVirglRendererSession *session) +{ + const PFNEGLGETPLATFORMDISPLAYEXTPROC get_platform_display = + (PFNEGLGETPLATFORMDISPLAYEXTPROC)eglGetProcAddress("eglGetPlatformDisplayEXT"); + if (get_platform_display == NULL) + return -ENOTSUP; + + const EGLint display_attributes[] = { + EGL_PLATFORM_ANGLE_TYPE_ANGLE, + EGL_PLATFORM_ANGLE_TYPE_METAL_ANGLE, + EGL_PLATFORM_ANGLE_DEVICE_TYPE_ANGLE, + EGL_PLATFORM_ANGLE_DEVICE_TYPE_HARDWARE_ANGLE, + EGL_NONE, + }; + session->angle_display = get_platform_display( + EGL_PLATFORM_ANGLE_ANGLE, + (void *)(uintptr_t)EGL_DEFAULT_DISPLAY, + display_attributes + ); + if (session->angle_display == EGL_NO_DISPLAY) + return -ENODEV; + + EGLint major = 0; + EGLint minor = 0; + if (eglInitialize(session->angle_display, &major, &minor) != EGL_TRUE) + return -EIO; + if (eglBindAPI(EGL_OPENGL_ES_API) != EGL_TRUE) + return -EIO; + + const EGLint config_attributes[] = { + EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, + EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT_KHR, + EGL_RED_SIZE, 8, + EGL_GREEN_SIZE, 8, + EGL_BLUE_SIZE, 8, + EGL_ALPHA_SIZE, 8, + EGL_NONE, + }; + EGLint config_count = 0; + if (eglChooseConfig( + session->angle_display, + config_attributes, + &session->angle_config, + 1, + &config_count + ) != EGL_TRUE || config_count != 1) + return -ENOTSUP; + const EGLint context_attributes[] = { + EGL_CONTEXT_CLIENT_VERSION, 3, + EGL_NONE, + }; + session->angle_share_context = eglCreateContext( + session->angle_display, + session->angle_config, + EGL_NO_CONTEXT, + context_attributes + ); + if (session->angle_share_context == EGL_NO_CONTEXT) + return -EIO; + return 0; +} + +static void dory_terminate_angle(DoryVirglRendererSession *session) +{ + if (session->angle_display != EGL_NO_DISPLAY) { + (void)eglMakeCurrent( + session->angle_display, + EGL_NO_SURFACE, + EGL_NO_SURFACE, + EGL_NO_CONTEXT + ); + if (session->angle_share_context != EGL_NO_CONTEXT) { + (void)eglDestroyContext( + session->angle_display, + session->angle_share_context + ); + session->angle_share_context = EGL_NO_CONTEXT; + } + (void)eglTerminate(session->angle_display); + session->angle_display = EGL_NO_DISPLAY; + session->angle_config = NULL; + } + if (session->angle_context_lock_initialized) { + pthread_mutex_destroy(&session->angle_context_lock); + session->angle_context_lock_initialized = false; + } +} + +static DoryVirglRendererGLContext dory_create_gl_context( + void *cookie, + int32_t scanout_index, + DoryVirglRendererGLContextParameters *parameters +) +{ + (void)scanout_index; + DoryVirglRendererSession *session = cookie; + if (session == NULL || parameters == NULL || + session->angle_display == EGL_NO_DISPLAY) + return NULL; + + DoryVirglRendererANGLEContext *owned = calloc(1, sizeof(*owned)); + if (owned == NULL) + return NULL; + + pthread_mutex_lock(&session->angle_context_lock); + /* + * Keep one process-lifetime root instead of making the first vrend context the share-group + * owner. Virgl may destroy and recreate its primary context while sync/blit contexts remain; + * every callback context therefore shares with this root regardless of creation order. + */ + const EGLContext share = session->angle_share_context; + const EGLint context_attributes[] = { + EGL_CONTEXT_CLIENT_VERSION, 3, + EGL_NONE, + }; + owned->context = eglCreateContext( + session->angle_display, + session->angle_config, + share, + context_attributes + ); + const EGLint surface_attributes[] = { + EGL_WIDTH, 1, + EGL_HEIGHT, 1, + EGL_NONE, + }; + owned->surface = owned->context == EGL_NO_CONTEXT + ? EGL_NO_SURFACE + : eglCreatePbufferSurface( + session->angle_display, + session->angle_config, + surface_attributes + ); + pthread_mutex_unlock(&session->angle_context_lock); + + if (owned->context == EGL_NO_CONTEXT || owned->surface == EGL_NO_SURFACE) { + if (owned->surface != EGL_NO_SURFACE) + (void)eglDestroySurface(session->angle_display, owned->surface); + if (owned->context != EGL_NO_CONTEXT) + (void)eglDestroyContext(session->angle_display, owned->context); + free(owned); + return NULL; + } + owned->display = session->angle_display; + return owned; +} + +static void dory_destroy_gl_context(void *cookie, DoryVirglRendererGLContext context) +{ + DoryVirglRendererSession *session = cookie; + DoryVirglRendererANGLEContext *owned = context; + if (session == NULL || owned == NULL) + return; + pthread_mutex_lock(&session->angle_context_lock); + (void)eglDestroySurface(owned->display, owned->surface); + (void)eglDestroyContext(owned->display, owned->context); + pthread_mutex_unlock(&session->angle_context_lock); + free(owned); +} + +static int32_t dory_make_current( + void *cookie, + int32_t scanout_index, + DoryVirglRendererGLContext context +) +{ + (void)scanout_index; + DoryVirglRendererSession *session = cookie; + if (session == NULL || session->angle_display == EGL_NO_DISPLAY) + return -EINVAL; + if (context == NULL) { + return eglMakeCurrent( + session->angle_display, + EGL_NO_SURFACE, + EGL_NO_SURFACE, + EGL_NO_CONTEXT + ) == EGL_TRUE ? 0 : -EIO; + } + DoryVirglRendererANGLEContext *owned = context; + return eglMakeCurrent( + owned->display, + owned->surface, + owned->surface, + owned->context + ) == EGL_TRUE ? 0 : -EIO; +} + +static void *dory_get_egl_display(void *cookie) +{ + DoryVirglRendererSession *session = cookie; + if (session == NULL || session->angle_display == EGL_NO_DISPLAY) + return NULL; + return session->angle_display; +} + +static GLuint dory_compile_self_test_shader(GLenum type, const char *source) +{ + GLuint shader = glCreateShader(type); + if (shader == 0) + return 0; + glShaderSource(shader, 1, &source, NULL); + glCompileShader(shader); + GLint compiled = GL_FALSE; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled != GL_TRUE) { + glDeleteShader(shader); + return 0; + } + return shader; +} + +/* + * This is deliberately an execution test, not a string-only ANGLE probe. The historical Dory + * failure initialized an OpenGL context successfully but then rejected GNOME's first uniform-block + * shader. A production worker therefore proves the exact path GNOME needs before it may return a + * VirGL2 capset: Metal ANGLE, GLES 3, UBO-backed shader compilation/link, instanced drawing, FBO + * rendering, GPU completion, and deterministic readback. + */ +static int32_t dory_run_angle_execution_self_test(DoryVirglRendererSession *session) +{ + const EGLint surface_attributes[] = { + EGL_WIDTH, 4, + EGL_HEIGHT, 4, + EGL_NONE, + }; + EGLSurface surface = eglCreatePbufferSurface( + session->angle_display, + session->angle_config, + surface_attributes + ); + if (surface == EGL_NO_SURFACE) + return -EIO; + + const EGLint context_attributes[] = { + EGL_CONTEXT_CLIENT_VERSION, 3, + EGL_NONE, + }; + EGLContext context = eglCreateContext( + session->angle_display, + session->angle_config, + session->angle_share_context, + context_attributes + ); + if (context == EGL_NO_CONTEXT) { + (void)eglDestroySurface(session->angle_display, surface); + return -EIO; + } + + int32_t result = -EIO; + GLuint vertex_shader = 0; + GLuint fragment_shader = 0; + GLuint program = 0; + GLuint uniform_buffer = 0; + GLuint texture = 0; + GLuint framebuffer = 0; + GLuint vertex_array = 0; + GLsync execution_fence = NULL; + if (eglMakeCurrent( + session->angle_display, + surface, + surface, + context + ) != EGL_TRUE) + goto cleanup; + + GLint major = 0; + GLint minor = 0; + GLint uniform_bindings = 0; + GLint vertex_attributes = 0; + GLint draw_buffers = 0; + glGetIntegerv(GL_MAJOR_VERSION, &major); + glGetIntegerv(GL_MINOR_VERSION, &minor); + glGetIntegerv(GL_MAX_UNIFORM_BUFFER_BINDINGS, &uniform_bindings); + glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &vertex_attributes); + glGetIntegerv(GL_MAX_DRAW_BUFFERS, &draw_buffers); + const char *renderer = (const char *)glGetString(GL_RENDERER); + if (major < 3 || uniform_bindings < 1 || vertex_attributes < 8 || draw_buffers < 1 || + renderer == NULL || strstr(renderer, "ANGLE") == NULL || + strstr(renderer, "Metal") == NULL || + glGetError() != GL_NO_ERROR) { + result = -ENOTSUP; + goto cleanup; + } + (void)minor; + + static const char vertex_source[] = + "#version 300 es\n" + "const vec2 p[3] = vec2[3](vec2(-1.0,-1.0), vec2(3.0,-1.0), " + "vec2(-1.0,3.0));\n" + "void main() { gl_Position = vec4(p[gl_VertexID], 0.0, 1.0); }\n"; + static const char fragment_source[] = + "#version 300 es\n" + "precision highp float;\n" + "layout(std140) uniform DoryColorBlock { vec4 color; };\n" + "out vec4 doryColor;\n" + "void main() { doryColor = color; }\n"; + vertex_shader = dory_compile_self_test_shader(GL_VERTEX_SHADER, vertex_source); + fragment_shader = dory_compile_self_test_shader(GL_FRAGMENT_SHADER, fragment_source); + if (vertex_shader == 0 || fragment_shader == 0) + goto cleanup; + + program = glCreateProgram(); + if (program == 0) + goto cleanup; + glAttachShader(program, vertex_shader); + glAttachShader(program, fragment_shader); + glLinkProgram(program); + GLint linked = GL_FALSE; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked != GL_TRUE) + goto cleanup; + + const GLuint block_index = glGetUniformBlockIndex(program, "DoryColorBlock"); + if (block_index == GL_INVALID_INDEX) + goto cleanup; + glUniformBlockBinding(program, block_index, 0); + static const GLfloat expected_color[4] = {0.25f, 0.5f, 0.75f, 1.0f}; + glGenBuffers(1, &uniform_buffer); + glBindBuffer(GL_UNIFORM_BUFFER, uniform_buffer); + glBufferData(GL_UNIFORM_BUFFER, sizeof(expected_color), expected_color, GL_STATIC_DRAW); + glBindBufferBase(GL_UNIFORM_BUFFER, 0, uniform_buffer); + + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 4, 4); + glGenFramebuffers(1, &framebuffer); + glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); + glFramebufferTexture2D( + GL_FRAMEBUFFER, + GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, + texture, + 0 + ); + if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) + goto cleanup; + + glViewport(0, 0, 4, 4); + glGenVertexArrays(1, &vertex_array); + glBindVertexArray(vertex_array); + glUseProgram(program); + glDrawArraysInstanced(GL_TRIANGLES, 0, 3, 1); + execution_fence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); + if (execution_fence == NULL) + goto cleanup; + glFlush(); + const GLenum wait_result = glClientWaitSync( + execution_fence, + GL_SYNC_FLUSH_COMMANDS_BIT, + 1000000000ULL + ); + if (wait_result != GL_ALREADY_SIGNALED && wait_result != GL_CONDITION_SATISFIED) + goto cleanup; + glFinish(); + GLubyte pixel[4] = {0, 0, 0, 0}; + glReadPixels(2, 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + if (glGetError() != GL_NO_ERROR) + goto cleanup; + const int expected[4] = {64, 128, 191, 255}; + for (size_t component = 0; component < 4; component++) { + const int difference = (int)pixel[component] - expected[component]; + if (difference < -2 || difference > 2) + goto cleanup; + } + result = 0; + +cleanup: + if (execution_fence != NULL) + glDeleteSync(execution_fence); + if (vertex_array != 0) + glDeleteVertexArrays(1, &vertex_array); + if (framebuffer != 0) + glDeleteFramebuffers(1, &framebuffer); + if (texture != 0) + glDeleteTextures(1, &texture); + if (uniform_buffer != 0) + glDeleteBuffers(1, &uniform_buffer); + if (program != 0) + glDeleteProgram(program); + if (fragment_shader != 0) + glDeleteShader(fragment_shader); + if (vertex_shader != 0) + glDeleteShader(vertex_shader); + (void)eglMakeCurrent( + session->angle_display, + EGL_NO_SURFACE, + EGL_NO_SURFACE, + EGL_NO_CONTEXT + ); + (void)eglDestroyContext(session->angle_display, context); + (void)eglDestroySurface(session->angle_display, surface); + return result; +} +#endif + +static pthread_mutex_t dory_session_lock = PTHREAD_MUTEX_INITIALIZER; +static bool dory_session_active; + +static void dory_write_fence(void *cookie, uint32_t fence); + +static int32_t dory_make_completion_pipe(int descriptors[2]) +{ + descriptors[0] = -1; + descriptors[1] = -1; + if (pipe(descriptors) != 0) + return errno ? -errno : -EMFILE; + + for (size_t index = 0; index < 2; index++) { + const int flags = fcntl(descriptors[index], F_GETFD); + if (flags < 0 || fcntl(descriptors[index], F_SETFD, flags | FD_CLOEXEC) != 0) { + const int saved_errno = errno; + close(descriptors[0]); + close(descriptors[1]); + descriptors[0] = -1; + descriptors[1] = -1; + return saved_errno ? -saved_errno : -EMFILE; + } + } + return 0; +} + +static void dory_remove_fence_completion_locked( + DoryVirglRendererSession *session, + DoryVirglRendererFenceCompletion *target +) +{ + DoryVirglRendererFenceCompletion *previous = NULL; + DoryVirglRendererFenceCompletion *completion = session->fence_head; + while (completion != NULL && completion != target) { + previous = completion; + completion = completion->next; + } + if (completion == NULL) + return; + if (previous == NULL) + session->fence_head = completion->next; + else + previous->next = completion->next; + if (session->fence_tail == completion) + session->fence_tail = previous; + if (completion->read_descriptor >= 0) + close(completion->read_descriptor); + if (completion->write_descriptor >= 0) + close(completion->write_descriptor); + free(completion); +} + +static int32_t dory_allocate_global_renderer_fence_id_locked( + DoryVirglRendererSession *session, + uint32_t *out_renderer_fence_id +) +{ + if (out_renderer_fence_id == NULL) + return -EINVAL; + + size_t live_global_fences = 0; + for (DoryVirglRendererFenceCompletion *completion = session->fence_head; + completion != NULL; + completion = completion->next) { + if (completion->context_id == 0 && completion->renderer_fence_id != 0) + live_global_fences++; + } + if (live_global_fences >= UINT32_MAX - 1U) + return -ENOSPC; + + uint32_t candidate = session->next_global_renderer_fence_id; + if (candidate == 0) + candidate = 1; + + /* Among N live ids, at least one of the next N+1 nonzero candidates is free. This keeps the + * allocator bounded even after wrap and never aliases an outstanding renderer callback. */ + for (size_t attempt = 0; attempt <= live_global_fences; attempt++) { + bool collision = false; + for (DoryVirglRendererFenceCompletion *completion = session->fence_head; + completion != NULL; + completion = completion->next) { + if (completion->context_id == 0 && + completion->renderer_fence_id == candidate) { + collision = true; + break; + } + } + if (!collision) { + *out_renderer_fence_id = candidate; + session->next_global_renderer_fence_id = candidate + 1U; + if (session->next_global_renderer_fence_id == 0) + session->next_global_renderer_fence_id = 1; + return 0; + } + candidate++; + if (candidate == 0) + candidate = 1; + } + return -ENOSPC; +} + +static void dory_write_fence(void *cookie, uint32_t renderer_fence_id) +{ + DoryVirglRendererSession *session = cookie; + if (session == NULL || !session->fence_lock_initialized || renderer_fence_id == 0) + return; + + pthread_mutex_lock(&session->fence_lock); + DoryVirglRendererFenceCompletion *target = session->fence_head; + while (target != NULL && + (target->context_id != 0 || + target->renderer_fence_id != renderer_fence_id)) { + target = target->next; + } + if (target == NULL) { + pthread_mutex_unlock(&session->fence_lock); + return; + } + + /* VirGL's ctx0 timeline may coalesce retirement. Signal every earlier admitted global fence + * through the callback target by registration order; guest ids are deliberately not compared + * numerically because they are independent 64-bit protocol identities and may wrap. */ + DoryVirglRendererFenceCompletion *previous = NULL; + DoryVirglRendererFenceCompletion *completion = session->fence_head; + while (completion != NULL) { + DoryVirglRendererFenceCompletion *next = completion->next; + if (completion->context_id == 0 && completion->renderer_fence_id != 0) { + if (completion->write_descriptor >= 0) { + close(completion->write_descriptor); + completion->write_descriptor = -1; + } + /* The callback mapping is no longer outstanding, even if export races behind it. */ + completion->renderer_fence_id = 0; + } + + const bool reached_target = completion == target; + if (completion->read_descriptor < 0 && completion->write_descriptor < 0) { + if (previous == NULL) + session->fence_head = next; + else + previous->next = next; + if (session->fence_tail == completion) + session->fence_tail = previous; + free(completion); + } else { + previous = completion; + } + if (reached_target) + break; + completion = next; + } + pthread_mutex_unlock(&session->fence_lock); +} + +static void dory_write_context_fence( + void *cookie, + uint32_t context_id, + uint32_t ring_index, + uint64_t fence_id +) +{ + DoryVirglRendererSession *session = cookie; + if (session == NULL || !session->fence_lock_initialized) + return; + + pthread_mutex_lock(&session->fence_lock); + + /* + * An inner render-server fence may coalesce timeline notifications. Confirm this callback is + * one we admitted, then signal every completion registered before it on the same context/ring. + * Registration order, rather than numeric fence ordering, remains correct across id wraparound. + */ + DoryVirglRendererFenceCompletion *target = session->fence_head; + while (target != NULL && + (target->context_id != context_id || + target->ring_index != ring_index || + target->fence_id != fence_id)) { + target = target->next; + } + if (target == NULL) { + pthread_mutex_unlock(&session->fence_lock); + return; + } + + DoryVirglRendererFenceCompletion *previous = NULL; + DoryVirglRendererFenceCompletion *completion = session->fence_head; + while (completion != NULL) { + DoryVirglRendererFenceCompletion *next = completion->next; + if (completion->context_id == context_id && + completion->ring_index == ring_index && + completion->write_descriptor >= 0) { + /* EOF is a one-shot pollable completion edge and cannot block the renderer callback. */ + close(completion->write_descriptor); + completion->write_descriptor = -1; + } + + const bool reached_target = completion == target; + if (completion->read_descriptor < 0 && completion->write_descriptor < 0) { + if (previous == NULL) + session->fence_head = next; + else + previous->next = next; + if (session->fence_tail == completion) + session->fence_tail = previous; + free(completion); + } else { + previous = completion; + } + if (reached_target) + break; + completion = next; + } + + pthread_mutex_unlock(&session->fence_lock); +} + +static int32_t dory_register_fence_completion( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t ring_index, + uint64_t fence_id, + bool allocate_global_renderer_id, + uint32_t *out_renderer_fence_id +) +{ + DoryVirglRendererFenceCompletion *completion = calloc(1, sizeof(*completion)); + if (completion == NULL) + return -ENOMEM; + + int descriptors[2]; + int32_t result = dory_make_completion_pipe(descriptors); + if (result != 0) { + free(completion); + return result; + } + completion->context_id = context_id; + completion->ring_index = ring_index; + completion->fence_id = fence_id; + completion->read_descriptor = descriptors[0]; + completion->write_descriptor = descriptors[1]; + + pthread_mutex_lock(&session->fence_lock); + for (DoryVirglRendererFenceCompletion *existing = session->fence_head; + existing != NULL; + existing = existing->next) { + /* The public one-shot handoff consumes by fence_id, so live ids are process-unique. */ + if (existing->fence_id == fence_id) { + pthread_mutex_unlock(&session->fence_lock); + close(completion->read_descriptor); + close(completion->write_descriptor); + free(completion); + return -EEXIST; + } + } + if (allocate_global_renderer_id) { + result = dory_allocate_global_renderer_fence_id_locked( + session, + &completion->renderer_fence_id + ); + if (result != 0) { + pthread_mutex_unlock(&session->fence_lock); + close(completion->read_descriptor); + close(completion->write_descriptor); + free(completion); + return result; + } + *out_renderer_fence_id = completion->renderer_fence_id; + } + if (session->fence_tail == NULL) + session->fence_head = completion; + else + session->fence_tail->next = completion; + session->fence_tail = completion; + pthread_mutex_unlock(&session->fence_lock); + return 0; +} + +static void dory_cancel_fence_completion( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t ring_index, + uint64_t fence_id +) +{ + pthread_mutex_lock(&session->fence_lock); + DoryVirglRendererFenceCompletion *completion = session->fence_head; + while (completion != NULL) { + if (completion->context_id == context_id && + completion->ring_index == ring_index && + completion->fence_id == fence_id) { + dory_remove_fence_completion_locked(session, completion); + break; + } + completion = completion->next; + } + pthread_mutex_unlock(&session->fence_lock); +} + +static void dory_remove_context_fence_completions( + DoryVirglRendererSession *session, + uint32_t context_id +) +{ + if (!session->fence_lock_initialized) + return; + pthread_mutex_lock(&session->fence_lock); + DoryVirglRendererFenceCompletion *completion = session->fence_head; + while (completion != NULL) { + DoryVirglRendererFenceCompletion *next = completion->next; + if (completion->context_id == context_id) + dory_remove_fence_completion_locked(session, completion); + completion = next; + } + pthread_mutex_unlock(&session->fence_lock); +} + +static void dory_destroy_fence_registry(DoryVirglRendererSession *session) +{ + if (!session->fence_lock_initialized) + return; + pthread_mutex_lock(&session->fence_lock); + while (session->fence_head != NULL) + dory_remove_fence_completion_locked(session, session->fence_head); + pthread_mutex_unlock(&session->fence_lock); + pthread_mutex_destroy(&session->fence_lock); + session->fence_lock_initialized = false; +} + +static int32_t dory_claim_process_slot(DoryVirglRendererSession *session) +{ + int32_t result = 0; + pthread_mutex_lock(&dory_session_lock); + if (dory_session_active) { + result = -EBUSY; + } else { + dory_session_active = true; + session->owns_process_slot = true; + } + pthread_mutex_unlock(&dory_session_lock); + return result; +} + +static void dory_release_process_slot(DoryVirglRendererSession *session) +{ + if (!session->owns_process_slot) + return; + pthread_mutex_lock(&dory_session_lock); + dory_session_active = false; + session->owns_process_slot = false; + pthread_mutex_unlock(&dory_session_lock); +} + +static int32_t dory_bind_static_functions(DoryVirglRendererSession *session) +{ +#if defined(DORY_VIRGL_RENDERER_STATIC_LINKED) + session->functions = (DoryVirglRendererFunctions){ + .initialize = virgl_renderer_init, + .cleanup = virgl_renderer_cleanup, + .get_cap_set = virgl_renderer_get_cap_set, + .fill_caps = virgl_renderer_fill_caps, + .context_create = virgl_renderer_context_create_with_flags, + .context_destroy = virgl_renderer_context_destroy, + .context_attach_resource = virgl_renderer_ctx_attach_resource, + .context_detach_resource = virgl_renderer_ctx_detach_resource, + .submit_cmd2 = virgl_renderer_submit_cmd2, + .blob_create = virgl_renderer_resource_create_blob, + .resource_create = virgl_renderer_resource_create, + .resource_attach_iov = virgl_renderer_resource_attach_iov, + .resource_detach_iov = virgl_renderer_resource_detach_iov, + .resource_unref = virgl_renderer_resource_unref, + .resource_get_map_info = virgl_renderer_resource_get_map_info, + .resource_export_blob = virgl_renderer_resource_export_blob, + .resource_get_info = virgl_renderer_resource_get_info, + .create_handle_for_scanout = virgl_renderer_create_handle_for_scanout, + .release_handle_for_scanout = virgl_renderer_release_handle_for_scanout, + .transfer_write_iov = virgl_renderer_transfer_write_iov, + .transfer_read_iov = virgl_renderer_transfer_read_iov, + .context_create_fence = virgl_renderer_context_create_fence, + .create_fence = virgl_renderer_create_fence, + .get_poll_fd = virgl_renderer_get_poll_fd, + .poll = virgl_renderer_poll, + .set_log_callback = virgl_set_log_callback, + }; + return 0; +#else + (void)session; + return -ENOSYS; +#endif +} + +static void dory_destroy_partial_session(DoryVirglRendererSession *session) +{ + if (session == NULL) + return; + if (session->log_callback_installed) { + session->functions.set_log_callback(NULL, NULL, NULL); + session->log_callback_installed = false; + } + if (session->renderer_initialized) { + session->functions.cleanup(session); + session->renderer_initialized = false; + } +#if defined(DORY_VIRGL_RENDERER_DUAL_METAL) + dory_terminate_angle(session); +#endif + dory_destroy_fence_registry(session); + if (session->submit_diagnostic_lock_initialized) { + pthread_mutex_destroy(&session->submit_diagnostic_lock); + session->submit_diagnostic_lock_initialized = false; + } + dory_release_process_slot(session); + free(session); +} + +int32_t DoryVirglRendererSessionCreate(DoryVirglRendererSession **out_session) +{ + if (out_session == NULL || *out_session != NULL) + return -EINVAL; + + if (setenv( + "APP_SANDBOX_GROUP_ID", + dory_renderer_app_sandbox_group, + 1 + ) != 0) + return errno ? -errno : -EINVAL; + + DoryVirglRendererSession *session = calloc(1, sizeof(*session)); + if (session == NULL) + return -ENOMEM; + + int32_t result = pthread_mutex_init(&session->fence_lock, NULL); + if (result != 0) { + free(session); + return -result; + } + session->fence_lock_initialized = true; + result = pthread_mutex_init(&session->submit_diagnostic_lock, NULL); + if (result != 0) { + dory_destroy_partial_session(session); + return -result; + } + session->submit_diagnostic_lock_initialized = true; + + /* virglrenderer and its external EGL winsys are process-global. Claim before constructing + * either ANGLE or renderer state so two concurrent bootstrap attempts cannot briefly create + * independent share groups and then race for the foreign singleton. */ + result = dory_claim_process_slot(session); + if (result != 0) { + dory_destroy_partial_session(session); + return result; + } + +#if defined(DORY_VIRGL_RENDERER_DUAL_METAL) + result = pthread_mutex_init(&session->angle_context_lock, NULL); + if (result != 0) { + dory_destroy_partial_session(session); + return -result; + } + session->angle_context_lock_initialized = true; + session->angle_display = EGL_NO_DISPLAY; + result = dory_initialize_angle(session); + if (result != 0) { + dory_destroy_partial_session(session); + return result; + } + result = dory_run_angle_execution_self_test(session); + if (result != 0) { + dory_destroy_partial_session(session); + return result; + } +#endif + + result = dory_bind_static_functions(session); + if (result != 0) { + dory_destroy_partial_session(session); + return result; + } + session->functions.set_log_callback(dory_renderer_log_callback, session, NULL); + session->log_callback_installed = true; + + session->callbacks = (DoryVirglRendererCallbacks){ + .version = DORY_VIRGL_RENDERER_CALLBACKS_VERSION, + .write_fence = dory_write_fence, +#if defined(DORY_VIRGL_RENDERER_DUAL_METAL) + .create_gl_context = dory_create_gl_context, + .destroy_gl_context = dory_destroy_gl_context, + .make_current = dory_make_current, +#else + .create_gl_context = NULL, + .destroy_gl_context = NULL, + .make_current = NULL, +#endif + .get_drm_fd = NULL, + .write_context_fence = dory_write_context_fence, + .get_server_fd = NULL, +#if defined(DORY_VIRGL_RENDERER_DUAL_METAL) + .get_egl_display = dory_get_egl_display, +#else + .get_egl_display = NULL, +#endif + }; + result = session->functions.initialize( + session, +#if defined(DORY_VIRGL_RENDERER_DUAL_METAL) + DORY_VIRGL_RENDERER_DUAL_METAL_INITIALIZATION_FLAGS, +#else + DORY_VIRGL_RENDERER_VENUS_ONLY_INITIALIZATION_FLAGS, +#endif + &session->callbacks + ); + if (result != 0) { + dory_destroy_partial_session(session); + return result; + } + session->renderer_initialized = true; + *out_session = session; + return 0; +} + +void DoryVirglRendererSessionDestroy(DoryVirglRendererSession *session) +{ + dory_destroy_partial_session(session); +} + +int32_t DoryVirglRendererGetCapset( + DoryVirglRendererSession *session, + uint32_t capset_id, + uint32_t *maximum_version, + void *bytes, + size_t capacity, + size_t *actual_size +) +{ + if (session == NULL || maximum_version == NULL || actual_size == NULL) + return -EINVAL; + uint32_t version = 0; + uint32_t size = 0; + session->functions.get_cap_set(capset_id, &version, &size); + *maximum_version = version; + *actual_size = size; + if (size == 0) + return -ENOTSUP; + if (bytes == NULL) + return capacity == 0 ? 0 : -EINVAL; + if (capacity < size) + return -EMSGSIZE; + session->functions.fill_caps(capset_id, version, bytes); + return 0; +} + +int32_t DoryVirglRendererContextCreate( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t flags, + const char *name, + size_t name_length +) +{ + if (session == NULL || context_id == 0 || name == NULL || + name_length == 0 || name_length > UINT32_MAX) + return -EINVAL; + const int32_t result = session->functions.context_create( + context_id, + flags, + (uint32_t)name_length, + name + ); + return result; +} + +void DoryVirglRendererContextDestroy( + DoryVirglRendererSession *session, + uint32_t context_id +) +{ + if (session == NULL || context_id == 0) + return; + session->functions.context_destroy(context_id); + dory_remove_context_fence_completions(session, context_id); +} + +void DoryVirglRendererContextAttachResource( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t resource_id +) +{ + if (session == NULL || context_id == 0 || resource_id == 0) + return; + session->functions.context_attach_resource((int32_t)context_id, (int32_t)resource_id); +} + +void DoryVirglRendererContextDetachResource( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t resource_id +) +{ + if (session == NULL || context_id == 0 || resource_id == 0) + return; + session->functions.context_detach_resource((int32_t)context_id, (int32_t)resource_id); +} + +int32_t DoryVirglRendererSubmit( + DoryVirglRendererSession *session, + uint32_t context_id, + const void *command_bytes, + uint32_t dword_count, + DoryVirglRendererSubmitDiagnostic *diagnostic +) +{ + if (diagnostic != NULL) + *diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + if (session == NULL || context_id == 0 || command_bytes == NULL || + diagnostic == NULL || + dword_count == 0 || dword_count > INT32_MAX || + ((uintptr_t)command_bytes & 7U) != 0) + return -EINVAL; + + pthread_mutex_lock(&session->submit_diagnostic_lock); + if (session->submit_in_progress) { + pthread_mutex_unlock(&session->submit_diagnostic_lock); + return -EBUSY; + } + session->active_submit_context_id = context_id; + session->submit_diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + session->submit_in_progress = true; + pthread_mutex_unlock(&session->submit_diagnostic_lock); + + const int32_t result = session->functions.submit_cmd2( + (void *)command_bytes, + (int32_t)context_id, + (int32_t)dword_count, + NULL, + 0 + ); + + pthread_mutex_lock(&session->submit_diagnostic_lock); + session->submit_in_progress = false; + session->active_submit_context_id = 0; + if (session->submit_diagnostic.valid != 0 || + session->submit_diagnostic.precursor_category != DORY_VIRGL_PRECURSOR_NONE || + session->submit_diagnostic.failed_command_location_disposition != + DORY_VIRGL_FAILED_COMMAND_LOCATION_ABSENT) { + *diagnostic = session->submit_diagnostic; + if (diagnostic->failed_command_location_disposition == + DORY_VIRGL_FAILED_COMMAND_LOCATION_PRESENT && + diagnostic->status != result) { + diagnostic->failed_command_location_disposition = + DORY_VIRGL_FAILED_COMMAND_LOCATION_AMBIGUOUS; + diagnostic->failed_command_dword_offset = 0; + diagnostic->failed_command_ordinal = 0; + } + if (diagnostic->failed_command_location_disposition != + DORY_VIRGL_FAILED_COMMAND_LOCATION_ABSENT || + (diagnostic->valid != 0 && diagnostic->command_id == 1U)) { + (void)DoryVirglRendererCorrelateCreateObjectSubtype( + command_bytes, + dword_count, + diagnostic + ); + } + } + session->submit_diagnostic = (DoryVirglRendererSubmitDiagnostic){0}; + pthread_mutex_unlock(&session->submit_diagnostic_lock); + return result; +} + +int32_t DoryVirglRendererBlobCreate( + DoryVirglRendererSession *session, + const DoryVirglRendererBlobCreateArguments *arguments +) +{ + if (session == NULL || arguments == NULL || arguments->resource_handle == 0 || + (arguments->iovecs == NULL) != (arguments->iovec_count == 0)) + return -EINVAL; + const int32_t result = session->functions.blob_create(arguments); + return result; +} + +int32_t DoryVirglRendererResource3DCreate( + DoryVirglRendererSession *session, + const DoryVirglRendererResource3DCreateArguments *arguments +) +{ + if (session == NULL || arguments == NULL || arguments->handle == 0 || + arguments->format == 0 || arguments->width == 0 || + arguments->height == 0 || arguments->depth == 0 || + arguments->array_size == 0) + return -EINVAL; + DoryVirglRendererResource3DCreateArguments mutable_arguments = *arguments; + return session->functions.resource_create(&mutable_arguments, NULL, 0); +} + +int32_t DoryVirglRendererResourceAttachBacking( + DoryVirglRendererSession *session, + uint32_t resource_id, + const struct iovec *iovecs, + uint32_t iovec_count +) +{ + if (session == NULL || resource_id == 0 || iovecs == NULL || + iovec_count == 0 || iovec_count > INT32_MAX) + return -EINVAL; + const int32_t result = session->functions.resource_attach_iov( + (int32_t)resource_id, + (struct iovec *)iovecs, + (int32_t)iovec_count + ); + return result; +} + +void DoryVirglRendererResourceDetachBacking( + DoryVirglRendererSession *session, + uint32_t resource_id +) +{ + if (session == NULL || resource_id == 0) + return; + session->functions.resource_detach_iov((int32_t)resource_id, NULL, NULL); +} + +void DoryVirglRendererResourceUnref( + DoryVirglRendererSession *session, + uint32_t resource_id +) +{ + if (session == NULL || resource_id == 0) + return; + session->functions.resource_unref(resource_id); +} + +int32_t DoryVirglRendererResourceGetMapInfo( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t *map_info +) +{ + if (session == NULL || resource_id == 0 || map_info == NULL) + return -EINVAL; + return session->functions.resource_get_map_info(resource_id, map_info); +} + +int32_t DoryVirglRendererResourceExportBlob( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t *fd_type, + int32_t *owned_file_descriptor +) +{ + if (session == NULL || resource_id == 0 || fd_type == NULL || + owned_file_descriptor == NULL) + return -EINVAL; + *fd_type = 0; + *owned_file_descriptor = -1; + return session->functions.resource_export_blob( + resource_id, + fd_type, + owned_file_descriptor + ); +} + +int32_t DoryVirglRendererResourceGetInfo( + DoryVirglRendererSession *session, + uint32_t resource_id, + DoryVirglRendererResourceInfo *info +) +{ + if (session == NULL || resource_id == 0 || info == NULL) + return -EINVAL; + memset(info, 0, sizeof(*info)); + return session->functions.resource_get_info((int32_t)resource_id, info); +} + +int32_t DoryVirglRendererResourceAcquireScanoutMetalTexture( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t width, + uint32_t height, + uint32_t virgl_format, + uint32_t stride, + uint32_t offset, + void **retained_texture +) +{ + if (session == NULL || resource_id == 0 || width == 0 || height == 0 || + stride == 0 || retained_texture == NULL) + return -EINVAL; + *retained_texture = NULL; + void *handle = NULL; + const int32_t type = session->functions.create_handle_for_scanout( + resource_id, + width, + height, + virgl_format, + 0, + stride, + offset, + &handle + ); + if (type != DORY_VIRGL_RENDERER_NATIVE_HANDLE_METAL_TEXTURE || handle == NULL) { + if (type != 0 && handle != NULL) + session->functions.release_handle_for_scanout(type, handle); + return -ENOTSUP; + } + *retained_texture = handle; + return 0; +} + +int32_t DoryVirglRendererTransferToHost( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t context_id, + uint32_t level, + uint32_t stride, + uint32_t layer_stride, + const DoryVirglRendererBox *box, + uint64_t offset, + const struct iovec *iovecs, + uint32_t iovec_count +) +{ + if (session == NULL || resource_id == 0 || box == NULL || + (iovecs == NULL) != (iovec_count == 0)) + return -EINVAL; + const int32_t result = session->functions.transfer_write_iov( + resource_id, + context_id, + (int32_t)level, + stride, + layer_stride, + (DoryVirglRendererBox *)box, + offset, + (struct iovec *)iovecs, + iovec_count + ); + return result; +} + +int32_t DoryVirglRendererTransferFromHost( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t context_id, + uint32_t level, + uint32_t stride, + uint32_t layer_stride, + const DoryVirglRendererBox *box, + uint64_t offset, + const struct iovec *iovecs, + uint32_t iovec_count +) +{ + if (session == NULL || resource_id == 0 || box == NULL || + (iovecs == NULL) != (iovec_count == 0) || iovec_count > INT32_MAX) + return -EINVAL; + const int32_t result = session->functions.transfer_read_iov( + resource_id, + context_id, + level, + stride, + layer_stride, + (DoryVirglRendererBox *)box, + offset, + (struct iovec *)iovecs, + (int32_t)iovec_count + ); + return result; +} + +int32_t DoryVirglRendererCreateContextFence( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t flags, + uint32_t ring_index, + uint64_t fence_id +) +{ + if (session == NULL || context_id == 0 || fence_id == 0) + return -EINVAL; + int32_t result = dory_register_fence_completion( + session, + context_id, + ring_index, + fence_id, + false, + NULL + ); + if (result != 0) + return result; + result = session->functions.context_create_fence( + context_id, + flags, + ring_index, + fence_id + ); + if (result != 0) + dory_cancel_fence_completion(session, context_id, ring_index, fence_id); + return result; +} + +int32_t DoryVirglRendererCreateGlobalFence( + DoryVirglRendererSession *session, + uint64_t fence_id +) +{ + if (session == NULL || fence_id == 0 || session->functions.create_fence == NULL) + return -EINVAL; + uint32_t renderer_fence_id = 0; + int32_t result = dory_register_fence_completion( + session, + 0, + 0, + fence_id, + true, + &renderer_fence_id + ); + if (result != 0) + return result; + result = session->functions.create_fence((int32_t)renderer_fence_id, 0); + if (result != 0) + dory_cancel_fence_completion(session, 0, 0, fence_id); + return result; +} + +int32_t DoryVirglRendererGetFenceFileDescriptor( + DoryVirglRendererSession *session, + uint64_t fence_id +) +{ + if (session == NULL || fence_id == 0) + return -1; + pthread_mutex_lock(&session->fence_lock); + DoryVirglRendererFenceCompletion *completion = session->fence_head; + while (completion != NULL && completion->fence_id != fence_id) + completion = completion->next; + if (completion == NULL || completion->read_descriptor < 0) { + pthread_mutex_unlock(&session->fence_lock); + return -1; + } + + const int descriptor = completion->read_descriptor; + completion->read_descriptor = -1; + if (completion->write_descriptor < 0) + dory_remove_fence_completion_locked(session, completion); + pthread_mutex_unlock(&session->fence_lock); + return descriptor; +} + +int32_t DoryVirglRendererGetPollFileDescriptor(DoryVirglRendererSession *session) +{ + if (session == NULL || session->functions.get_poll_fd == NULL) + return -1; + return session->functions.get_poll_fd(); +} + +void DoryVirglRendererPoll(DoryVirglRendererSession *session) +{ + if (session == NULL) + return; + session->functions.poll(); +} diff --git a/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/DoryVirglRendererShim.c b/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/DoryVirglRendererShim.c new file mode 100644 index 00000000..1f09ed82 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/DoryVirglRendererShim.c @@ -0,0 +1,30 @@ +#include "DoryVirglRendererShim.h" + +#include + +_Static_assert(sizeof(DoryVirglRendererResourceInfo) == 40, + "virgl resource-info ABI size changed"); +_Static_assert(offsetof(DoryVirglRendererResourceInfo, handle) == 0, + "virgl resource-info handle offset changed"); +_Static_assert(offsetof(DoryVirglRendererResourceInfo, tex_id) == 24, + "virgl resource-info texture offset changed"); +_Static_assert(offsetof(DoryVirglRendererResourceInfo, drm_fourcc) == 32, + "virgl resource-info DRM format offset changed"); +_Static_assert(offsetof(DoryVirglRendererResourceInfo, fd) == 36, + "virgl resource-info file-descriptor offset changed"); +_Static_assert(DORY_VIRGL_RENDERER_RESOURCE_BIND_RENDER_TARGET == (1u << 1), + "virgl render-target resource-bind ABI changed"); +_Static_assert(DORY_VIRGL_RENDERER_RESOURCE_BIND_SAMPLER_VIEW == (1u << 3), + "virgl sampler-view resource-bind ABI changed"); +_Static_assert(DORY_VIRGL_RENDERER_RESOURCE_BIND_SCANOUT == (1u << 18), + "virgl scanout resource-bind ABI changed"); + +size_t DoryVirglRendererResourceInfoSize(void) +{ + return sizeof(DoryVirglRendererResourceInfo); +} + +size_t DoryVirglRendererResourceInfoFileDescriptorOffset(void) +{ + return offsetof(DoryVirglRendererResourceInfo, fd); +} diff --git a/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/include/DoryVirglRendererShim.h b/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/include/DoryVirglRendererShim.h new file mode 100644 index 00000000..6b8a1b90 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryVirglRendererShim/include/DoryVirglRendererShim.h @@ -0,0 +1,378 @@ +#ifndef DORY_VIRGL_RENDERER_SHIM_H +#define DORY_VIRGL_RENDERER_SHIM_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * ABI mirror of virglrenderer.h's struct virgl_renderer_resource_info. + * + * The production worker statically links the exact qualified virglrenderer archives, but this + * package target deliberately does not import the renderer's private build tree. Keep the foreign + * layout in C: Swift must not reproduce it with a private struct because a missing trailing field + * lets virglrenderer overwrite adjacent Swift storage. scripts/build-virglrenderer.sh separately + * compiles this mirror against the pinned upstream header before publishing a renderer artifact. + */ +typedef struct DoryVirglRendererResourceInfo { + uint32_t handle; + uint32_t virgl_format; + uint32_t width; + uint32_t height; + uint32_t depth; + uint32_t flags; + uint32_t tex_id; + uint32_t stride; + int32_t drm_fourcc; + int fd; +} DoryVirglRendererResourceInfo; + +/* + * Exact C-owned mirrors used by the renderer worker. Swift deliberately does not reproduce any + * virglrenderer aggregate: every writable or retained foreign record still needs a compile-checked + * layout boundary even though the renderer is statically linked into the worker. + */ +typedef void *DoryVirglRendererGLContext; + +typedef struct DoryVirglRendererGLContextParameters { + int32_t version; + uint8_t shared; + uint8_t reserved[3]; + int32_t major_version; + int32_t minor_version; + int32_t compatibility_context; +} DoryVirglRendererGLContextParameters; + +typedef struct DoryVirglRendererCallbacks { + int32_t version; + void (*write_fence)(void *cookie, uint32_t fence); + DoryVirglRendererGLContext (*create_gl_context)( + void *cookie, + int32_t scanout_index, + DoryVirglRendererGLContextParameters *parameters + ); + void (*destroy_gl_context)(void *cookie, DoryVirglRendererGLContext context); + int32_t (*make_current)( + void *cookie, + int32_t scanout_index, + DoryVirglRendererGLContext context + ); + int32_t (*get_drm_fd)(void *cookie); + void (*write_context_fence)( + void *cookie, + uint32_t context_id, + uint32_t ring_index, + uint64_t fence_id + ); + int32_t (*get_server_fd)(void *cookie, uint32_t version); + void *(*get_egl_display)(void *cookie); +} DoryVirglRendererCallbacks; + +typedef struct DoryVirglRendererBlobCreateArguments { + uint32_t resource_handle; + uint32_t context_id; + uint32_t blob_memory; + uint32_t blob_flags; + uint64_t blob_id; + uint64_t size; + const struct iovec *iovecs; + uint32_t iovec_count; +} DoryVirglRendererBlobCreateArguments; + +typedef struct DoryVirglRendererResource3DCreateArguments { + uint32_t handle; + uint32_t target; + uint32_t format; + uint32_t bind; + uint32_t width; + uint32_t height; + uint32_t depth; + uint32_t array_size; + uint32_t last_level; + uint32_t samples; + uint32_t flags; +} DoryVirglRendererResource3DCreateArguments; + +typedef struct DoryVirglRendererBox { + uint32_t x; + uint32_t y; + uint32_t z; + uint32_t width; + uint32_t height; + uint32_t depth; +} DoryVirglRendererBox; + +enum { + /* Exact pinned virglrenderer initialization ABI for Dory's dual Metal worker. */ + DORY_VIRGL_RENDERER_USE_EGL = 1 << 0, + DORY_VIRGL_RENDERER_THREAD_SYNC = 1 << 1, + DORY_VIRGL_RENDERER_USE_GLES = 1 << 4, + DORY_VIRGL_RENDERER_USE_EXTERNAL_BLOB = 1 << 5, + DORY_VIRGL_RENDERER_VENUS = 1 << 6, + DORY_VIRGL_RENDERER_NO_VIRGL = 1 << 7, + DORY_VIRGL_RENDERER_ASYNC_FENCE_CALLBACK = 1 << 8, + DORY_VIRGL_RENDERER_RENDER_SERVER = 1 << 9, + DORY_VIRGL_RENDERER_NATIVE_SHARE_TEXTURE = 1 << 12, + DORY_VIRGL_RENDERER_VENUS_ONLY_INITIALIZATION_FLAGS = + DORY_VIRGL_RENDERER_THREAD_SYNC | + DORY_VIRGL_RENDERER_USE_EXTERNAL_BLOB | + DORY_VIRGL_RENDERER_VENUS | + DORY_VIRGL_RENDERER_NO_VIRGL | + DORY_VIRGL_RENDERER_ASYNC_FENCE_CALLBACK | + DORY_VIRGL_RENDERER_RENDER_SERVER, + DORY_VIRGL_RENDERER_DUAL_METAL_INITIALIZATION_FLAGS = + DORY_VIRGL_RENDERER_THREAD_SYNC | + DORY_VIRGL_RENDERER_USE_GLES | + DORY_VIRGL_RENDERER_USE_EXTERNAL_BLOB | + DORY_VIRGL_RENDERER_VENUS | + DORY_VIRGL_RENDERER_NATIVE_SHARE_TEXTURE | + DORY_VIRGL_RENDERER_ASYNC_FENCE_CALLBACK | + DORY_VIRGL_RENDERER_RENDER_SERVER, + DORY_VIRGL_RENDERER_CAPSET_VIRGL2 = 2, + DORY_VIRGL_RENDERER_CAPSET_VENUS = 4, + DORY_VIRGL_RENDERER_BLOB_MEMORY_HOST3D = 0x0002, + DORY_VIRGL_RENDERER_BLOB_FLAG_MAPPABLE = 0x0001, + DORY_VIRGL_RENDERER_BLOB_FLAG_SHAREABLE = 0x0002, + DORY_VIRGL_RENDERER_BLOB_FD_TYPE_SHM = 0x0003, + DORY_VIRGL_RENDERER_NATIVE_HANDLE_METAL_TEXTURE = 2, + /* Exact pinned virglrenderer resource-bind ABI used by the native Metal scanout path. */ + DORY_VIRGL_RENDERER_RESOURCE_BIND_RENDER_TARGET = 1 << 1, + DORY_VIRGL_RENDERER_RESOURCE_BIND_SAMPLER_VIEW = 1 << 3, + DORY_VIRGL_RENDERER_RESOURCE_BIND_SCANOUT = 1 << 18, + DORY_VIRGL_RENDERER_FORMAT_BGRA8_UNORM = 1, + DORY_VIRGL_RENDERER_FORMAT_RGBA8_UNORM = 67, +}; + +typedef struct DoryVirglRendererSession DoryVirglRendererSession; + +/* + * Sanitized result of the one exact vrend decoder-error message accepted while a submit is in + * progress. `command_id` is the pinned `enum virgl_context_cmd` ordinal after the callback has + * matched the complete static command name; no raw renderer log bytes cross this boundary. + */ +typedef struct DoryVirglRendererSubmitDiagnostic { + uint32_t valid; + uint32_t context_id; + uint32_t command_id; + int32_t status; + /* + * Future exact decoder location tuple. 0 absent, 1 present, 2 ambiguous/malformed. + * Offset is a dword index and ordinal is a zero-based command-header index. Both must match + * the same header in a complete bounded walk before either can influence subtype reporting. + */ + uint32_t failed_command_location_disposition; + uint32_t failed_command_dword_offset; + uint32_t failed_command_ordinal; + /* 0 absent, 1 present, 2 ambiguous/malformed. Present values are pinned object types 0...11. */ + uint32_t create_object_subtype_disposition; + uint32_t create_object_subtype; + /* Saturating 0...255 CREATE_OBJECT header count; never a payload or stream length. */ + uint32_t create_object_candidate_count; + /* Closed 12-bit set of pinned object types observed in CREATE_OBJECT headers. */ + uint32_t create_object_subtype_mask; + /* + * Closed surface-validation reason from the exact dispatch tuple. Zero is absent/unknown; + * 1...7 are accepted only after correlation proves CREATE_OBJECT subtype SURFACE + EINVAL. + */ + uint32_t surface_failure_reason; + /* Closed fixed-prefix category; zero means no approved precursor was observed. */ + uint32_t precursor_category; +} DoryVirglRendererSubmitDiagnostic; + +/* + * Binds only renderer symbols resolved into the executable itself. The production target defines + * DORY_VIRGL_RENDERER_STATIC_LINKED and force-loads the reviewed archives. Other builds fail closed + * with ENOSYS; packaging rejects unresolved renderer symbols. No path, environment variable, + * loader, or ICD manifest participates in renderer selection. + */ +int32_t DoryVirglRendererSessionCreate(DoryVirglRendererSession **session); +void DoryVirglRendererSessionDestroy(DoryVirglRendererSession *session); + +int32_t DoryVirglRendererGetCapset( + DoryVirglRendererSession *session, + uint32_t capset_id, + uint32_t *maximum_version, + void *bytes, + size_t capacity, + size_t *actual_size +); +int32_t DoryVirglRendererContextCreate( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t flags, + const char *name, + size_t name_length +); +void DoryVirglRendererContextDestroy( + DoryVirglRendererSession *session, + uint32_t context_id +); +void DoryVirglRendererContextAttachResource( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t resource_id +); +void DoryVirglRendererContextDetachResource( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t resource_id +); +int32_t DoryVirglRendererSubmit( + DoryVirglRendererSession *session, + uint32_t context_id, + const void *command_bytes, + uint32_t dword_count, + DoryVirglRendererSubmitDiagnostic *diagnostic +); +/* Pure classifier used by the production callback and focused ABI/security tests. */ +int32_t DoryVirglRendererClassifySubmitDiagnosticMessage( + const char *message, + uint32_t expected_context_id, + DoryVirglRendererSubmitDiagnostic *diagnostic +); +/* Exact additive machine diagnostic; returns -EINVAL for a matching prefix with invalid grammar. */ +int32_t DoryVirglRendererClassifyExactSubmitDiagnosticMessage( + const char *message, + uint32_t expected_context_id, + DoryVirglRendererSubmitDiagnostic *diagnostic +); +uint32_t DoryVirglRendererClassifySubmitPrecursorMessage(const char *message); +int32_t DoryVirglRendererClassifyCreateObjectSubtype( + const void *command_bytes, + uint32_t dword_count, + DoryVirglRendererSubmitDiagnostic *diagnostic +); +/* + * Correlates an already-sanitized decoder tuple against command headers only. The submitted + * payload is neither retained nor returned. With no tuple, this preserves the legacy unique-header + * classifier; a present tuple additionally requires exact offset + ordinal + opcode agreement. + */ +int32_t DoryVirglRendererCorrelateCreateObjectSubtype( + const void *command_bytes, + uint32_t dword_count, + DoryVirglRendererSubmitDiagnostic *diagnostic +); +int32_t DoryVirglRendererBlobCreate( + DoryVirglRendererSession *session, + const DoryVirglRendererBlobCreateArguments *arguments +); +int32_t DoryVirglRendererResource3DCreate( + DoryVirglRendererSession *session, + const DoryVirglRendererResource3DCreateArguments *arguments +); +int32_t DoryVirglRendererResourceAttachBacking( + DoryVirglRendererSession *session, + uint32_t resource_id, + const struct iovec *iovecs, + uint32_t iovec_count +); +void DoryVirglRendererResourceDetachBacking( + DoryVirglRendererSession *session, + uint32_t resource_id +); +void DoryVirglRendererResourceUnref( + DoryVirglRendererSession *session, + uint32_t resource_id +); +int32_t DoryVirglRendererResourceGetMapInfo( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t *map_info +); +int32_t DoryVirglRendererResourceExportBlob( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t *fd_type, + int32_t *owned_file_descriptor +); +int32_t DoryVirglRendererResourceGetInfo( + DoryVirglRendererSession *session, + uint32_t resource_id, + DoryVirglRendererResourceInfo *info +); +/* + * Transfers one retained id for a native-share scanout resource. The caller must + * consume the returned +1 Objective-C reference exactly once. No texture pointer crosses XPC; + * the Swift worker converts it to an MTLSharedTextureHandle first. + */ +int32_t DoryVirglRendererResourceAcquireScanoutMetalTexture( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t width, + uint32_t height, + uint32_t virgl_format, + uint32_t stride, + uint32_t offset, + void **retained_texture +); +int32_t DoryVirglRendererTransferToHost( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t context_id, + uint32_t level, + uint32_t stride, + uint32_t layer_stride, + const DoryVirglRendererBox *box, + uint64_t offset, + const struct iovec *iovecs, + uint32_t iovec_count +); +int32_t DoryVirglRendererTransferFromHost( + DoryVirglRendererSession *session, + uint32_t resource_id, + uint32_t context_id, + uint32_t level, + uint32_t stride, + uint32_t layer_stride, + const DoryVirglRendererBox *box, + uint64_t offset, + const struct iovec *iovecs, + uint32_t iovec_count +); +int32_t DoryVirglRendererCreateContextFence( + DoryVirglRendererSession *session, + uint32_t context_id, + uint32_t flags, + uint32_t ring_index, + uint64_t fence_id +); +/* + * Registers a classic VirGL ctx0 fence while preserving the guest's full 64-bit identity. The + * shim allocates an independent collision-safe 32-bit renderer callback token and translates the + * callback back to `fence_id`; callers continue to export completion by the original id. + */ +int32_t DoryVirglRendererCreateGlobalFence( + DoryVirglRendererSession *session, + uint64_t fence_id +); +/* + * Transfers the read side of the shim's one-shot callback-backed completion pipe. The descriptor + * becomes readable only after virglrenderer retires this context/ring fence; this deliberately + * does not rely on virgl_renderer_export_fence, which is not a Venus completion API on macOS. + */ +int32_t DoryVirglRendererGetFenceFileDescriptor( + DoryVirglRendererSession *session, + uint64_t fence_id +); +/* + * Returns virglrenderer's borrowed event descriptor, or -1 when Darwin selected explicit polling. + * A nonnegative descriptor must be observed but never closed by the caller. + */ +int32_t DoryVirglRendererGetPollFileDescriptor( + DoryVirglRendererSession *session +); +void DoryVirglRendererPoll(DoryVirglRendererSession *session); + +/* Exported for a Swift test that proves it is using this imported C layout. */ +size_t DoryVirglRendererResourceInfoSize(void); +size_t DoryVirglRendererResourceInfoFileDescriptorOffset(void); + +#ifdef __cplusplus +} +#endif + +#endif /* DORY_VIRGL_RENDERER_SHIM_H */ diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/BoundedSerialConsolePublisher.swift b/Packages/ContainerizationEngine/Sources/dory-hv/BoundedSerialConsolePublisher.swift new file mode 100644 index 00000000..efa7ae40 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/BoundedSerialConsolePublisher.swift @@ -0,0 +1,419 @@ +import Darwin +import Foundation + +/// A fixed-capacity FIFO used by ``BoundedSerialConsolePublisher``. +/// +/// Keeping the storage fixed is intentional: a guest can write an unlimited console stream, but +/// it cannot make the host helper grow without bound. The publisher reports rejected bytes rather +/// than blocking a vCPU on host I/O or silently allocating more memory. +struct BoundedSerialByteRing { + private var storage: [UInt8] + private var head = 0 + private(set) var count = 0 + + init(capacity: Int) { + precondition(capacity > 0) + storage = [UInt8](repeating: 0, count: capacity) + } + + var capacity: Int { storage.count } + var isEmpty: Bool { count == 0 } + + @discardableResult + mutating func append(_ byte: UInt8) -> Bool { + guard count < storage.count else { return false } + storage[(head + count) % storage.count] = byte + count += 1 + return true + } + + mutating func removeFirst(maxCount: Int) -> [UInt8] { + precondition(maxCount > 0) + let removalCount = min(count, maxCount) + guard removalCount > 0 else { return [] } + + var result = [UInt8]() + result.reserveCapacity(removalCount) + let firstRun = min(removalCount, storage.count - head) + result.append(contentsOf: storage[head..<(head + firstRun)]) + let secondRun = removalCount - firstRun + if secondRun > 0 { + result.append(contentsOf: storage[0.. Snapshot { + Snapshot( + acceptedBytes: acceptedBytes, + overflowDroppedBytes: overflowDroppedBytes, + rejectedAfterStopBytes: rejectedAfterStopBytes, + processedBytes: processedBytes, + pendingBytes: pendingBytes, + peakPendingBytes: peakPendingBytes, + batches: batches, + writeSystemCalls: writeSystemCalls, + writeFailureCount: writeFailureCount, + firstWriteErrno: firstWriteErrno, + synchronizationFailureCount: synchronizationFailureCount, + firstSynchronizationErrno: firstSynchronizationErrno, + workerExited: workerExited + ) + } + } + + private struct WriteResult { + let systemCalls: UInt64 + let failureErrno: Int32? + } + + private let state: State + private let worker: Thread + + init( + destinations: [Destination], + capacityBytes: Int = 256 * 1_024, + batchBytes: Int = 16 * 1_024, + coalescingInterval: TimeInterval = 0.001 + ) throws { + guard !destinations.isEmpty else { + throw StartError.invalidConfiguration("at least one destination is required") + } + guard capacityBytes > 0 else { + throw StartError.invalidConfiguration("capacity must be positive") + } + guard batchBytes > 0, batchBytes <= capacityBytes else { + throw StartError.invalidConfiguration("batch size must be within the FIFO capacity") + } + guard coalescingInterval >= 0, coalescingInterval.isFinite else { + throw StartError.invalidConfiguration("coalescing interval must be finite and nonnegative") + } + + var ownedDestinations = [OwnedDestination]() + ownedDestinations.reserveCapacity(destinations.count) + for destination in destinations { + // The engine launches child helpers after the console is attached. Use an atomic + // close-on-exec duplicate so serial authority cannot leak across those exec boundaries. + let duplicate = Darwin.fcntl(destination.fileDescriptor, F_DUPFD_CLOEXEC, 0) + guard duplicate >= 0 else { + let savedErrno = errno + for owned in ownedDestinations { Darwin.close(owned.fileDescriptor) } + throw StartError.duplicateDescriptor( + fileDescriptor: destination.fileDescriptor, + errno: savedErrno + ) + } + ownedDestinations.append(OwnedDestination( + fileDescriptor: duplicate, + synchronizeOnStop: destination.synchronizeOnStop + )) + } + + let state = State( + destinations: ownedDestinations, + capacityBytes: capacityBytes, + batchBytes: batchBytes, + coalescingInterval: coalescingInterval + ) + self.state = state + let worker = Thread { Self.runWorker(state) } + worker.name = "dory-hv.serial-output" + worker.qualityOfService = .utility + self.worker = worker + worker.start() + } + + /// Enqueues one byte without waiting for host I/O. `false` means the exact bounded policy + /// rejected it because the FIFO was full or shutdown had already fenced new publication. + @discardableResult + func enqueue(_ byte: UInt8) -> Bool { + state.condition.lock() + defer { state.condition.unlock() } + guard state.accepting else { + state.rejectedAfterStopBytes &+= 1 + return false + } + let wasEmpty = state.ring.isEmpty + guard state.ring.append(byte) else { + state.overflowDroppedBytes &+= 1 + return false + } + state.acceptedBytes &+= 1 + state.peakPendingBytes = max(state.peakPendingBytes, state.pendingBytes) + if wasEmpty || state.ring.count >= state.batchBytes { + state.condition.signal() + } + return true + } + + /// Waits only at an explicit non-vCPU boundary for all bytes accepted before this call. + @discardableResult + func flush(timeout: TimeInterval = 5) -> Snapshot { + state.condition.lock() + let target = state.acceptedBytes + state.forceDrain = true + state.condition.broadcast() + waitLocked( + until: { state.processedBytes >= target || state.workerExited }, + timeout: timeout + ) + let result = state.snapshotLocked() + state.condition.unlock() + return result + } + + /// Fences producers, drains accepted bytes, synchronizes durable destinations, and retires the + /// worker. A timeout is represented by `workerExited == false`; it never turns into an unbounded + /// shutdown wait. + @discardableResult + func stop(timeout: TimeInterval = 5) -> Snapshot { + state.condition.lock() + state.accepting = false + state.stopRequested = true + state.forceDrain = true + state.condition.broadcast() + waitLocked(until: { state.workerExited }, timeout: timeout) + let result = state.snapshotLocked() + state.condition.unlock() + return result + } + + var snapshot: Snapshot { + state.condition.lock() + defer { state.condition.unlock() } + return state.snapshotLocked() + } + + private func waitLocked(until predicate: () -> Bool, timeout: TimeInterval) { + guard !predicate() else { return } + guard timeout > 0, timeout.isFinite else { return } + let deadline = Date().addingTimeInterval(timeout) + while !predicate(), state.condition.wait(until: deadline) {} + } + + private static func runWorker(_ state: State) { + while true { + state.condition.lock() + while state.ring.isEmpty, !state.stopRequested { + state.condition.wait() + } + + if state.ring.isEmpty, state.stopRequested { + state.condition.unlock() + finishWorker(state) + return + } + + if !state.forceDrain, + !state.stopRequested, + state.ring.count < state.batchBytes, + state.coalescingInterval > 0 { + let deadline = Date().addingTimeInterval(state.coalescingInterval) + while state.ring.count < state.batchBytes, + !state.forceDrain, + !state.stopRequested, + state.condition.wait(until: deadline) {} + } + + let batch = state.ring.removeFirst(maxCount: state.batchBytes) + state.inFlightBytes = batch.count + if state.ring.isEmpty { state.forceDrain = false } + state.condition.unlock() + + var writeResults = [WriteResult]() + writeResults.reserveCapacity(state.destinations.count) + for destination in state.destinations { + writeResults.append(writeAll(batch, to: destination.fileDescriptor)) + } + + state.condition.lock() + state.inFlightBytes = 0 + state.processedBytes &+= UInt64(batch.count) + state.batches &+= 1 + if state.ring.isEmpty { state.forceDrain = false } + for result in writeResults { + state.writeSystemCalls &+= result.systemCalls + if let failureErrno = result.failureErrno { + state.writeFailureCount &+= 1 + if state.firstWriteErrno == nil { state.firstWriteErrno = failureErrno } + } + } + state.condition.broadcast() + state.condition.unlock() + } + } + + private static func finishWorker(_ state: State) { + var synchronizationErrors = [Int32]() + for destination in state.destinations where destination.synchronizeOnStop { + while Darwin.fsync(destination.fileDescriptor) != 0 { + if errno == EINTR { continue } + synchronizationErrors.append(errno) + break + } + } + for destination in state.destinations { + Darwin.close(destination.fileDescriptor) + } + + state.condition.lock() + state.synchronizationFailureCount &+= UInt64(synchronizationErrors.count) + if state.firstSynchronizationErrno == nil { + state.firstSynchronizationErrno = synchronizationErrors.first + } + state.workerExited = true + state.condition.broadcast() + state.condition.unlock() + } + + private static func writeAll(_ bytes: [UInt8], to fileDescriptor: Int32) -> WriteResult { + var systemCalls: UInt64 = 0 + var failureErrno: Int32? + bytes.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + var offset = 0 + while offset < rawBuffer.count { + systemCalls &+= 1 + let written = Darwin.write( + fileDescriptor, + baseAddress.advanced(by: offset), + rawBuffer.count - offset + ) + if written > 0 { + offset += written + continue + } + if written < 0, errno == EINTR { continue } + failureErrno = written < 0 ? errno : EIO + break + } + } + return WriteResult(systemCalls: systemCalls, failureErrno: failureErrno) + } + + deinit { + _ = stop(timeout: 1) + withExtendedLifetime(worker) {} + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift new file mode 100644 index 00000000..3a3d5fc2 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopAudioBackend.swift @@ -0,0 +1,746 @@ +@preconcurrency import AVFAudio +@preconcurrency import AVFoundation +import DoryHV +import Foundation + +enum DoryMacAudioConfigurationRecoveryAction: Equatable { + case none + case restartOutput + case rebuildInput +} + +struct DoryMacAudioConfigurationRecoveryState: Equatable { + var outputConfigured: Bool + var outputRunning: Bool + var inputConfigured: Bool + var inputRunning: Bool + + func action(for direction: VirtioSoundDirection) -> DoryMacAudioConfigurationRecoveryAction { + switch direction { + case .output: + outputConfigured && outputRunning ? .restartOutput : .none + case .input: + inputConfigured && inputRunning ? .rebuildInput : .none + } + } +} + +struct DoryMacAudioQueueCapacity { + static let maximumBufferBytes = 4 * 1_024 * 1_024 + static let maximumPeriodBytes = 1 * 1_024 * 1_024 + + static func accepts(parameters: VirtioSoundPCMParameters) -> Bool { + parameters.bufferBytes > 0 + && parameters.bufferBytes <= maximumBufferBytes + && parameters.periodBytes > 0 + && parameters.periodBytes <= maximumPeriodBytes + && parameters.periodBytes <= parameters.bufferBytes + && parameters.bufferBytes % parameters.periodBytes == 0 + } + + static func accepts(currentBytes: Int, requestBytes: Int, capacityBytes: Int) -> Bool { + currentBytes >= 0 + && requestBytes > 0 + && capacityBytes > 0 + && currentBytes <= capacityBytes + && requestBytes <= capacityBytes - currentBytes + } +} + +enum DoryMacAudioPlaybackCompletionPolicy { + // VirtIO PCM completion advances Linux's ALSA hardware pointer. A data-consumed callback only + // means AVAudioPlayerNode removed the buffer from its scheduling queue, which can happen much + // faster than real time and lets the guest overrun the audible timeline. dataRendered is paced + // by the engine render timeline without depending on a physical device's played-back callback. + static let callbackType: AVAudioPlayerNodeCompletionCallbackType = .dataRendered +} + +struct DoryMacAudioRuntimeMetrics: Equatable, Sendable { + var queuedPlaybackBytes: Int + var pendingCaptureBytes: Int + var bufferedCaptureBytes: Int + var droppedPlaybackPeriods: UInt64 + var droppedCapturePeriods: UInt64 + var discardedCaptureBytes: UInt64 + var configurationChanges: Int +} + +/// Bridges the raw Hypervisor.framework virtio-snd device to Core Audio. Guest PCM remains the +/// standard signed 16-bit interleaved format while AVAudioEngine performs host sample-rate and +/// device conversion. +final class DoryMacAudioBackend: VirtioSoundHost, @unchecked Sendable { + private static let deliveryQueue = DispatchQueue( + label: "com.dory.desktop.audio.completion", + qos: .userInteractive + ) + + private struct CaptureRequest { + var id: UInt64 + var byteCount: Int + var fallbackArmed: Bool + var completion: @Sendable (Data?, UInt32) -> Void + } + + private struct PlaybackRequest { + var byteCount: Int + var completion: @Sendable (Bool, UInt32) -> Void + } + + private final class ConverterInput: @unchecked Sendable { + let buffer: AVAudioPCMBuffer + var served = false + + init(buffer: AVAudioPCMBuffer) { + self.buffer = buffer + } + } + + private let queue = DispatchQueue(label: "com.dory.desktop.audio", qos: .userInteractive) + // Keep playback and capture on independent graphs. PipeWire may prepare and start them in + // either order; mutating a shared running graph to add the other direction can leave an + // AVAudioPlayerNode permanently scheduled but never rendered. + let outputEngine = AVAudioEngine() + let inputEngine = AVAudioEngine() + private let player = AVAudioPlayerNode() + private let log: @Sendable (String) -> Void + private let notificationCenter: NotificationCenter + private let microphoneAuthorizationStatus: @Sendable () -> AVAuthorizationStatus + private let requestMicrophoneAccess: @Sendable ( + @escaping @Sendable (Bool) -> Void + ) -> Void + private var configurationObservers = [NSObjectProtocol]() + + private var outputParameters: VirtioSoundPCMParameters? + private var inputParameters: VirtioSoundPCMParameters? + private var outputRunning = false + private var inputRunning = false + private var inputTapInstalled = false + private var microphoneAccessDenied = false + private var permissionRequestInFlight = false + private var captureBytes = Data() + private var captureRequests = [CaptureRequest]() + private var pendingCaptureBytes = 0 + private var nextCaptureRequestID: UInt64 = 1 + private var captureTapCount = 0 + private var captureFallbackLogged = false + private var nextCaptureFallbackUptime: TimeInterval = 0 + private var queuedPlaybackBytes = 0 + private var playbackRequests = [UInt64: PlaybackRequest]() + private var nextPlaybackRequestID: UInt64 = 1 + private var observedConfigurationChanges = 0 + private var droppedPlaybackPeriods: UInt64 = 0 + private var droppedCapturePeriods: UInt64 = 0 + private var discardedCaptureBytes: UInt64 = 0 + + init( + log: @escaping @Sendable (String) -> Void, + notificationCenter: NotificationCenter = .default, + microphoneAuthorizationStatus: @escaping @Sendable () -> AVAuthorizationStatus = { + AVCaptureDevice.authorizationStatus(for: .audio) + }, + requestMicrophoneAccess: @escaping @Sendable ( + @escaping @Sendable (Bool) -> Void + ) -> Void = { completion in + AVCaptureDevice.requestAccess(for: .audio, completionHandler: completion) + } + ) { + self.log = log + self.notificationCenter = notificationCenter + self.microphoneAuthorizationStatus = microphoneAuthorizationStatus + self.requestMicrophoneAccess = requestMicrophoneAccess + outputEngine.attach(player) + configurationObservers = [ + notificationCenter.addObserver( + forName: .AVAudioEngineConfigurationChange, + object: outputEngine, + queue: nil + ) { [weak self] _ in + self?.scheduleConfigurationRecovery(for: .output) + }, + notificationCenter.addObserver( + forName: .AVAudioEngineConfigurationChange, + object: inputEngine, + queue: nil + ) { [weak self] _ in + self?.scheduleConfigurationRecovery(for: .input) + }, + ] + } + + deinit { + for observer in configurationObservers { + notificationCenter.removeObserver(observer) + } + } + + var configurationChangeCount: Int { queue.sync { observedConfigurationChanges } } + + var runtimeMetrics: DoryMacAudioRuntimeMetrics { + queue.sync { + DoryMacAudioRuntimeMetrics( + queuedPlaybackBytes: queuedPlaybackBytes, + pendingCaptureBytes: pendingCaptureBytes, + bufferedCaptureBytes: captureBytes.count, + droppedPlaybackPeriods: droppedPlaybackPeriods, + droppedCapturePeriods: droppedCapturePeriods, + discardedCaptureBytes: discardedCaptureBytes, + configurationChanges: observedConfigurationChanges + ) + } + } + + func configure( + streamID: Int, + direction: VirtioSoundDirection, + parameters: VirtioSoundPCMParameters + ) -> Bool { + guard Self.valid(parameters) else { return false } + return queue.sync { + switch direction { + case .output: + player.stop() + failPlaybackRequests() + outputEngine.disconnectNodeOutput(player) + guard let format = Self.floatFormat(parameters) else { return false } + outputEngine.connect(player, to: outputEngine.mainMixerNode, format: format) + outputParameters = parameters + outputRunning = false + case .input: + removeInputTap() + failCaptureRequests() + captureBytes.removeAll(keepingCapacity: true) + captureTapCount = 0 + captureFallbackLogged = false + nextCaptureFallbackUptime = 0 + microphoneAccessDenied = false + inputParameters = parameters + inputRunning = false + } + return true + } + } + + func prepare(streamID: Int, direction: VirtioSoundDirection) -> Bool { + queue.sync { + switch direction { + case .output: guard outputParameters != nil else { return false } + case .input: guard inputParameters != nil else { return false } + } + switch direction { + case .output: + if !outputEngine.isRunning { outputEngine.prepare() } + case .input: + // An input-only AVAudioEngine has no graph until its tap is installed. Preparing + // it here raises an Objective-C exception; installInputTapAndStartEngine() owns + // input graph preparation once macOS microphone access is available. + break + } + return true + } + } + + func start(streamID: Int, direction: VirtioSoundDirection) -> Bool { + queue.sync { + switch direction { + case .output: + outputRunning = true + guard outputParameters != nil, startOutputEngine() else { + outputRunning = false + return false + } + player.play() + return true + case .input: + guard inputParameters != nil else { return false } + inputRunning = true + guard startInputWhenAuthorized() else { + failCaptureRequests() + return false + } + satisfyCaptureRequests() + armCaptureFallbacks() + return true + } + } + } + + func stop(streamID: Int, direction: VirtioSoundDirection) -> Bool { + queue.sync { + switch direction { + case .output: + player.pause() + outputRunning = false + case .input: + inputRunning = false + microphoneAccessDenied = false + removeInputTap() + inputEngine.stop() + nextCaptureFallbackUptime = 0 + } + return true + } + } + + func release(streamID: Int, direction: VirtioSoundDirection) { + queue.sync { + switch direction { + case .output: + player.stop() + outputEngine.stop() + outputRunning = false + failPlaybackRequests() + outputParameters = nil + case .input: + inputRunning = false + microphoneAccessDenied = false + removeInputTap() + inputEngine.stop() + inputParameters = nil + captureBytes.removeAll(keepingCapacity: false) + nextCaptureFallbackUptime = 0 + failCaptureRequests() + } + } + } + + func enqueuePlayback( + _ data: Data, + parameters: VirtioSoundPCMParameters, + completion: @escaping @Sendable (Bool, UInt32) -> Void + ) -> Bool { + queue.sync { + guard outputParameters == parameters else { + return false + } + guard DoryMacAudioQueueCapacity.accepts( + currentBytes: queuedPlaybackBytes, + requestBytes: data.count, + capacityBytes: parameters.bufferBytes + ) else { + droppedPlaybackPeriods &+= 1 + return false + } + guard let buffer = Self.playbackBuffer(data: data, parameters: parameters) else { + return false + } + if outputRunning, !startOutputEngine() { return false } + let requestID = nextPlaybackRequestID + nextPlaybackRequestID &+= 1 + playbackRequests[requestID] = PlaybackRequest( + byteCount: data.count, + completion: completion + ) + queuedPlaybackBytes += data.count + // Completing the VirtIO descriptor advances Linux's ALSA hardware pointer, so this + // acknowledgement must be paced by Core Audio's render timeline. `.dataConsumed` + // merely drains the scheduling queue and can make the guest run PCM millions of + // periods ahead of real time. `.dataPlayedBack` depends on a physical-device callback + // that some application-owned engines do not publish. `.dataRendered` provides the + // correct bounded contract between those two stages. + player.scheduleBuffer( + buffer, + completionCallbackType: DoryMacAudioPlaybackCompletionPolicy.callbackType + ) { + [weak self] _ in + guard let self else { return } + self.queue.async { + self.completePlayback(requestID: requestID, success: true) + } + } + // AVAudioPlayerNode stops after an empty queue. Linux commonly starts the PCM stream + // before submitting the first period, so the play() issued by start() may have already + // gone idle by the time this buffer arrives. Rearm it for every transition from an + // empty/stopped player to queued audio, otherwise virtio TX descriptors never complete. + if outputRunning { player.play() } + return true + } + } + + func requestCapture( + byteCount: Int, + parameters: VirtioSoundPCMParameters, + completion: @escaping @Sendable (Data?, UInt32) -> Void + ) -> Bool { + queue.sync { + // Linux primes capture descriptors while the PCM is prepared, before PCM_START. Keep + // those requests pending just as playback keeps its pre-roll buffers scheduled. + guard byteCount > 0, + inputParameters == parameters, + !microphoneAccessDenied else { return false } + guard DoryMacAudioQueueCapacity.accepts( + currentBytes: pendingCaptureBytes, + requestBytes: byteCount, + capacityBytes: parameters.bufferBytes + ) else { + droppedCapturePeriods &+= 1 + return false + } + let requestID = nextCaptureRequestID + nextCaptureRequestID &+= 1 + pendingCaptureBytes += byteCount + captureRequests.append(CaptureRequest( + id: requestID, + byteCount: byteCount, + fallbackArmed: false, + completion: completion + )) + satisfyCaptureRequests() + if inputRunning { armCaptureFallback(requestID: requestID) } + return true + } + } + + func reset() { + queue.sync { + player.stop() + removeInputTap() + outputEngine.stop() + inputEngine.stop() + outputParameters = nil + inputParameters = nil + outputRunning = false + inputRunning = false + microphoneAccessDenied = false + failPlaybackRequests() + captureBytes.removeAll(keepingCapacity: false) + nextCaptureFallbackUptime = 0 + failCaptureRequests() + } + } + + private func startInputWhenAuthorized() -> Bool { + switch microphoneAuthorizationStatus() { + case .authorized: + microphoneAccessDenied = false + return installInputTapAndStartEngine() + case .notDetermined: + microphoneAccessDenied = false + guard !permissionRequestInFlight else { return true } + permissionRequestInFlight = true + log("requesting Mac microphone access; Linux capture will provide paced silence until permission is resolved") + requestMicrophoneAccess { [weak self] granted in + guard let self else { return } + self.queue.async { + self.permissionRequestInFlight = false + guard self.inputRunning else { return } + guard granted else { + self.inputRunning = false + self.microphoneAccessDenied = true + self.log("microphone access was denied; Linux capture requests were stopped") + self.failCaptureRequests() + return + } + if self.installInputTapAndStartEngine() { return } + self.log("microphone access is unavailable; Linux capture will continue with paced silence") + self.armCaptureFallbacks() + } + } + return true + case .denied, .restricted: + microphoneAccessDenied = true + log("microphone access is denied; enable it for Dory in System Settings > Privacy & Security > Microphone") + inputRunning = false + return false + @unknown default: + microphoneAccessDenied = true + inputRunning = false + return false + } + } + + private func scheduleConfigurationRecovery(for direction: VirtioSoundDirection) { + queue.async { [weak self] in + guard let self else { return } + observedConfigurationChanges += 1 + let state = DoryMacAudioConfigurationRecoveryState( + outputConfigured: outputParameters != nil, + outputRunning: outputRunning, + inputConfigured: inputParameters != nil, + inputRunning: inputRunning + ) + switch state.action(for: direction) { + case .none: + return + case .restartOutput: + player.stop() + outputEngine.stop() + failPlaybackRequests() + if startOutputEngine() { + log("Mac audio output recovered after the host device configuration changed") + } else { + log("Mac audio output is waiting for a usable host device after configuration changed") + } + case .rebuildInput: + removeInputTap() + inputEngine.stop() + captureBytes.removeAll(keepingCapacity: true) + captureTapCount = 0 + captureFallbackLogged = false + nextCaptureFallbackUptime = 0 + if startInputWhenAuthorized() { + log("Mac audio input recovered after the host device configuration changed") + } else { + log("Mac audio input is unavailable after the host device configuration changed") + } + satisfyCaptureRequests() + if inputRunning { + armCaptureFallbacks() + } else { + failCaptureRequests() + } + } + } + } + + private func installInputTapAndStartEngine() -> Bool { + guard inputRunning, let parameters = inputParameters else { return false } + if !inputTapInstalled { + let input = inputEngine.inputNode + let nativeFormat = input.outputFormat(forBus: 0) + guard nativeFormat.channelCount > 0, + nativeFormat.sampleRate > 0, + let targetFormat = Self.floatFormat(parameters), + let converter = AVAudioConverter(from: nativeFormat, to: targetFormat) else { + log("the selected Mac input device does not expose a usable audio format") + return false + } + input.installTap(onBus: 0, bufferSize: 1_024, format: nativeFormat) { + [weak self] buffer, _ in + let data = Self.convertCapture( + buffer: buffer, + converter: converter, + targetFormat: targetFormat, + parameters: parameters + ) + let inputFrames = buffer.frameLength + self?.queue.async { [weak self] in + guard let self else { return } + self.captureTapCount += 1 + if self.captureTapCount == 1 { + self.log("Mac microphone stream active (inputFrames=\(inputFrames), convertedBytes=\(data?.count ?? 0))") + } + if let data, !data.isEmpty { self.appendCapture(data) } + } + } + inputTapInstalled = true + } + return startInputEngine() + } + + private func startOutputEngine() -> Bool { + if outputEngine.isRunning { + if outputRunning, !player.isPlaying { player.play() } + return true + } + do { + outputEngine.prepare() + try outputEngine.start() + if outputRunning, !player.isPlaying { player.play() } + return true + } catch { + log("could not start Mac audio output: \(error)") + return false + } + } + + private func startInputEngine() -> Bool { + if inputEngine.isRunning { return true } + do { + inputEngine.prepare() + try inputEngine.start() + return true + } catch { + log("could not start Mac audio input: \(error)") + return false + } + } + + private func removeInputTap() { + guard inputTapInstalled else { return } + inputEngine.inputNode.removeTap(onBus: 0) + inputTapInstalled = false + } + + private func appendCapture(_ data: Data) { + captureBytes.append(data) + // Keep only the newest negotiated buffer when PipeWire temporarily stops submitting + // receive descriptors. Old microphone frames are less useful than current audio after a + // stall, and the guest-controlled PCM contract must remain a hard memory bound. + let capacity = max(0, inputParameters?.bufferBytes ?? 0) + if captureBytes.count > capacity { + let discarded = captureBytes.count - capacity + discardedCaptureBytes &+= UInt64(discarded) + captureBytes.removeFirst(discarded) + } + satisfyCaptureRequests() + } + + private func satisfyCaptureRequests() { + while let request = captureRequests.first, captureBytes.count >= request.byteCount { + captureRequests.removeFirst() + pendingCaptureBytes = max(0, pendingCaptureBytes - request.byteCount) + let data = Data(captureBytes.prefix(request.byteCount)) + captureBytes.removeFirst(request.byteCount) + let latency = UInt32(clamping: captureBytes.count) + Self.deliver { request.completion(data, latency) } + } + if captureRequests.isEmpty { nextCaptureFallbackUptime = 0 } + } + + private func armCaptureFallbacks() { + for requestID in captureRequests.lazy.filter({ !$0.fallbackArmed }).map(\.id) { + armCaptureFallback(requestID: requestID) + } + } + + private func armCaptureFallback(requestID: UInt64) { + guard inputRunning, + let parameters = inputParameters, + let index = captureRequests.firstIndex(where: { $0.id == requestID }), + !captureRequests[index].fallbackArmed else { return } + captureRequests[index].fallbackArmed = true + let byteCount = captureRequests[index].byteCount + let frames = byteCount / parameters.bytesPerFrame + let period = Double(frames) / parameters.sampleRate + // During a permission prompt, pace from the first period. Once the real input graph is + // active, allow two periods (at least 100 ms) before treating a missed callback as silence. + let now = ProcessInfo.processInfo.systemUptime + let firstDelay = inputTapInstalled ? max(0.1, 2 * period) : period + let deadline: TimeInterval + if nextCaptureFallbackUptime > now { + deadline = nextCaptureFallbackUptime + period + } else { + deadline = now + firstDelay + } + nextCaptureFallbackUptime = deadline + queue.asyncAfter(deadline: .now() + max(0, deadline - now)) { [weak self] in + guard let self, + let pendingIndex = self.captureRequests.firstIndex(where: { $0.id == requestID }) else { + return + } + let request = self.captureRequests.remove(at: pendingIndex) + self.pendingCaptureBytes = max(0, self.pendingCaptureBytes - request.byteCount) + if !self.captureFallbackLogged { + self.captureFallbackLogged = true + self.log("Mac microphone frames are pending; Linux capture is using paced silence") + } + if self.captureRequests.isEmpty { self.nextCaptureFallbackUptime = 0 } + Self.deliver { request.completion(Data(count: request.byteCount), 0) } + } + } + + private func failCaptureRequests() { + let requests = captureRequests + captureRequests.removeAll(keepingCapacity: false) + pendingCaptureBytes = 0 + droppedCapturePeriods &+= UInt64(requests.count) + for request in requests { Self.deliver { request.completion(nil, 0) } } + } + + private func completePlayback(requestID: UInt64, success: Bool) { + guard let request = playbackRequests.removeValue(forKey: requestID) else { return } + queuedPlaybackBytes = max(0, queuedPlaybackBytes - request.byteCount) + let latency = UInt32(clamping: queuedPlaybackBytes) + Self.deliver { request.completion(success, latency) } + } + + private func failPlaybackRequests() { + let requestIDs = playbackRequests.keys.sorted() + droppedPlaybackPeriods &+= UInt64(requestIDs.count) + for requestID in requestIDs { + completePlayback(requestID: requestID, success: false) + } + queuedPlaybackBytes = 0 + } + + private static func valid(_ parameters: VirtioSoundPCMParameters) -> Bool { + parameters.bytesPerSample == 2 + && (parameters.channels == 1 || parameters.channels == 2) + && (parameters.sampleRate == 44_100 || parameters.sampleRate == 48_000) + && DoryMacAudioQueueCapacity.accepts(parameters: parameters) + && parameters.periodBytes % parameters.bytesPerFrame == 0 + } + + private static func floatFormat(_ parameters: VirtioSoundPCMParameters) -> AVAudioFormat? { + AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: parameters.sampleRate, + channels: AVAudioChannelCount(parameters.channels), + interleaved: false + ) + } + + private static func playbackBuffer( + data: Data, + parameters: VirtioSoundPCMParameters + ) -> AVAudioPCMBuffer? { + guard !data.isEmpty, + data.count % parameters.bytesPerFrame == 0, + let format = floatFormat(parameters) else { return nil } + let frames = data.count / parameters.bytesPerFrame + guard let buffer = AVAudioPCMBuffer( + pcmFormat: format, + frameCapacity: AVAudioFrameCount(frames) + ), let channels = buffer.floatChannelData else { return nil } + buffer.frameLength = AVAudioFrameCount(frames) + data.withUnsafeBytes { raw in + guard let bytes = raw.bindMemory(to: UInt8.self).baseAddress else { return } + for frame in 0.. Data? { + let ratio = targetFormat.sampleRate / buffer.format.sampleRate + let capacity = AVAudioFrameCount(max(1, Int(ceil(Double(buffer.frameLength) * ratio)) + 32)) + guard let converted = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { + return nil + } + let input = ConverterInput(buffer: buffer) + var conversionError: NSError? + let status = converter.convert(to: converted, error: &conversionError) { _, outStatus in + if input.served { + outStatus.pointee = .noDataNow + return nil + } + input.served = true + outStatus.pointee = .haveData + return input.buffer + } + guard conversionError == nil, + status != .error, + converted.frameLength > 0, + let channels = converted.floatChannelData else { return nil } + + var bytes = Data(count: Int(converted.frameLength) * parameters.bytesPerFrame) + bytes.withUnsafeMutableBytes { raw in + guard let output = raw.bindMemory(to: UInt8.self).baseAddress else { return } + for frame in 0..> 8) + } + } + } + return bytes + } + + private static func deliver(_ operation: @escaping @Sendable () -> Void) { + deliveryQueue.async(execute: operation) + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopCameraBackend.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopCameraBackend.swift new file mode 100644 index 00000000..79df65b1 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopCameraBackend.swift @@ -0,0 +1,345 @@ +@preconcurrency import AVFoundation +import CoreImage +import CoreMedia +import CoreVideo +import DoryHV +import Foundation +import ImageIO + +enum DoryMacCameraError: Error, CustomStringConvertible { + case permissionDenied + case permissionRestricted + case permissionTimedOut + case unavailable + case inputCreationFailed(String) + case cannotAttachInput + case cannotAttachOutput + case startFailed + + var description: String { + switch self { + case .permissionDenied: + "Mac camera access is denied. Enable Dory Desktop in System Settings > Privacy & Security > Camera, or disable Camera for this desktop." + case .permissionRestricted: + "Mac camera access is restricted by system policy. Disable Camera for this desktop or ask the Mac administrator to allow it." + case .permissionTimedOut: + "Mac camera permission was not resolved in time. Try again and answer the macOS permission prompt, or disable Camera for this desktop." + case .unavailable: + "No usable Mac camera is available. Connect or enable a camera, or disable Camera for this desktop." + case .inputCreationFailed(let detail): + "The selected Mac camera could not be opened: \(detail)" + case .cannotAttachInput: + "The Mac camera input could not be attached to the capture session." + case .cannotAttachOutput: + "The Mac camera output could not be attached to the capture session." + case .startFailed: + "The Mac camera capture session did not start." + } + } +} + +/// Permission-aware AVFoundation source for the standard UVC device exported to Linux. Capture and +/// JPEG conversion run off the AppKit thread. The physical camera starts lazily on the first guest +/// video read and stops after the guest stream goes idle, matching the privacy lifecycle of a local +/// camera instead of holding the device for the VM's whole lifetime. +final class DoryMacCameraBackend: NSObject, DoryUVCCameraFrameSource, + AVCaptureVideoDataOutputSampleBufferDelegate, @unchecked Sendable +{ + private let condition = NSCondition() + private let captureQueue = DispatchQueue( + label: "com.dory.desktop.camera.capture", + qos: .userInitiated + ) + // AVCaptureSession start/stop and graph mutation are blocking operations. Keep them on one + // serial queue so a VM teardown can never race a still-configuring camera session. + private let sessionQueue = DispatchQueue( + label: "com.dory.desktop.camera.session", + qos: .userInitiated + ) + private let session = AVCaptureSession() + private let output = AVCaptureVideoDataOutput() + private let imageContext = CIContext(options: [.cacheIntermediates: false]) + private let colorSpace = CGColorSpaceCreateDeviceRGB() + private let log: @Sendable (String) -> Void + private var latestJPEG: Data? + private var generation: UInt64 = 0 + private var deliveredGeneration: UInt64 = 0 + private var requestedWidth = 1_280 + private var requestedHeight = 720 + private var idleGeneration: UInt64 = 0 + private var waitingConsumers = 0 + private var prepared = false + private var captureRunning = false + private var stopped = false + + init(log: @escaping @Sendable (String) -> Void) { + self.log = log + super.init() + } + + func prepareAndAuthorize(permissionTimeout: TimeInterval = 60) throws { + try Self.requireAuthorization(timeout: permissionTimeout) + guard let device = AVCaptureDevice.default(for: .video) else { + throw DoryMacCameraError.unavailable + } + let input: AVCaptureDeviceInput + do { + input = try AVCaptureDeviceInput(device: device) + } catch { + throw DoryMacCameraError.inputCreationFailed(String(describing: error)) + } + + try sessionQueue.sync { + condition.lock() + let mayPrepare = !stopped + condition.unlock() + guard mayPrepare else { throw DoryMacCameraError.startFailed } + session.beginConfiguration() + if session.canSetSessionPreset(.hd1280x720) { + session.sessionPreset = .hd1280x720 + } else if session.canSetSessionPreset(.vga640x480) { + session.sessionPreset = .vga640x480 + } + guard session.canAddInput(input) else { + session.commitConfiguration() + throw DoryMacCameraError.cannotAttachInput + } + session.addInput(input) + output.alwaysDiscardsLateVideoFrames = true + output.videoSettings = [ + kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA), + ] + output.setSampleBufferDelegate(self, queue: captureQueue) + guard session.canAddOutput(output) else { + session.removeInput(input) + session.commitConfiguration() + throw DoryMacCameraError.cannotAttachOutput + } + session.addOutput(output) + session.commitConfiguration() + + condition.lock() + guard !stopped else { + condition.unlock() + throw DoryMacCameraError.startFailed + } + prepared = true + condition.broadcast() + condition.unlock() + } + log("dory-hv desktop: Dory UVC Camera ready (\(device.localizedName))") + } + + func nextJPEGFrame(width: Int, height: Int, timeout: TimeInterval) -> Data? { + guard (width == 640 && height == 480) || (width == 1_280 && height == 720) else { + return nil + } + guard ensureCaptureRunning() else { return nil } + let deadline = Date().addingTimeInterval(max(0.001, min(timeout, 2))) + condition.lock() + guard captureRunning, !stopped else { + condition.unlock() + return nil + } + if requestedWidth != width || requestedHeight != height { + requestedWidth = width + requestedHeight = height + latestJPEG = nil + deliveredGeneration = generation + } + waitingConsumers += 1 + defer { + waitingConsumers -= 1 + idleGeneration &+= 1 + let idleToken = idleGeneration + let shouldScheduleIdleRelease = waitingConsumers == 0 && !stopped + condition.unlock() + if shouldScheduleIdleRelease { + scheduleIdleRelease(token: idleToken) + } + } + while !stopped, captureRunning, generation == deliveredGeneration { + guard condition.wait(until: deadline) else { return nil } + } + guard !stopped, captureRunning, + generation != deliveredGeneration, let latestJPEG else { return nil } + deliveredGeneration = generation + return latestJPEG + } + + func stop() { + condition.lock() + guard !stopped else { + condition.unlock() + return + } + stopped = true + prepared = false + captureRunning = false + idleGeneration &+= 1 + latestJPEG = nil + condition.broadcast() + condition.unlock() + output.setSampleBufferDelegate(nil, queue: nil) + sessionQueue.sync { + if session.isRunning { session.stopRunning() } + } + log("dory-hv desktop: Mac camera stopped") + } + + func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + condition.lock() + let shouldEncode = captureRunning && !stopped && waitingConsumers > 0 + let targetWidth = requestedWidth + let targetHeight = requestedHeight + condition.unlock() + guard shouldEncode, let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { + return + } + let image = Self.centerCroppedImage( + CIImage(cvPixelBuffer: pixelBuffer), + width: targetWidth, + height: targetHeight + ) + guard let jpeg = imageContext.jpegRepresentation( + of: image, + colorSpace: colorSpace, + options: [ + CIImageRepresentationOption( + rawValue: kCGImageDestinationLossyCompressionQuality as String + ): 0.82, + ] + ), !jpeg.isEmpty, jpeg.count <= 1_280 * 720 * 2 else { + return + } + condition.lock() + if !stopped { + latestJPEG = jpeg + generation &+= 1 + condition.broadcast() + } + condition.unlock() + } + + private static func centerCroppedImage(_ image: CIImage, width: Int, height: Int) -> CIImage { + let targetWidth = CGFloat(width) + let targetHeight = CGFloat(height) + let sourceExtent = image.extent + let scale = max(targetWidth / sourceExtent.width, targetHeight / sourceExtent.height) + let scaled = image.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + let crop = CGRect( + x: scaled.extent.midX - targetWidth / 2, + y: scaled.extent.midY - targetHeight / 2, + width: targetWidth, + height: targetHeight + ) + return scaled.cropped(to: crop).transformed( + by: CGAffineTransform(translationX: -crop.minX, y: -crop.minY) + ) + } + + deinit { + stop() + } + + private func ensureCaptureRunning() -> Bool { + sessionQueue.sync { + condition.lock() + guard prepared, !stopped else { + condition.unlock() + return false + } + if captureRunning { + condition.unlock() + return true + } + latestJPEG = nil + deliveredGeneration = generation + condition.unlock() + + session.startRunning() + let didStart = session.isRunning + + condition.lock() + if stopped { + condition.unlock() + if session.isRunning { session.stopRunning() } + return false + } + captureRunning = didStart + condition.broadcast() + condition.unlock() + if didStart { + log("dory-hv desktop: Mac camera capture started") + } + return didStart + } + } + + private func scheduleIdleRelease(token: UInt64) { + sessionQueue.asyncAfter(deadline: .now() + 2) { [weak self] in + guard let self else { return } + self.condition.lock() + let shouldRelease = !self.stopped + && self.captureRunning + && self.waitingConsumers == 0 + && self.idleGeneration == token + if shouldRelease { + self.captureRunning = false + self.latestJPEG = nil + self.deliveredGeneration = self.generation + self.condition.broadcast() + } + self.condition.unlock() + if shouldRelease, self.session.isRunning { + self.session.stopRunning() + self.log("dory-hv desktop: Mac camera capture released after guest stream idle") + } + } + } + + private static func requireAuthorization(timeout: TimeInterval) throws { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + return + case .denied: + throw DoryMacCameraError.permissionDenied + case .restricted: + throw DoryMacCameraError.permissionRestricted + case .notDetermined: + let semaphore = DispatchSemaphore(value: 0) + let result = LockedCameraAuthorization() + AVCaptureDevice.requestAccess(for: .video) { granted in + result.set(granted) + semaphore.signal() + } + guard semaphore.wait(timeout: .now() + max(1, min(timeout, 120))) == .success else { + throw DoryMacCameraError.permissionTimedOut + } + guard result.value else { throw DoryMacCameraError.permissionDenied } + @unknown default: + throw DoryMacCameraError.permissionRestricted + } + } +} + +private final class LockedCameraAuthorization: @unchecked Sendable { + private let lock = NSLock() + private var granted = false + + var value: Bool { + lock.lock() + defer { lock.unlock() } + return granted + } + + func set(_ value: Bool) { + lock.lock() + granted = value + lock.unlock() + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift new file mode 100644 index 00000000..9d06b0e1 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMetalDisplay.swift @@ -0,0 +1,2787 @@ +import AppKit +import CoreFoundation +import Darwin +import DoryHV +import DoryRendererWorkerContracts +import Foundation +import Metal +import QuartzCore + +/// `DesktopMode` enters `NSApplication.run()` from the executable's async `@MainActor` task. +/// That AppKit run loop remains live, but the enclosing dispatch-main task cannot return while the +/// VM is running, so another `DispatchQueue.main.async` block cannot begin. Publish UI work through +/// the main CFRunLoop source that AppKit actually drains instead of queueing behind the app loop. +enum DesktopAppRunLoop { + static func perform(_ operation: @escaping @MainActor @Sendable () -> Void) { + let runLoop = CFRunLoopGetMain() + CFRunLoopPerformBlock(runLoop, CFRunLoopMode.commonModes.rawValue as CFTypeRef) { + MainActor.assumeIsolated { operation() } + } + CFRunLoopWakeUp(runLoop) + } + + static func perform( + after delay: TimeInterval, + _ operation: @escaping @MainActor @Sendable () -> Void + ) { + DispatchQueue.global(qos: .userInteractive).asyncAfter( + deadline: .now() + max(0, delay) + ) { + perform(operation) + } + } +} + +/// Maps one window-local absolute pointer into the guest's deterministic horizontal scanout +/// layout. Virtio-input exposes one tablet for the whole desktop rather than one per connector. +final class DesktopPointerTopology: @unchecked Sendable { + private let lock = NSLock() + private var sizes: [VirtioGPUScanoutSize] + + init(sizes: [VirtioGPUScanoutSize]) { + self.sizes = sizes + } + + func update(scanoutID: UInt32, width: UInt32, height: UInt32) { + lock.withLock { + let index = Int(scanoutID) + guard sizes.indices.contains(index) else { return } + sizes[index] = VirtioGPUScanoutSize(width: width, height: height) + } + } + + func normalizedPoint( + scanoutID: UInt32, + localX: CGFloat, + localY: CGFloat + ) -> CGPoint { + lock.withLock { + let index = Int(scanoutID) + guard sizes.indices.contains(index), !sizes.isEmpty else { + return CGPoint( + x: min(1, max(0, localX)), + y: min(1, max(0, localY)) + ) + } + let totalWidth = sizes.reduce(UInt64(0)) { $0 + UInt64($1.width) } + let totalHeight = sizes.map(\.height).max() ?? 1 + let originX = sizes[.. SIMD4 { + let left = Float(sourceRect.x) / Float(backingWidth) + let right = Float(sourceRect.x + sourceRect.width) / Float(backingWidth) + let firstY = Float(sourceRect.y) / Float(backingHeight) + let secondY = Float(sourceRect.y + sourceRect.height) / Float(backingHeight) + return SIMD4( + left, + yOriginTop ? firstY : secondY, + right, + yOriginTop ? secondY : firstY + ) + } +} + +/// Measures the bounded producer-to-main-thread software presentation lane. Frame counters refer +/// to producer submissions; byte counters distinguish received payload, explicit mailbox copies, +/// display uploads, rejected/destroyed payload, and current backlog. +struct DesktopFrameMailboxMetrics: Equatable, Sendable { + var presentedFrames: UInt64 + var droppedFrames: UInt64 + var budgetRejectedFrames: UInt64 + /// Immutable producer payload observed at the mailbox boundary, including rejected frames. + var receivedFrameBytes: UInt64 + /// Bytes explicitly copied while normalizing stride or merging a software-frame backlog. + var stagingCopyBytes: UInt64 + /// Bytes copied from sparse cells into tightly packed main-thread upload buffers. + var drainCopyBytes: UInt64 + /// Texel payload submitted by successful CPU-to-display texture uploads. + var uploadedFrameBytes: UInt64 + /// Producer payload rejected, destroyed, disabled, or unpresentable before a complete upload. + var droppedFrameBytes: UInt64 + /// Retained payload/cell storage currently waiting for main-thread presentation. + var pendingFrameBytes: UInt64 + /// Number of accepted producer updates represented by the current retained state. + var pendingFrameDepth: UInt64 + + init( + presentedFrames: UInt64, + droppedFrames: UInt64, + budgetRejectedFrames: UInt64 = 0, + receivedFrameBytes: UInt64 = 0, + stagingCopyBytes: UInt64 = 0, + drainCopyBytes: UInt64 = 0, + uploadedFrameBytes: UInt64 = 0, + droppedFrameBytes: UInt64 = 0, + pendingFrameBytes: UInt64 = 0, + pendingFrameDepth: UInt64 = 0 + ) { + self.presentedFrames = presentedFrames + self.droppedFrames = droppedFrames + self.budgetRejectedFrames = budgetRejectedFrames + self.receivedFrameBytes = receivedFrameBytes + self.stagingCopyBytes = stagingCopyBytes + self.drainCopyBytes = drainCopyBytes + self.uploadedFrameBytes = uploadedFrameBytes + self.droppedFrameBytes = droppedFrameBytes + self.pendingFrameBytes = pendingFrameBytes + self.pendingFrameDepth = pendingFrameDepth + } +} + +struct DesktopCPUPresentationBudgetMetrics: Equatable, Sendable { + var residentBytes: Int + var peakResidentBytes: Int + var rejectedReservations: UInt64 +} + +/// One process-wide authority bounds retained software-frame payload and sparse accumulator cells +/// across every scanout mailbox. Per-mailbox limits remain useful for fairness, but cannot +/// substitute for this aggregate limit: Linux may bind one framebuffer to all 16 scanouts. +final class DesktopCPUPresentationBudget: @unchecked Sendable { + static let processDefault = DesktopCPUPresentationBudget( + maximumResidentBytes: 256 * 1_024 * 1_024 + ) + + private let lock = NSLock() + private let maximumResidentBytes: Int + private var residentBytes = 0 + private var peakResidentBytes = 0 + private var rejectedReservations: UInt64 = 0 + + init(maximumResidentBytes: Int) { + self.maximumResidentBytes = max(1, maximumResidentBytes) + } + + func replaceReservation(releasing oldBytes: Int, reserving newBytes: Int) -> Bool { + lock.withLock { + guard oldBytes >= 0, oldBytes <= residentBytes, + newBytes >= 0, + newBytes <= maximumResidentBytes - (residentBytes - oldBytes) else { + rejectedReservations = Self.saturatingAdd(rejectedReservations, 1) + return false + } + residentBytes = residentBytes - oldBytes + newBytes + peakResidentBytes = max(peakResidentBytes, residentBytes) + return true + } + } + + func release(_ byteCount: Int) { + guard byteCount > 0 else { return } + lock.withLock { + precondition(byteCount <= residentBytes, "CPU presentation budget over-release") + residentBytes -= byteCount + } + } + + var metrics: DesktopCPUPresentationBudgetMetrics { + lock.withLock { + DesktopCPUPresentationBudgetMetrics( + residentBytes: residentBytes, + peakResidentBytes: peakResidentBytes, + rejectedReservations: rejectedReservations + ) + } + } + + private static func saturatingAdd(_ value: UInt64, _ increment: UInt64) -> UInt64 { + let (sum, overflow) = value.addingReportingOverflow(increment) + return overflow ? UInt64.max : sum + } +} + +/// Keeps process-wide presentation bytes reserved until the main-thread consumer has finished +/// with the drained `Data`. Moving bytes out of a mailbox must not make the shared budget appear +/// free while the display is still reading those same bytes. +private final class DesktopCPUPresentationDrainReservation: @unchecked Sendable { + private let budget: DesktopCPUPresentationBudget + private let byteCount: Int + + init(budget: DesktopCPUPresentationBudget, byteCount: Int) { + self.budget = budget + self.byteCount = byteCount + } + + deinit { + budget.release(byteCount) + } +} + +/// Stable guest resource identity shared by copied CPU frames and worker-issued Metal surfaces. +/// Resource IDs may be reused, so the ID alone is never sufficient for presentation lifetime. +struct DesktopScanoutResourceIdentity: Hashable, Sendable { + var resourceID: UInt32 + var generation: UInt64 + + init(resourceID: UInt32, generation: UInt64) { + self.resourceID = resourceID + self.generation = generation + } + + init(frame: VirtioGPUScanoutFrame) { + self.init(resourceID: frame.resourceID, generation: frame.resourceGeneration) + } + + init(metalUpdate: VirtioGPUMetalScanoutUpdate) { + self.init( + resourceID: metalUpdate.resourceID, + generation: metalUpdate.resourceGeneration + ) + } +} + +/// Pure presentation lifetime state. It remembers releases independently of the currently bound +/// scanout so a delayed update for a retired generation cannot become visible after its release. +struct DesktopScanoutResourceLifetime { + private enum GenerationState { + case active(UInt64) + case released(through: UInt64) + + var generation: UInt64 { + switch self { + case .active(let generation), .released(let generation): generation + } + } + } + + private var generations: [UInt32: GenerationState] = [:] + private(set) var boundIdentity: DesktopScanoutResourceIdentity? + + func accepts(_ identity: DesktopScanoutResourceIdentity) -> Bool { + guard let state = generations[identity.resourceID] else { return true } + switch state { + case .active(let generation): + return identity.generation >= generation + case .released(let throughGeneration): + return identity.generation > throughGeneration + } + } + + @discardableResult + mutating func bind(_ identity: DesktopScanoutResourceIdentity) -> Bool { + guard accepts(identity) else { return false } + generations[identity.resourceID] = .active(identity.generation) + boundIdentity = identity + return true + } + + /// Returns true only when this release retired the currently displayed resource generation. + @discardableResult + mutating func release(resourceID: UInt32, throughGeneration: UInt64) -> Bool { + if (generations[resourceID]?.generation ?? 0) <= throughGeneration { + generations[resourceID] = .released(through: throughGeneration) + } + guard let binding = boundIdentity, + binding.resourceID == resourceID, + binding.generation <= throughGeneration else { + return false + } + boundIdentity = nil + return true + } + + mutating func unbind() { + boundIdentity = nil + } +} + +/// Preserves every accepted damage pixel without allocating a complete framebuffer in the +/// mailbox. The normal one-update case retains the producer's immutable `Data` and drains it +/// without a copy. Only a stalled main thread with multiple non-superseding updates activates a +/// sparse accumulator whose cells are derived from one host page. Dirty masks ensure that holes +/// between distant rectangles are never copied or uploaded as if they were valid pixels. +final class DesktopScanoutFrameCoalescer { + enum AppendOutcome: Equatable { + case accepted + case invalid + case budgetExceeded + } + + fileprivate struct ResourceKey: Hashable { + var resourceID: UInt32 + var resourceGeneration: UInt64 + } + + fileprivate struct TileKey: Hashable { + var column: UInt32 + var row: UInt32 + } + + fileprivate struct Tile { + var width: Int + var height: Int + var bytes: Data + var dirtyRows: [UInt64] + var dirtyPixelCount: Int + } + + fileprivate final class TiledStorage { + var tiles: [TileKey: Tile] = [:] + var residentBytes = 0 + var dirtyPixelCount = 0 + } + + fileprivate enum Storage { + case single(VirtioGPUScanoutFrame) + case tiled(TiledStorage) + } + + fileprivate struct Surface { + var scanoutID: UInt32 + var format: UInt32 + var width: UInt32 + var height: UInt32 + var storage: Storage + var inputFrameCount: UInt64 + var inputPayloadByteCount: UInt64 + + var residentBytes: Int { + switch storage { + case .single(let frame): frame.bytes.count + case .tiled(let tiled): tiled.residentBytes + } + } + + var uploadByteCount: Int { + switch storage { + case .single(let frame): + Int(frame.dirtyRect.width) * Int(frame.dirtyRect.height) * 4 + case .tiled(let tiled): + tiled.dirtyPixelCount * 4 + } + } + } + + struct Metrics: Equatable { + var residentBytes: Int + var peakResidentBytes: Int + var pendingFrameDepth: UInt64 + var peakPendingFrameDepth: UInt64 + var stagingCopyBytes: UInt64 + } + + struct Removal: Equatable { + var frameCount: UInt64 + var payloadByteCount: UInt64 + } + + struct Drain { + struct Batch { + var frameRange: Range + var inputFrameCount: UInt64 + var inputPayloadByteCount: UInt64 + } + + var frames: [VirtioGPUScanoutFrame] + var inputFrameCount: UInt64 + var outputByteCount: Int + var copyByteCount: Int + var hasMorePendingFrames: Bool + var batches: [Batch] + fileprivate var reservation: DesktopCPUPresentationDrainReservation? + } + + struct PendingDrain { + fileprivate var entries: [(ResourceKey, Surface)] + fileprivate var inputFrameCount: UInt64 + fileprivate var outputByteCount: Int + fileprivate var hasMorePendingFrames: Bool + fileprivate var reservation: DesktopCPUPresentationDrainReservation? + + fileprivate func materialize() -> Drain { + var frames = [VirtioGPUScanoutFrame]() + var batches = [Drain.Batch]() + var copyByteCount = 0 + for (key, surface) in entries { + let start = frames.count + switch surface.storage { + case .single(let frame): + frames.append(frame) + case .tiled(let tiled): + for rect in DesktopScanoutFrameCoalescer.dirtyRectangles(in: tiled) { + frames.append(DesktopScanoutFrameCoalescer.makeFrame( + key: key, + surface: surface, + tiled: tiled, + rect: rect + )) + copyByteCount += Int(rect.width) * Int(rect.height) * 4 + } + } + batches.append(Drain.Batch( + frameRange: start.. Bool { + appendOutcome(frame) == .accepted + } + + func appendOutcome(_ frame: VirtioGPUScanoutFrame) -> AppendOutcome { + let sourceRowBytes = UInt64(frame.dirtyRect.width) * 4 + let requiredSourceBytes = UInt64(frame.stride) * UInt64(frame.dirtyRect.height) + guard frame.width > 0, frame.height > 0, + frame.dirtyRect.width > 0, frame.dirtyRect.height > 0, + frame.dirtyRect.x <= frame.width, + frame.dirtyRect.width <= frame.width - frame.dirtyRect.x, + frame.dirtyRect.y <= frame.height, + frame.dirtyRect.height <= frame.height - frame.dirtyRect.y, + UInt64(frame.stride) >= sourceRowBytes, + requiredSourceBytes <= UInt64(Int.max), + UInt64(frame.bytes.count) == requiredSourceBytes else { + return .invalid + } + guard let surfaceByteCount = Self.rgbaByteCount( + width: frame.width, + height: frame.height + ), surfaceByteCount <= UInt64(Int.max), + surfaceByteCount <= UInt64(maximumSurfaceBytes) else { + return .budgetExceeded + } + + let normalized = Self.normalized(frame) + let key = ResourceKey( + resourceID: frame.resourceID, + resourceGeneration: frame.resourceGeneration + ) + var surface = surfaces.removeValue(forKey: key) + if let surface, + surface.scanoutID != frame.scanoutID + || surface.format != frame.format + || surface.width != frame.width + || surface.height != frame.height { + surfaces[key] = surface + return .invalid + } + + let oldResidentBytes = surface?.residentBytes ?? 0 + let newResidentBytes: Int + if let surface { + switch surface.storage { + case .single(let oldFrame): + if Self.contains(normalized.frame.dirtyRect, oldFrame.dirtyRect) { + newResidentBytes = normalized.frame.bytes.count + } else { + newResidentBytes = Self.tileStorageByteCount( + rects: [oldFrame.dirtyRect, normalized.frame.dirtyRect], + surfaceWidth: frame.width, + surfaceHeight: frame.height + ) + } + case .tiled(let tiled): + if Self.isFullSurface(normalized.frame.dirtyRect, width: frame.width, height: frame.height) { + newResidentBytes = normalized.frame.bytes.count + } else { + newResidentBytes = tiled.residentBytes + Self.additionalTileStorageByteCount( + rect: normalized.frame.dirtyRect, + excluding: tiled.tiles.keys, + surfaceWidth: frame.width, + surfaceHeight: frame.height + ) + } + } + } else { + newResidentBytes = normalized.frame.bytes.count + } + + let residentWithoutOld = residentSurfaceBytes - oldResidentBytes + guard newResidentBytes <= maximumAggregateSurfaceBytes - residentWithoutOld else { + if let surface { surfaces[key] = surface } + return .budgetExceeded + } + guard sharedBudget.replaceReservation( + releasing: oldResidentBytes, + reserving: newResidentBytes + ) else { + if let surface { surfaces[key] = surface } + return .budgetExceeded + } + + var additionalCopies = normalized.copyByteCount + if var existing = surface { + let nextInputFrameCount = Self.saturatingAdd(existing.inputFrameCount, 1) + switch existing.storage { + case .single(let oldFrame): + if Self.contains(normalized.frame.dirtyRect, oldFrame.dirtyRect) { + existing.storage = .single(normalized.frame) + } else { + let tiled = TiledStorage() + Self.apply(oldFrame, to: tiled, surfaceWidth: frame.width, surfaceHeight: frame.height) + Self.apply( + normalized.frame, + to: tiled, + surfaceWidth: frame.width, + surfaceHeight: frame.height + ) + additionalCopies = Self.saturatingAdd( + additionalCopies, + UInt64(oldFrame.dirtyRect.width) * UInt64(oldFrame.dirtyRect.height) * 4 + ) + additionalCopies = Self.saturatingAdd( + additionalCopies, + UInt64(normalized.frame.dirtyRect.width) + * UInt64(normalized.frame.dirtyRect.height) * 4 + ) + existing.storage = .tiled(tiled) + } + case .tiled(let tiled): + if Self.isFullSurface( + normalized.frame.dirtyRect, + width: frame.width, + height: frame.height + ) { + existing.storage = .single(normalized.frame) + } else { + Self.apply( + normalized.frame, + to: tiled, + surfaceWidth: frame.width, + surfaceHeight: frame.height + ) + additionalCopies = Self.saturatingAdd( + additionalCopies, + UInt64(normalized.frame.dirtyRect.width) + * UInt64(normalized.frame.dirtyRect.height) * 4 + ) + } + } + existing.inputFrameCount = nextInputFrameCount + existing.inputPayloadByteCount = Self.saturatingAdd( + existing.inputPayloadByteCount, + UInt64(frame.bytes.count) + ) + surface = existing + } else { + surface = Surface( + scanoutID: frame.scanoutID, + format: frame.format, + width: frame.width, + height: frame.height, + storage: .single(normalized.frame), + inputFrameCount: 1, + inputPayloadByteCount: UInt64(frame.bytes.count) + ) + } + guard let surface else { preconditionFailure("accepted scanout update lost its surface") } + residentSurfaceBytes = residentWithoutOld + surface.residentBytes + peakResidentSurfaceBytes = max(peakResidentSurfaceBytes, residentSurfaceBytes) + pendingFrameDepth = Self.saturatingAdd(pendingFrameDepth, 1) + peakPendingFrameDepth = max(peakPendingFrameDepth, pendingFrameDepth) + stagingCopyByteCount = Self.saturatingAdd(stagingCopyByteCount, additionalCopies) + surfaces[key] = surface + pendingOrder.removeAll { $0 == key } + pendingOrder.append(key) + return .accepted + } + + func remove(resourceID: UInt32, throughGeneration: UInt64) -> UInt64 { + removeOutcome(resourceID: resourceID, throughGeneration: throughGeneration).frameCount + } + + func removeOutcome(resourceID: UInt32, throughGeneration: UInt64) -> Removal { + let matching = surfaces.keys.filter { + $0.resourceID == resourceID && $0.resourceGeneration <= throughGeneration + } + var removed: UInt64 = 0 + var removedPayloadBytes: UInt64 = 0 + for key in matching { + let surface = surfaces.removeValue(forKey: key) + if let surface { + residentSurfaceBytes -= surface.residentBytes + sharedBudget.release(surface.residentBytes) + pendingFrameDepth = Self.saturatingSubtract( + pendingFrameDepth, + surface.inputFrameCount + ) + removedPayloadBytes = Self.saturatingAdd( + removedPayloadBytes, + surface.inputPayloadByteCount + ) + } + removed = Self.saturatingAdd( + removed, + surface?.inputFrameCount ?? 0 + ) + } + pendingOrder.removeAll { + $0.resourceID == resourceID && $0.resourceGeneration <= throughGeneration + } + return Removal(frameCount: removed, payloadByteCount: removedPayloadBytes) + } + + func discardPending() -> UInt64 { + discardPendingOutcome().frameCount + } + + func discardPendingOutcome() -> Removal { + var discarded: UInt64 = 0 + var discardedPayloadBytes: UInt64 = 0 + for key in pendingOrder { + guard let surface = surfaces.removeValue(forKey: key) else { continue } + discarded = Self.saturatingAdd(discarded, surface.inputFrameCount) + discardedPayloadBytes = Self.saturatingAdd( + discardedPayloadBytes, + surface.inputPayloadByteCount + ) + residentSurfaceBytes -= surface.residentBytes + sharedBudget.release(surface.residentBytes) + } + pendingFrameDepth = 0 + pendingOrder.removeAll(keepingCapacity: true) + return Removal(frameCount: discarded, payloadByteCount: discardedPayloadBytes) + } + + func drain() -> Drain { + takeDrain().materialize() + } + + func takeDrain() -> PendingDrain { + var entries = [(ResourceKey, Surface)]() + var inputFrameCount: UInt64 = 0 + var outputByteCount = 0 + var reservedByteCount = 0 + var remainingOrder = [ResourceKey]() + for key in pendingOrder { + guard let surface = surfaces[key] else { continue } + guard surface.uploadByteCount <= maximumDrainBytes - outputByteCount else { + remainingOrder.append(key) + continue + } + guard let removed = surfaces.removeValue(forKey: key) else { continue } + entries.append((key, removed)) + outputByteCount += removed.uploadByteCount + inputFrameCount = Self.saturatingAdd(inputFrameCount, surface.inputFrameCount) + reservedByteCount += removed.residentBytes + residentSurfaceBytes -= removed.residentBytes + pendingFrameDepth = Self.saturatingSubtract( + pendingFrameDepth, + removed.inputFrameCount + ) + } + pendingOrder = remainingOrder + let reservation = reservedByteCount > 0 + ? DesktopCPUPresentationDrainReservation( + budget: sharedBudget, + byteCount: reservedByteCount + ) + : nil + return PendingDrain( + entries: entries, + inputFrameCount: inputFrameCount, + outputByteCount: outputByteCount, + hasMorePendingFrames: !remainingOrder.isEmpty, + reservation: reservation + ) + } + + var metrics: Metrics { + Metrics( + residentBytes: residentSurfaceBytes, + peakResidentBytes: peakResidentSurfaceBytes, + pendingFrameDepth: pendingFrameDepth, + peakPendingFrameDepth: peakPendingFrameDepth, + stagingCopyBytes: stagingCopyByteCount + ) + } + + private static var tileEdge: UInt32 { + let pagePixels = max(1, Int(HostPage.size) / 4) + var edge = 1 + while edge < 64, (edge * 2) * (edge * 2) <= pagePixels { edge *= 2 } + return UInt32(edge) + } + + private static func normalized( + _ frame: VirtioGPUScanoutFrame + ) -> (frame: VirtioGPUScanoutFrame, copyByteCount: UInt64) { + let tightStride = Int(frame.dirtyRect.width) * 4 + let tightByteCount = tightStride * Int(frame.dirtyRect.height) + if Int(frame.stride) == tightStride, frame.bytes.count == tightByteCount { + return (frame, 0) + } + var output = Data(count: tightByteCount) + output.withUnsafeMutableBytes { destination in + frame.bytes.withUnsafeBytes { source in + guard let destinationBase = destination.baseAddress, + let sourceBase = source.baseAddress else { return } + for row in 0.. Bool { + outer.x <= inner.x + && outer.y <= inner.y + && UInt64(outer.x) + UInt64(outer.width) + >= UInt64(inner.x) + UInt64(inner.width) + && UInt64(outer.y) + UInt64(outer.height) + >= UInt64(inner.y) + UInt64(inner.height) + } + + private static func isFullSurface(_ rect: VirtioGPURect, width: UInt32, height: UInt32) -> Bool { + rect.x == 0 && rect.y == 0 && rect.width == width && rect.height == height + } + + private static func tileKeys(for rect: VirtioGPURect) -> [TileKey] { + let edge = tileEdge + let lastColumn = (rect.x + rect.width - 1) / edge + let lastRow = (rect.y + rect.height - 1) / edge + var keys = [TileKey]() + keys.reserveCapacity( + Int(lastColumn - rect.x / edge + 1) * Int(lastRow - rect.y / edge + 1) + ) + for row in rect.y / edge...lastRow { + for column in rect.x / edge...lastColumn { + keys.append(TileKey(column: column, row: row)) + } + } + return keys + } + + private static func tileByteCount( + key: TileKey, + surfaceWidth: UInt32, + surfaceHeight: UInt32 + ) -> Int { + let originX = key.column * tileEdge + let originY = key.row * tileEdge + let width = min(tileEdge, surfaceWidth - originX) + let height = min(tileEdge, surfaceHeight - originY) + return Int(width) * Int(height) * 4 + } + + private static func tileStorageByteCount( + rects: [VirtioGPURect], + surfaceWidth: UInt32, + surfaceHeight: UInt32 + ) -> Int { + let keys = Set(rects.flatMap(tileKeys(for:))) + return keys.reduce(0) { + $0 + tileByteCount(key: $1, surfaceWidth: surfaceWidth, surfaceHeight: surfaceHeight) + } + } + + private static func additionalTileStorageByteCount( + rect: VirtioGPURect, + excluding existing: Dictionary.Keys, + surfaceWidth: UInt32, + surfaceHeight: UInt32 + ) -> Int { + let existing = Set(existing) + return tileKeys(for: rect).reduce(0) { total, key in + total + (existing.contains(key) ? 0 : tileByteCount( + key: key, + surfaceWidth: surfaceWidth, + surfaceHeight: surfaceHeight + )) + } + } + + private static func apply( + _ frame: VirtioGPUScanoutFrame, + to tiled: TiledStorage, + surfaceWidth: UInt32, + surfaceHeight: UInt32 + ) { + let edge = tileEdge + frame.bytes.withUnsafeBytes { source in + guard let sourceBase = source.baseAddress else { return } + for key in tileKeys(for: frame.dirtyRect) { + let originX = key.column * edge + let originY = key.row * edge + let tileWidth = Int(min(edge, surfaceWidth - originX)) + let tileHeight = Int(min(edge, surfaceHeight - originY)) + var tile = tiled.tiles.removeValue(forKey: key) ?? Tile( + width: tileWidth, + height: tileHeight, + bytes: Data(count: tileWidth * tileHeight * 4), + dirtyRows: [UInt64](repeating: 0, count: tileHeight), + dirtyPixelCount: 0 + ) + if tile.dirtyPixelCount == 0 { tiled.residentBytes += tile.bytes.count } + + let intersectionX = max(frame.dirtyRect.x, originX) + let intersectionY = max(frame.dirtyRect.y, originY) + let intersectionRight = min( + UInt64(frame.dirtyRect.x) + UInt64(frame.dirtyRect.width), + UInt64(originX) + UInt64(tileWidth) + ) + let intersectionBottom = min( + UInt64(frame.dirtyRect.y) + UInt64(frame.dirtyRect.height), + UInt64(originY) + UInt64(tileHeight) + ) + let copyWidth = Int(intersectionRight - UInt64(intersectionX)) + let copyHeight = Int(intersectionBottom - UInt64(intersectionY)) + let localX = Int(intersectionX - originX) + let localY = Int(intersectionY - originY) + let sourceX = Int(intersectionX - frame.dirtyRect.x) + let sourceY = Int(intersectionY - frame.dirtyRect.y) + let dirtyMask: UInt64 = copyWidth == 64 + ? .max + : ((UInt64(1) << UInt64(copyWidth)) - 1) << UInt64(localX) + + tile.bytes.withUnsafeMutableBytes { destination in + guard let destinationBase = destination.baseAddress else { return } + for row in 0.. [VirtioGPURect] { + var spansByRow: [UInt32: [HorizontalSpan]] = [:] + let sortedTiles = tiled.tiles.sorted { + ($0.key.row, $0.key.column) < ($1.key.row, $1.key.column) + } + for (key, tile) in sortedTiles { + for localY in 0.. VirtioGPUScanoutFrame { + let stride = Int(rect.width) * 4 + var output = Data(count: stride * Int(rect.height)) + output.withUnsafeMutableBytes { destination in + guard let destinationBase = destination.baseAddress else { return } + for row in 0.. UInt64 { + let (sum, overflow) = value.addingReportingOverflow(increment) + return overflow ? UInt64.max : sum + } + + private static func saturatingSubtract(_ value: UInt64, _ decrement: UInt64) -> UInt64 { + value >= decrement ? value - decrement : 0 + } + + private static func rgbaByteCount(width: UInt32, height: UInt32) -> UInt64? { + let (pixels, pixelOverflow) = UInt64(width).multipliedReportingOverflow( + by: UInt64(height) + ) + guard !pixelOverflow else { return nil } + let (bytes, byteOverflow) = pixels.multipliedReportingOverflow(by: 4) + return byteOverflow ? nil : bytes + } +} + +struct DesktopCPUFramePresentationResult { + var presented: [Bool] + var uploadedByteCount: UInt64 +} + +/// Tracks the evdev modifier keys already published to Linux. Physical keyboards normally emit +/// AppKit `flagsChanged` events, but accessibility and remote-input sources may encode a modifier +/// only in the following key event. Reconcile both forms so shifted characters never arrive as +/// their unmodified key while avoiding duplicate modifier presses for ordinary hardware input. +struct DesktopKeyboardModifierState: Sendable { + enum Modifier: CaseIterable, Hashable, Sendable { + case command + case shift + case capsLock + case option + case control + + var canonicalLinuxCode: UInt16 { + switch self { + case .command: 125 + case .shift: 42 + case .capsLock: 58 + case .option: 56 + case .control: 29 + } + } + } + + private var activeCodes: [Modifier: Set] = [:] + + mutating func reconcile(activeModifiers: Set) -> [VirtioInputEvent] { + var events = [VirtioInputEvent]() + for modifier in Modifier.allCases { + let codes = activeCodes[modifier] ?? [] + if activeModifiers.contains(modifier) { + guard codes.isEmpty else { continue } + let code = modifier.canonicalLinuxCode + activeCodes[modifier] = [code] + events.append(VirtioInputEvent(type: 1, code: code, value: 1)) + } else { + guard !codes.isEmpty else { continue } + activeCodes[modifier] = nil + events.append(contentsOf: codes.sorted().map { + VirtioInputEvent(type: 1, code: $0, value: 0) + }) + } + } + return events + } + + mutating func update( + modifier: Modifier, + linuxCode: UInt16, + pressed: Bool + ) -> [VirtioInputEvent] { + var codes = activeCodes[modifier] ?? [] + let changed: Bool + if pressed { + changed = codes.insert(linuxCode).inserted + } else { + changed = codes.remove(linuxCode) != nil + } + activeCodes[modifier] = codes.isEmpty ? nil : codes + guard changed else { return [] } + return [VirtioInputEvent(type: 1, code: linuxCode, value: pressed ? 1 : 0)] + } + + mutating func reset() { + activeCodes.removeAll(keepingCapacity: true) + } +} + +/// Owns the AppKit-to-evdev scroll boundary. Linux input stacks use the high-resolution wheel +/// events for trackpad responsiveness and the matching discrete events for legacy applications. +/// Both axes and both resolutions must cross this boundary together; filtering the frame here +/// reduces precise trackpad gestures to occasional coarse mouse-wheel ticks. +struct DesktopScrollEventState: Sendable { + private var accumulator = VirtioInputScrollAccumulator() + + mutating func events( + horizontalDelta: Double, + verticalDelta: Double, + hasPreciseDeltas: Bool + ) -> [VirtioInputEvent] { + accumulator.events( + horizontalDelta: horizontalDelta, + verticalDelta: verticalDelta, + hasPreciseDeltas: hasPreciseDeltas + ) + } + + mutating func reset() { + accumulator = VirtioInputScrollAccumulator() + } +} + +/// One AppKit surface owns keyboard, pointer, cursor, resize, and scanout geometry semantics for +/// the qualified Metal display. Presentation subclasses implement only their resource boundary; +/// they cannot silently substitute another renderer when their own validation or device fails. +@MainActor +class DesktopDisplayView: NSView { + private let keyboardInput: VirtioInput + private let pointerInput: VirtioInput + private let guestBackingScaleFactor: CGFloat + let scanoutID: UInt32 + private let pointerTopology: DesktopPointerTopology? + var scanoutSize = CGSize.zero + private var guestCursor = NSCursor.arrow + private var guestCursorUpdate: VirtioGPUCursorUpdate? + private var tracking: NSTrackingArea? + private var scrollEventState = DesktopScrollEventState() + private var pressedKeyboardInput = VirtioInputPressedState() + private var pressedPointerInput = VirtioInputPressedState() + private var keyboardModifierState = DesktopKeyboardModifierState() + private var resizeGeneration: UInt64 = 0 + var onDrawableSizeChange: ((UInt32, UInt32) -> Void)? + var onMacShortcut: ((NSEvent) -> Bool)? + + init( + frame: NSRect, + keyboardInput: VirtioInput, + pointerInput: VirtioInput, + guestBackingScaleFactor: CGFloat, + scanoutID: UInt32, + pointerTopology: DesktopPointerTopology? + ) { + self.keyboardInput = keyboardInput + self.pointerInput = pointerInput + self.guestBackingScaleFactor = guestBackingScaleFactor + self.scanoutID = scanoutID + self.pointerTopology = pointerTopology + super.init(frame: frame) + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var acceptsFirstResponder: Bool { true } + override var isFlipped: Bool { true } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + drawableSurfaceDidChange() + needsDisplay = true + } + + /// A dedicated guest display is often not the key macOS window yet (notably immediately after + /// entering fullscreen). Activation and guest input delivery intentionally share that click. + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override func updateTrackingAreas() { + if let tracking { removeTrackingArea(tracking) } + let replacement = NSTrackingArea( + rect: bounds, + options: [.activeInKeyWindow, .inVisibleRect, .mouseMoved, .mouseEnteredAndExited], + owner: self, + userInfo: nil + ) + addTrackingArea(replacement) + tracking = replacement + super.updateTrackingAreas() + } + + /// Presentation hooks deliberately reject by default. A mismatched producer/view pairing is a + /// terminal capability error at the mailbox instead of an ambient graphics fallback. + @discardableResult + func present(_ frames: [VirtioGPUScanoutFrame]) -> DesktopCPUFramePresentationResult { + DesktopCPUFramePresentationResult( + presented: [Bool](repeating: false, count: frames.count), + uploadedByteCount: 0 + ) + } + + @discardableResult + func present(_ update: VirtioGPUMetalScanoutUpdate) -> Bool { false } + + func release(resourceID: UInt32, throughGeneration: UInt64) {} + func disable() {} + func drawableSurfaceDidChange() {} + + func presentCursor(_ update: VirtioGPUCursorUpdate?) { + guestCursorUpdate = update + rebuildGuestCursor() + } + + private func rebuildGuestCursor(pixelSize: CGSize? = nil) { + guard let update = guestCursorUpdate else { + guestCursor = Self.transparentCursor + window?.invalidateCursorRects(for: self) + return + } + let effectivePixelSize = pixelSize + ?? (scanoutSize.width > 0 ? scanoutSize : CGSize( + width: bounds.width * guestBackingScaleFactor, + height: bounds.height * guestBackingScaleFactor + )) + let cursorScale = bounds.width > 0 + ? max(1, effectivePixelSize.width / bounds.width) + : CGFloat(1) + guestCursor = Self.makeCursor(update, scale: cursorScale) ?? Self.transparentCursor + window?.invalidateCursorRects(for: self) + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: guestCursor) + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + drawableSurfaceDidChange() + let guestPixelSize = CGSize( + width: max(1, bounds.width * guestBackingScaleFactor), + height: max(1, bounds.height * guestBackingScaleFactor) + ) + if guestCursorUpdate != nil { rebuildGuestCursor(pixelSize: guestPixelSize) } + let width = UInt32(clamping: max(1, Int(guestPixelSize.width.rounded()))) + let height = UInt32(clamping: max(1, Int(guestPixelSize.height.rounded()))) + resizeGeneration &+= 1 + let generation = resizeGeneration + // AppKit reports every intermediate drag size. Debounce the guest modeset so Mutter/Xfce + // receives the final Retina pixel size without reallocating scanout resources per event. + DesktopAppRunLoop.perform(after: 0.12) { [weak self] in + guard let self, self.resizeGeneration == generation else { return } + self.onDrawableSizeChange?(width, height) + } + needsDisplay = true + } + + override func keyDown(with event: NSEvent) { + if onMacShortcut?(event) == true { return } + if event.modifierFlags.contains(.command), + let character = event.charactersIgnoringModifiers?.lowercased(), + let code = Self.macCommandShortcutMap[character] { + sendControlShortcut(keyCode: code) + return + } + guard let code = Self.linuxKeyCode(macKeyCode: event.keyCode) else { + super.keyDown(with: event) + return + } + let modifiers = keyboardModifierState.reconcile( + activeModifiers: Self.activeModifiers(event.modifierFlags) + ) + sendKeyboardTracked(modifiers + [ + VirtioInputEvent(type: 1, code: code, value: event.isARepeat ? 2 : 1) + ]) + } + + override func keyUp(with event: NSEvent) { + guard let code = Self.linuxKeyCode(macKeyCode: event.keyCode) else { + super.keyUp(with: event) + return + } + sendKeyboardTracked([VirtioInputEvent(type: 1, code: code, value: 0)]) + let modifiers = keyboardModifierState.reconcile( + activeModifiers: Self.activeModifiers(event.modifierFlags) + ) + if !modifiers.isEmpty { sendKeyboardTracked(modifiers) } + } + + override func flagsChanged(with event: NSEvent) { + guard let code = Self.linuxKeyCode(macKeyCode: event.keyCode), + let modifier = Self.modifier(macKeyCode: event.keyCode), + let flag = Self.modifierFlag(modifier) else { + super.flagsChanged(with: event) + return + } + let events = keyboardModifierState.update( + modifier: modifier, + linuxCode: code, + pressed: event.modifierFlags.contains(flag) + ) + if !events.isEmpty { sendKeyboardTracked(events) } + } + + override func mouseMoved(with event: NSEvent) { sendPointer(event: event) } + override func mouseDragged(with event: NSEvent) { sendPointer(event: event) } + override func rightMouseDragged(with event: NSEvent) { sendPointer(event: event) } + override func otherMouseDragged(with event: NSEvent) { sendPointer(event: event) } + override func mouseDown(with event: NSEvent) { sendMouseButton(event, code: 272, pressed: true) } + override func mouseUp(with event: NSEvent) { sendMouseButton(event, code: 272, pressed: false) } + override func rightMouseDown(with event: NSEvent) { sendMouseButton(event, code: 273, pressed: true) } + override func rightMouseUp(with event: NSEvent) { sendMouseButton(event, code: 273, pressed: false) } + override func otherMouseDown(with event: NSEvent) { + sendMouseButton(event, code: linuxOtherMouseButton(for: event.buttonNumber), pressed: true) + } + override func otherMouseUp(with event: NSEvent) { + sendMouseButton(event, code: linuxOtherMouseButton(for: event.buttonNumber), pressed: false) + } + + private func sendMouseButton(_ event: NSEvent, code: UInt16, pressed: Bool) { + sendPointer(event: event, button: code, pressed: pressed) + } + + private func linuxOtherMouseButton(for buttonNumber: Int) -> UInt16 { + switch buttonNumber { + case 2: 274 // BTN_MIDDLE + case 3: 275 // BTN_SIDE + default: 276 // BTN_EXTRA + } + } + + override func scrollWheel(with event: NSEvent) { + let events = scrollEventState.events( + horizontalDelta: event.scrollingDeltaX, + verticalDelta: event.scrollingDeltaY, + hasPreciseDeltas: event.hasPreciseScrollingDeltas + ) + if !events.isEmpty { sendPointerTracked(events) } + } + + private func sendPointer( + event: NSEvent, + button: UInt16? = nil, + pressed: Bool = false + ) { + window?.makeFirstResponder(self) + let point = convert(event.locationInWindow, from: nil) + let contentRect = scanoutContentRect(in: bounds.size) + let normalizedX = min(1, max(0, (point.x - contentRect.minX) / max(1, contentRect.width))) + let normalizedY = min(1, max(0, (point.y - contentRect.minY) / max(1, contentRect.height))) + let guestPoint = pointerTopology?.normalizedPoint( + scanoutID: scanoutID, + localX: normalizedX, + localY: normalizedY + ) ?? CGPoint(x: normalizedX, y: normalizedY) + var frame = [ + VirtioInputEvent(type: 3, code: 0, value: Int32((guestPoint.x * 32_767).rounded())), + VirtioInputEvent(type: 3, code: 1, value: Int32((guestPoint.y * 32_767).rounded())), + ] + if let button { + frame.append(VirtioInputEvent(type: 1, code: button, value: pressed ? 1 : 0)) + } + sendPointerTracked(frame) + } + + func releasePressedInput() { + let keyboardReleases = pressedKeyboardInput.releaseFrame() + let pointerReleases = pressedPointerInput.releaseFrame() + keyboardModifierState.reset() + scrollEventState.reset() + if !keyboardReleases.isEmpty { keyboardInput.send(frame: keyboardReleases) } + if !pointerReleases.isEmpty { pointerInput.send(frame: pointerReleases) } + } + + private func sendKeyboardTracked(_ events: [VirtioInputEvent]) { + for event in events { pressedKeyboardInput.record(event) } + keyboardInput.send(frame: events) + } + + private func sendPointerTracked(_ events: [VirtioInputEvent]) { + for event in events { pressedPointerInput.record(event) } + pointerInput.send(frame: events) + } + + func scanoutContentRect(in targetSize: CGSize) -> CGRect { + guard scanoutSize.width > 0, scanoutSize.height > 0, + targetSize.width > 0, targetSize.height > 0 else { + return CGRect(origin: .zero, size: targetSize) + } + let sourceAspect = scanoutSize.width / scanoutSize.height + let targetAspect = targetSize.width / targetSize.height + if targetAspect > sourceAspect { + let width = targetSize.height * sourceAspect + return CGRect( + x: (targetSize.width - width) / 2, + y: 0, + width: width, + height: targetSize.height + ) + } + let height = targetSize.width / sourceAspect + return CGRect( + x: 0, + y: (targetSize.height - height) / 2, + width: targetSize.width, + height: height + ) + } + + private static let transparentCursor: NSCursor = { + let image = NSImage( + size: NSSize(width: 1, height: 1), + flipped: false, + drawingHandler: { _ in true } + ) + return NSCursor(image: image, hotSpot: .zero) + }() + + private static func makeCursor( + _ update: VirtioGPUCursorUpdate, + scale: CGFloat + ) -> NSCursor? { + guard update.width > 0, update.height > 0, + update.width <= 256, update.height <= 256, + update.hotX < update.width, update.hotY < update.height, + update.bytes.count == Int(update.width * update.height * 4), + let provider = CGDataProvider(data: update.bytes as CFData), + let image = CGImage( + width: Int(update.width), + height: Int(update.height), + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: Int(update.width) * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo.byteOrder32Little.union(CGBitmapInfo( + rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue + )), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + ) else { + return nil + } + let cursorImage = NSImage( + cgImage: image, + size: NSSize( + width: CGFloat(update.width) / scale, + height: CGFloat(update.height) / scale + ) + ) + return NSCursor( + image: cursorImage, + hotSpot: NSPoint( + x: CGFloat(update.hotX) / scale, + y: CGFloat(update.hotY) / scale + ) + ) + } + + private static func modifier(macKeyCode: UInt16) -> DesktopKeyboardModifierState.Modifier? { + switch macKeyCode { + case 54, 55: .command + case 56, 60: .shift + case 57: .capsLock + case 58, 61: .option + case 59, 62: .control + default: nil + } + } + + private static func modifierFlag( + _ modifier: DesktopKeyboardModifierState.Modifier + ) -> NSEvent.ModifierFlags? { + switch modifier { + case .command: .command + case .shift: .shift + case .capsLock: .capsLock + case .option: .option + case .control: .control + } + } + + private static func activeModifiers( + _ flags: NSEvent.ModifierFlags + ) -> Set { + Set(DesktopKeyboardModifierState.Modifier.allCases.filter { + guard let flag = modifierFlag($0) else { return false } + return flags.contains(flag) + }) + } + + private static func linuxKeyCode(macKeyCode: UInt16) -> UInt16? { keyMap[macKeyCode] } + + private func sendControlShortcut(keyCode: UInt16) { + keyboardInput.send(frame: [ + VirtioInputEvent(type: 1, code: 125, value: 0), + VirtioInputEvent(type: 1, code: 126, value: 0), + VirtioInputEvent(type: 1, code: 29, value: 1), + VirtioInputEvent(type: 1, code: keyCode, value: 1), + VirtioInputEvent(type: 1, code: keyCode, value: 0), + VirtioInputEvent(type: 1, code: 29, value: 0), + ]) + } + + private static let macCommandShortcutMap: [String: UInt16] = [ + "a": 30, "f": 33, "l": 38, "n": 49, "o": 24, "p": 25, + "r": 19, "s": 31, "t": 20, "w": 17, "z": 44, + ] + + private static let keyMap: [UInt16: UInt16] = [ + 0: 30, 1: 31, 2: 32, 3: 33, 4: 35, 5: 34, 6: 44, 7: 45, + 8: 46, 9: 47, 11: 48, 12: 16, 13: 17, 14: 18, 15: 19, 16: 21, + 17: 20, 18: 2, 19: 3, 20: 4, 21: 5, 22: 7, 23: 6, 24: 13, + 25: 10, 26: 8, 27: 12, 28: 9, 29: 11, 30: 27, 31: 24, 32: 22, + 33: 26, 34: 23, 35: 25, 36: 28, 37: 38, 38: 36, 39: 40, 40: 37, + 41: 39, 42: 43, 43: 51, 44: 53, 45: 49, 46: 50, 47: 52, 48: 15, + 49: 57, 50: 41, 51: 14, 53: 1, 54: 126, 55: 125, 56: 42, 57: 58, + 58: 56, 59: 29, 60: 54, 61: 100, 62: 97, + 65: 83, 67: 55, 69: 78, 71: 69, 75: 98, 76: 96, 78: 74, 81: 117, + 82: 82, 83: 79, 84: 80, 85: 81, 86: 75, 87: 76, 88: 77, 89: 71, + 91: 72, 92: 73, + 96: 63, 97: 64, 98: 65, 99: 61, 100: 66, 101: 67, 103: 87, + 109: 68, 111: 88, 114: 110, 115: 102, 116: 104, 117: 111, 118: 62, + 119: 107, 120: 60, 121: 109, 122: 59, 123: 105, 124: 106, 125: 108, + 126: 103, + ] +} + +final class DesktopFrameMailbox: @unchecked Sendable { + private let lock = NSLock() + private let scanoutID: UInt32 + private let coalescer: DesktopScanoutFrameCoalescer + private var pendingMetalUpdate: VirtioGPUMetalScanoutUpdate? + /// Displaced authorities are retired synchronously by their producer callback rather than + /// accumulated while AppKit is stalled. Delivery will not acknowledge any release until these + /// bounded in-flight calls have returned. + private var discardOperationsInFlight = 0 + private var pendingReleases = [VirtioGPUScanoutResourceRelease]() + private var disabled = false + private var deliveryScheduled = false + private var presentedFrameCount: UInt64 = 0 + private var droppedFrameCount: UInt64 = 0 + private var budgetRejectedFrameCount: UInt64 = 0 + private var receivedFrameByteCount: UInt64 = 0 + private var drainCopyByteCount: UInt64 = 0 + private var uploadedFrameByteCount: UInt64 = 0 + private var droppedFrameByteCount: UInt64 = 0 + private var workerScanoutProgressStages = Set() + nonisolated(unsafe) weak var view: DesktopDisplayView? + + init( + scanoutID: UInt32 = 0, + maximumCPUSurfaceBytes: Int = 128 * 1_024 * 1_024, + maximumAggregateCPUSurfaceBytes: Int = 256 * 1_024 * 1_024, + maximumInFlightCPUFrameBytes: Int = 128 * 1_024 * 1_024, + sharedCPUPresentationBudget: DesktopCPUPresentationBudget = .processDefault + ) { + self.scanoutID = scanoutID + self.coalescer = DesktopScanoutFrameCoalescer( + maximumSurfaceBytes: maximumCPUSurfaceBytes, + maximumAggregateSurfaceBytes: maximumAggregateCPUSurfaceBytes, + maximumDrainBytes: maximumInFlightCPUFrameBytes, + sharedBudget: sharedCPUPresentationBudget + ) + } + + func submit(_ frame: VirtioGPUScanoutFrame) { + lock.lock() + receivedFrameByteCount = Self.saturatingAdd( + receivedFrameByteCount, + UInt64(frame.bytes.count) + ) + let outcome = frame.scanoutID == scanoutID + ? coalescer.appendOutcome(frame) + : .invalid + guard outcome == .accepted else { + droppedFrameCount = Self.saturatingAdd(droppedFrameCount, 1) + droppedFrameByteCount = Self.saturatingAdd( + droppedFrameByteCount, + UInt64(frame.bytes.count) + ) + if outcome == .budgetExceeded { + budgetRejectedFrameCount = Self.saturatingAdd(budgetRejectedFrameCount, 1) + } + lock.unlock() + return + } + disabled = false + let shouldSchedule = !deliveryScheduled + deliveryScheduled = true + lock.unlock() + guard shouldSchedule else { return } + DesktopAppRunLoop.perform { [weak self] in + self?.deliver() + } + } + + /// A worker update carries descriptor authority rather than frame bytes. Only one unpublished + /// authority may wait for AppKit per scanout; replacement retires the displaced lease before + /// any resource-release acknowledgement can overtake it. + func submit(_ update: VirtioGPUMetalScanoutUpdate) { + logWorkerScanoutProgress(stage: "mailbox-submit") + lock.lock() + guard update.scanoutID == scanoutID else { + lock.unlock() + update.rejectHostSubmission() + update.presentation.discardWithoutPresentation() + return + } + let displaced = pendingMetalUpdate + if displaced != nil { discardOperationsInFlight += 1 } + pendingMetalUpdate = update + disabled = false + let shouldSchedule = !deliveryScheduled + deliveryScheduled = true + lock.unlock() + if let displaced { retireDisplacedPresentation(displaced) } + if shouldSchedule { + logWorkerScanoutProgress(stage: "mailbox-delivery-scheduled") + scheduleDelivery() + } + } + + /// Drop presentation storage only when the guest destroys the corresponding virtio-gpu + /// resource. Direct scanout temporarily switches between application and compositor buffers; + /// evicting an older compositor buffer merely because it was not recently visible makes its + /// next partial update appear as a black or torn frame. + func release(_ release: VirtioGPUScanoutResourceRelease) { + lock.lock() + let removal = coalescer.removeOutcome( + resourceID: release.resourceID, + throughGeneration: release.resourceGeneration + ) + let displacedMetal: VirtioGPUMetalScanoutUpdate? + if let update = pendingMetalUpdate, + update.resourceID == release.resourceID, + update.resourceGeneration <= release.resourceGeneration { + pendingMetalUpdate = nil + displacedMetal = update + discardOperationsInFlight += 1 + } else { + displacedMetal = nil + } + pendingReleases.append(release) + droppedFrameCount = Self.saturatingAdd( + droppedFrameCount, + removal.frameCount + ) + droppedFrameByteCount = Self.saturatingAdd( + droppedFrameByteCount, + removal.payloadByteCount + ) + let shouldSchedule = !deliveryScheduled + deliveryScheduled = true + lock.unlock() + if let displacedMetal { retireDisplacedPresentation(displacedMetal) } + if shouldSchedule { scheduleDelivery() } + } + + func disable() { + lock.lock() + let discarded = coalescer.discardPendingOutcome() + let displacedMetal = pendingMetalUpdate + if displacedMetal != nil { discardOperationsInFlight += 1 } + pendingMetalUpdate = nil + disabled = true + droppedFrameCount = Self.saturatingAdd(droppedFrameCount, discarded.frameCount) + droppedFrameByteCount = Self.saturatingAdd( + droppedFrameByteCount, + discarded.payloadByteCount + ) + let shouldSchedule = !deliveryScheduled + deliveryScheduled = true + lock.unlock() + if let displacedMetal { retireDisplacedPresentation(displacedMetal) } + if shouldSchedule { scheduleDelivery() } + } + + @MainActor + func deliver() { + lock.lock() + guard discardOperationsInFlight == 0 else { + // The final retiring producer schedules another delivery. Returning keeps AppKit + // responsive and, critically, prevents a release acknowledgement from overtaking it. + deliveryScheduled = false + lock.unlock() + return + } + let pendingDrain = coalescer.takeDrain() + let metalUpdate = pendingMetalUpdate + let releases = pendingReleases + let shouldDisable = disabled + pendingMetalUpdate = nil + pendingReleases.removeAll(keepingCapacity: true) + deliveryScheduled = false + lock.unlock() + if metalUpdate != nil { + logWorkerScanoutProgress(stage: "mailbox-deliver") + } + // Sparse materialization can copy accumulated damage. Keep it off the producer lock so a + // vCPU never waits behind main-thread row packing; the single-update path performs no work. + let drain = pendingDrain.materialize() + for release in releases { + view?.release( + resourceID: release.resourceID, + throughGeneration: release.resourceGeneration + ) + release.acknowledge(scanoutID: scanoutID) + } + if shouldDisable { + view?.disable() + } + let frames = drain.frames + let framePresentation: DesktopCPUFramePresentationResult + if shouldDisable { + framePresentation = DesktopCPUFramePresentationResult( + presented: [Bool](repeating: false, count: frames.count), + uploadedByteCount: 0 + ) + } else { + framePresentation = view?.present(frames) ?? DesktopCPUFramePresentationResult( + presented: [Bool](repeating: false, count: frames.count), + uploadedByteCount: 0 + ) + } + var presentedInputs: UInt64 = 0 + var droppedInputs: UInt64 = 0 + var droppedInputBytes: UInt64 = 0 + for batch in drain.batches { + let complete = batch.frameRange.allSatisfy { + framePresentation.presented.indices.contains($0) + && framePresentation.presented[$0] + } + if complete { + presentedInputs = Self.saturatingAdd( + presentedInputs, + batch.inputFrameCount + ) + } else { + droppedInputs = Self.saturatingAdd(droppedInputs, batch.inputFrameCount) + droppedInputBytes = Self.saturatingAdd( + droppedInputBytes, + batch.inputPayloadByteCount + ) + } + } + let presentedMetal: Bool + if shouldDisable { + metalUpdate?.rejectHostSubmission() + metalUpdate?.presentation.discardWithoutPresentation() + presentedMetal = false + } else if let metalUpdate { + logWorkerScanoutProgress(stage: "view-present-enter") + presentedMetal = view?.present(metalUpdate) == true + logWorkerScanoutProgress( + stage: presentedMetal ? "view-present-accepted" : "view-present-rejected" + ) + if presentedMetal { + metalUpdate.acceptHostSubmission() + } else { + metalUpdate.rejectHostSubmission() + metalUpdate.presentation.discardWithoutPresentation() + } + } else { + presentedMetal = false + } + lock.withLock { + presentedFrameCount = Self.saturatingAdd( + presentedFrameCount, + presentedInputs + + (presentedMetal ? 1 : 0) + ) + droppedFrameCount = Self.saturatingAdd( + droppedFrameCount, + droppedInputs + ) + drainCopyByteCount = Self.saturatingAdd( + drainCopyByteCount, + UInt64(drain.copyByteCount) + ) + uploadedFrameByteCount = Self.saturatingAdd( + uploadedFrameByteCount, + framePresentation.uploadedByteCount + ) + droppedFrameByteCount = Self.saturatingAdd( + droppedFrameByteCount, + droppedInputBytes + ) + } + if drain.hasMorePendingFrames { + lock.lock() + let shouldSchedule = !deliveryScheduled + if shouldSchedule { deliveryScheduled = true } + lock.unlock() + if shouldSchedule { scheduleDelivery() } + } + } + + var metrics: DesktopFrameMailboxMetrics { + lock.withLock { + let coalescerMetrics = coalescer.metrics + return DesktopFrameMailboxMetrics( + presentedFrames: presentedFrameCount, + droppedFrames: droppedFrameCount, + budgetRejectedFrames: budgetRejectedFrameCount, + receivedFrameBytes: receivedFrameByteCount, + stagingCopyBytes: coalescerMetrics.stagingCopyBytes, + drainCopyBytes: drainCopyByteCount, + uploadedFrameBytes: uploadedFrameByteCount, + droppedFrameBytes: droppedFrameByteCount, + pendingFrameBytes: UInt64(max(0, coalescerMetrics.residentBytes)), + pendingFrameDepth: coalescerMetrics.pendingFrameDepth + ) + } + } + + private static func saturatingAdd(_ value: UInt64, _ increment: UInt64) -> UInt64 { + let (sum, overflow) = value.addingReportingOverflow(increment) + return overflow ? UInt64.max : sum + } + + private func retireDisplacedPresentation(_ update: VirtioGPUMetalScanoutUpdate) { + update.rejectHostSubmission() + update.presentation.discardWithoutPresentation() + finishDisplacedRetirement() + } + + private func finishDisplacedRetirement() { + lock.lock() + discardOperationsInFlight -= 1 + let shouldSchedule = discardOperationsInFlight == 0 && !deliveryScheduled + if shouldSchedule { deliveryScheduled = true } + lock.unlock() + if shouldSchedule { scheduleDelivery() } + } + + private func scheduleDelivery() { + DesktopAppRunLoop.perform { [weak self] in + self?.deliver() + } + } + + private func logWorkerScanoutProgress(stage: String) { + let firstOccurrence = lock.withLock { + workerScanoutProgressStages.insert(stage).inserted + } + guard firstOccurrence else { return } + FileHandle.standardError.write(Data( + "dory-hv: Metal worker scanout progress scanout=\(scanoutID) stage=\(stage)\n".utf8 + )) + } +} + +/// Moves copied cursor-plane updates from a vCPU thread to AppKit without retaining the guest +/// resource or touching NSCursor off the main thread. +final class DesktopCursorMailbox: @unchecked Sendable { + nonisolated(unsafe) weak var view: DesktopDisplayView? + + func submit(_ update: VirtioGPUCursorUpdate?) { + DesktopAppRunLoop.perform { [weak self] in + self?.view?.presentCursor(update) + } + } +} + +enum DesktopMetalScanoutLayoutError: Error, Equatable { + case identityMismatch + case invalidGeometry + case metalAlignmentMismatch + case mappedLengthOverflow +} + +/// Transport-independent identity and clipping validated before either a linear SHM texture or a +/// native shared Metal texture can enter the display. The renderer resource generation is kept +/// distinct from the VMM display generation: both must match the update that owns this lease. +struct DesktopMetalScanoutGeometry: Equatable { + let resourceID: UInt32 + let rendererResourceGeneration: UInt64 + let pixelFormat: MTLPixelFormat + let width: Int + let height: Int + let sourceRect: VirtioGPURect + let dirtyRect: VirtioGPURect + let yOriginTop: Bool + + init( + presentation: VirtioGPUMetalScanoutPresentation, + expectedScanoutID: UInt32, + updateScanoutID: UInt32, + updateResourceID: UInt32, + updateResourceGeneration: UInt64, + updateRendererResourceGeneration: UInt64, + sourceRect: VirtioGPURect, + dirtyRect: VirtioGPURect + ) throws { + guard updateScanoutID == expectedScanoutID, + updateResourceID == presentation.resourceID, + updateResourceGeneration != 0, + updateRendererResourceGeneration == presentation.resourceGeneration else { + throw DesktopMetalScanoutLayoutError.identityMismatch + } + guard DesktopMetalScanoutLayout.containsForCPU( + sourceRect, + width: presentation.width, + height: presentation.height + ), + DesktopMetalScanoutLayout.containsForCPU( + dirtyRect, + width: sourceRect.width, + height: sourceRect.height + ), + let width = Int(exactly: presentation.width), + let height = Int(exactly: presentation.height) else { + throw DesktopMetalScanoutLayoutError.invalidGeometry + } + let pixelFormat: MTLPixelFormat = switch presentation.pixelFormat { + case .bgra8Unorm: .bgra8Unorm + case .rgba8Unorm: .rgba8Unorm + } + self.resourceID = presentation.resourceID + self.rendererResourceGeneration = presentation.resourceGeneration + self.pixelFormat = pixelFormat + self.width = width + self.height = height + self.sourceRect = sourceRect + self.dirtyRect = dirtyRect + self.yOriginTop = presentation.yOriginTop + } +} + +/// Revalidates the authenticated worker lease against the concrete host Metal device. The worker +/// contract deliberately permits alignments used by more than one Metal family; publication is +/// accepted only when this device can reconstruct the exact linear texture without a copy. +struct DesktopMetalScanoutLayout: Equatable { + let pixelFormat: MTLPixelFormat + let width: Int + let height: Int + let stride: Int + let storageOffset: Int + let declaredFileSize: Int + let mappedLength: Int + let sourceRect: VirtioGPURect + let dirtyRect: VirtioGPURect + let yOriginTop: Bool + + init( + lease: DoryRendererScanoutLease, + geometry: DesktopMetalScanoutGeometry, + minimumLinearTextureAlignment: Int, + pageSize: Int, + maximumBufferLength: Int + ) throws { + guard geometry.resourceID == lease.resourceID, + geometry.rendererResourceGeneration == lease.resourceGeneration, + geometry.width == Int(lease.width), + geometry.height == Int(lease.height), + geometry.yOriginTop == lease.yOriginTop, + geometry.pixelFormat == Self.metalPixelFormat(lease.pixelFormat) else { + throw DesktopMetalScanoutLayoutError.identityMismatch + } + guard minimumLinearTextureAlignment > 0, + let metalAlignment = UInt32(exactly: minimumLinearTextureAlignment), + pageSize > 0, + pageSize.nonzeroBitCount == 1, + lease.stride % metalAlignment == 0, + lease.storageOffset % UInt64(minimumLinearTextureAlignment) == 0 else { + throw DesktopMetalScanoutLayoutError.invalidGeometry + } + guard Int(exactly: lease.width) != nil, + Int(exactly: lease.height) != nil, + let stride = Int(exactly: lease.stride), + let storageOffset = Int(exactly: lease.storageOffset), + let declaredFileSize = Int(exactly: lease.declaredFileSize), + declaredFileSize > 0 else { + throw DesktopMetalScanoutLayoutError.mappedLengthOverflow + } + let remainder = declaredFileSize & (pageSize - 1) + let padding = remainder == 0 ? 0 : pageSize - remainder + let (mappedLength, overflow) = declaredFileSize.addingReportingOverflow(padding) + guard !overflow, mappedLength > 0, mappedLength <= maximumBufferLength else { + throw DesktopMetalScanoutLayoutError.mappedLengthOverflow + } + self.pixelFormat = geometry.pixelFormat + self.width = geometry.width + self.height = geometry.height + self.stride = stride + self.storageOffset = storageOffset + self.declaredFileSize = declaredFileSize + self.mappedLength = mappedLength + self.sourceRect = geometry.sourceRect + self.dirtyRect = geometry.dirtyRect + self.yOriginTop = geometry.yOriginTop + } + + static func containsForCPU( + _ rect: VirtioGPURect, + width: UInt32, + height: UInt32 + ) -> Bool { + rect.width > 0 + && rect.height > 0 + && rect.x <= width + && rect.width <= width - rect.x + && rect.y <= height + && rect.height <= height - rect.y + } + + private static func metalPixelFormat( + _ pixelFormat: DoryRendererScanoutPixelFormat + ) -> MTLPixelFormat { + switch pixelFormat { + case .bgra8Unorm: .bgra8Unorm + case .rgba8Unorm: .rgba8Unorm + } + } +} + +private final class DesktopMetalWorkerLeaseRetirement: @unchecked Sendable { + private let lock = NSLock() + private let presentation: VirtioGPUMetalScanoutPresentation + private var presented = false + private var retired = false + + init(presentation: VirtioGPUMetalScanoutPresentation) { + self.presentation = presentation + } + + func markPresented() { + lock.withLock { + guard !retired else { return } + presented = true + } + } + + /// Metal calls this only after its last buffer/texture reference has gone away. Unmapping + /// first makes the worker release transition exactly follow GPU completion and local resource + /// destruction, rather than merely following command submission. + func releaseMapping(_ pointer: UnsafeMutableRawPointer, length: Int) { + let outcome = lock.withLock { () -> Bool? in + guard !retired else { return nil } + retired = true + return presented + } + guard let outcome else { return } + _ = munmap(pointer, length) + if outcome { + presentation.finishPresentation() + } else { + presentation.discardWithoutPresentation() + } + } + + /// A native shared texture has no CPU mapping to tear down. Its wrapper calls this only after + /// the command buffer releases its final local texture reference. + func releaseImportedTexture() { + let outcome = lock.withLock { () -> Bool? in + guard !retired else { return nil } + retired = true + return presented + } + guard let outcome else { return } + if outcome { + presentation.finishPresentation() + } else { + presentation.discardWithoutPresentation() + } + } +} + +private final class DesktopMetalWorkerScanout: @unchecked Sendable { + private var retainedTexture: (any MTLTexture)? + var texture: any MTLTexture { + precondition(retainedTexture != nil, "retired Metal worker scanout texture") + return retainedTexture! + } + private let buffer: (any MTLBuffer)? + private let retirement: DesktopMetalWorkerLeaseRetirement + private let retiresImportedTextureOnDeinit: Bool + + init( + texture: any MTLTexture, + buffer: (any MTLBuffer)?, + retirement: DesktopMetalWorkerLeaseRetirement, + retiresImportedTextureOnDeinit: Bool + ) { + self.retainedTexture = texture + self.buffer = buffer + self.retirement = retirement + self.retiresImportedTextureOnDeinit = retiresImportedTextureOnDeinit + } + + func markPresented() { + retirement.markPresented() + if retiresImportedTextureOnDeinit { + retainedTexture = nil + retirement.releaseImportedTexture() + } + } + + deinit { + if retiresImportedTextureOnDeinit { + retainedTexture = nil + retirement.releaseImportedTexture() + } + } +} + +enum DesktopMetalDisplayError: Error, CustomStringConvertible { + case deviceUnavailable + case commandQueueUnavailable + case shaderCompilationFailed(String) + case renderPipelineUnavailable(String) + case samplerUnavailable + + var description: String { + switch self { + case .deviceUnavailable: + "no Metal device is available for the desktop display" + case .commandQueueUnavailable: + "could not create the desktop Metal command queue" + case .shaderCompilationFailed(let detail): + "could not compile the desktop Metal display shader: \(detail)" + case .renderPipelineUnavailable(let detail): + "could not create the desktop Metal render pipeline: \(detail)" + case .samplerUnavailable: + "could not create the desktop Metal sampler" + } + } +} + +/// The production display boundary for both damage-proportional CPU uploads and descriptor-backed +/// worker scanout. Worker pixels remain in their SHM mapping and are sampled directly by Metal; +/// no `Data`, IOSurface, or intermediate frame allocation exists on that path. +@MainActor +final class DesktopMetalView: DesktopDisplayView { + private struct CPUTexture { + let texture: any MTLTexture + let identity: DesktopScanoutResourceIdentity + let format: UInt32 + let width: UInt32 + let height: UInt32 + } + + private let device: any MTLDevice + private let commandQueue: any MTLCommandQueue + private let pipeline: any MTLRenderPipelineState + private let sampler: any MTLSamplerState + private var cpuTextures: [UInt32: CPUTexture] = [:] + private var currentCPUTexture: CPUTexture? + private var resourceLifetime = DesktopScanoutResourceLifetime() + private var deviceFailed = false + private var workerScanoutDiagnosticStages = Set() + var onDeviceFailure: (@Sendable (String) -> Void)? + /// Fires only from a completed Metal command buffer for a worker-issued presentation. The + /// worker update itself is published only after its producer fence signals. + var onWorkerPresentationCompleted: (@Sendable (UInt64) -> Void)? + + override func makeBackingLayer() -> CALayer { + CAMetalLayer() + } + + init( + frame: NSRect, + keyboardInput: VirtioInput, + pointerInput: VirtioInput, + guestBackingScaleFactor: CGFloat = 2, + scanoutID: UInt32 = 0, + pointerTopology: DesktopPointerTopology? = nil, + device requestedDevice: (any MTLDevice)? = nil + ) throws { + guard let device = requestedDevice ?? MTLCreateSystemDefaultDevice() else { + throw DesktopMetalDisplayError.deviceUnavailable + } + guard let commandQueue = device.makeCommandQueue() else { + throw DesktopMetalDisplayError.commandQueueUnavailable + } + let library: any MTLLibrary + do { + library = try device.makeLibrary(source: Self.shaderSource, options: nil) + } catch { + throw DesktopMetalDisplayError.shaderCompilationFailed(error.localizedDescription) + } + guard let vertex = library.makeFunction(name: "doryDesktopVertex"), + let fragment = library.makeFunction(name: "doryDesktopFragment") else { + throw DesktopMetalDisplayError.shaderCompilationFailed("required functions missing") + } + let pipelineDescriptor = MTLRenderPipelineDescriptor() + pipelineDescriptor.label = "Dory desktop scanout" + pipelineDescriptor.vertexFunction = vertex + pipelineDescriptor.fragmentFunction = fragment + pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm + let pipeline: any MTLRenderPipelineState + do { + pipeline = try device.makeRenderPipelineState(descriptor: pipelineDescriptor) + } catch { + throw DesktopMetalDisplayError.renderPipelineUnavailable(error.localizedDescription) + } + let samplerDescriptor = MTLSamplerDescriptor() + samplerDescriptor.minFilter = .linear + samplerDescriptor.magFilter = .linear + samplerDescriptor.sAddressMode = .clampToEdge + samplerDescriptor.tAddressMode = .clampToEdge + guard let sampler = device.makeSamplerState(descriptor: samplerDescriptor) else { + throw DesktopMetalDisplayError.samplerUnavailable + } + self.device = device + self.commandQueue = commandQueue + self.pipeline = pipeline + self.sampler = sampler + super.init( + frame: frame, + keyboardInput: keyboardInput, + pointerInput: pointerInput, + guestBackingScaleFactor: guestBackingScaleFactor, + scanoutID: scanoutID, + pointerTopology: pointerTopology + ) + wantsLayer = true + guard let metalLayer = layer as? CAMetalLayer else { + throw DesktopMetalDisplayError.deviceUnavailable + } + metalLayer.device = device + metalLayer.pixelFormat = .bgra8Unorm + metalLayer.framebufferOnly = true + metalLayer.maximumDrawableCount = 3 + metalLayer.allowsNextDrawableTimeout = true + metalLayer.presentsWithTransaction = false + drawableSurfaceDidChange() + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func drawableSurfaceDidChange() { + guard let metalLayer = layer as? CAMetalLayer else { return } + let size = convertToBacking(bounds).size + guard size.width > 0, size.height > 0 else { return } + metalLayer.drawableSize = CGSize( + width: max(1, size.width.rounded()), + height: max(1, size.height.rounded()) + ) + metalLayer.contentsScale = window?.backingScaleFactor + ?? NSScreen.main?.backingScaleFactor + ?? 1 + } + + override func present( + _ frames: [VirtioGPUScanoutFrame] + ) -> DesktopCPUFramePresentationResult { + guard !deviceFailed else { + return DesktopCPUFramePresentationResult( + presented: [Bool](repeating: false, count: frames.count), + uploadedByteCount: 0 + ) + } + var presented = [Bool]() + presented.reserveCapacity(frames.count) + var uploadedBytes: UInt64 = 0 + for frame in frames { + let accepted = upload(frame) + presented.append(accepted) + if accepted { + uploadedBytes = Self.saturatingAdd( + uploadedBytes, + UInt64(frame.dirtyRect.width) * UInt64(frame.dirtyRect.height) * 4 + ) + } + } + if presented.contains(true), let currentCPUTexture { + let committed = render( + texture: currentCPUTexture.texture, + sourceRect: VirtioGPURect( + x: 0, + y: 0, + width: currentCPUTexture.width, + height: currentCPUTexture.height + ), + backingWidth: currentCPUTexture.width, + backingHeight: currentCPUTexture.height, + yOriginTop: true, + workerScanout: nil + ) + if !committed { + presented = [Bool](repeating: false, count: presented.count) + } + } + return DesktopCPUFramePresentationResult( + presented: presented, + uploadedByteCount: uploadedBytes + ) + } + + override func present(_ update: VirtioGPUMetalScanoutUpdate) -> Bool { + logWorkerScanoutProgress(stage: "view-present") + guard !deviceFailed else { + logWorkerScanoutRejection(stage: "device-failed") + return false + } + let geometry: DesktopMetalScanoutGeometry + do { + geometry = try DesktopMetalScanoutGeometry( + presentation: update.presentation, + expectedScanoutID: scanoutID, + updateScanoutID: update.scanoutID, + updateResourceID: update.resourceID, + updateResourceGeneration: update.resourceGeneration, + updateRendererResourceGeneration: update.rendererResourceGeneration, + sourceRect: update.sourceRect, + dirtyRect: update.dirtyRect + ) + } catch { + logWorkerScanoutRejection( + stage: "layout", + detail: String(describing: error) + ) + return false + } + logWorkerScanoutProgress(stage: "layout-validated") + let identity = DesktopScanoutResourceIdentity(metalUpdate: update) + guard resourceLifetime.accepts(identity) else { + logWorkerScanoutRejection(stage: "resource-lifetime-admission") + return false + } + let workerScanout: DesktopMetalWorkerScanout + do { + workerScanout = try importScanout(update: update, geometry: geometry) + } catch { + logWorkerScanoutRejection( + stage: "worker-metal-import", + detail: String(describing: error) + ) + return false + } + logWorkerScanoutProgress(stage: "worker-metal-imported") + guard resourceLifetime.bind(identity) else { + logWorkerScanoutRejection(stage: "resource-lifetime-bind") + return false + } + scanoutSize = CGSize( + width: Int(update.sourceRect.width), + height: Int(update.sourceRect.height) + ) + guard render( + texture: workerScanout.texture, + sourceRect: update.sourceRect, + backingWidth: update.presentation.width, + backingHeight: update.presentation.height, + yOriginTop: geometry.yOriginTop, + workerScanout: workerScanout, + completion: { [onWorkerPresentationCompleted] completed in + guard completed else { return } + onWorkerPresentationCompleted?( + update.presentation.workerGeneration.rawValue + ) + } + ) else { + logWorkerScanoutRejection(stage: "render-submission") + resourceLifetime.unbind() + return false + } + logWorkerScanoutProgress(stage: "metal-command-buffer-committed") + currentCPUTexture = nil + return true + } + + override func release(resourceID: UInt32, throughGeneration: UInt64) { + if let texture = cpuTextures[resourceID], + texture.identity.generation <= throughGeneration { + cpuTextures.removeValue(forKey: resourceID) + } + if currentCPUTexture?.identity.resourceID == resourceID, + let currentCPUTexture, + currentCPUTexture.identity.generation <= throughGeneration { + self.currentCPUTexture = nil + } + if resourceLifetime.release( + resourceID: resourceID, + throughGeneration: throughGeneration + ) { + currentCPUTexture = nil + } + } + + override func disable() { + resourceLifetime.unbind() + currentCPUTexture = nil + _ = render( + texture: nil, + sourceRect: VirtioGPURect(x: 0, y: 0, width: 1, height: 1), + backingWidth: 1, + backingHeight: 1, + yOriginTop: true, + workerScanout: nil + ) + } + + override func draw(_ dirtyRect: NSRect) { + guard let currentCPUTexture else { return } + _ = render( + texture: currentCPUTexture.texture, + sourceRect: VirtioGPURect( + x: 0, + y: 0, + width: currentCPUTexture.width, + height: currentCPUTexture.height + ), + backingWidth: currentCPUTexture.width, + backingHeight: currentCPUTexture.height, + yOriginTop: true, + workerScanout: nil + ) + } + + private func upload(_ frame: VirtioGPUScanoutFrame) -> Bool { + let identity = DesktopScanoutResourceIdentity(frame: frame) + guard let pixelFormat = Self.pixelFormat(for: frame.format), + frame.scanoutID == scanoutID, + frame.width > 0, frame.height > 0, + DesktopMetalScanoutLayout.containsForCPU( + frame.dirtyRect, + width: frame.width, + height: frame.height + ), + UInt64(frame.stride) >= UInt64(frame.dirtyRect.width) * 4, + UInt64(frame.bytes.count) + >= UInt64(frame.stride) * UInt64(frame.dirtyRect.height), + resourceLifetime.accepts(identity) else { + return false + } + var texture = cpuTextures[frame.resourceID] + if texture?.identity != identity + || texture?.width != frame.width + || texture?.height != frame.height + || texture?.format != frame.format { + let descriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: pixelFormat, + width: Int(frame.width), + height: Int(frame.height), + mipmapped: false + ) + descriptor.usage = [.shaderRead] + descriptor.storageMode = .shared + guard let created = device.makeTexture(descriptor: descriptor) else { return false } + texture = CPUTexture( + texture: created, + identity: identity, + format: frame.format, + width: frame.width, + height: frame.height + ) + cpuTextures[frame.resourceID] = texture + } + guard let texture else { return false } + let region = MTLRegionMake2D( + Int(frame.dirtyRect.x), + Int(frame.dirtyRect.y), + Int(frame.dirtyRect.width), + Int(frame.dirtyRect.height) + ) + frame.bytes.withUnsafeBytes { bytes in + guard let baseAddress = bytes.baseAddress else { return } + texture.texture.replace( + region: region, + mipmapLevel: 0, + withBytes: baseAddress, + bytesPerRow: Int(frame.stride) + ) + } + guard resourceLifetime.bind(identity) else { return false } + currentCPUTexture = texture + scanoutSize = CGSize(width: Int(frame.width), height: Int(frame.height)) + return true + } + + private func importScanout( + update: VirtioGPUMetalScanoutUpdate, + geometry: DesktopMetalScanoutGeometry + ) throws -> DesktopMetalWorkerScanout { + switch update.presentation.transport { + case .sharedMemory: + return try update.presentation.withSharedMemoryScanout { lease, descriptor in + let layout = try DesktopMetalScanoutLayout( + lease: lease, + geometry: geometry, + minimumLinearTextureAlignment: device.minimumLinearTextureAlignment( + for: geometry.pixelFormat + ), + pageSize: Int(getpagesize()), + maximumBufferLength: device.maxBufferLength + ) + var status = stat() + guard fstat(descriptor, &status) == 0, + status.st_size >= 0, + UInt64(status.st_size) == lease.declaredFileSize, + (status.st_mode & S_IFMT) == S_IFREG + || (status.st_mode & S_IFMT) == 0 else { + throw DesktopMetalScanoutLayoutError.invalidGeometry + } + guard let mapping = mmap( + nil, + layout.mappedLength, + PROT_READ, + MAP_SHARED, + descriptor, + 0 + ), mapping != MAP_FAILED else { + throw DesktopMetalScanoutLayoutError.invalidGeometry + } + let retirement = DesktopMetalWorkerLeaseRetirement( + presentation: update.presentation + ) + guard let buffer = device.makeBuffer( + bytesNoCopy: mapping, + length: layout.mappedLength, + options: [.storageModeShared, .hazardTrackingModeTracked], + deallocator: { pointer, length in + retirement.releaseMapping(pointer, length: length) + } + ) else { + retirement.releaseMapping(mapping, length: layout.mappedLength) + throw DesktopMetalScanoutLayoutError.invalidGeometry + } + let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: layout.pixelFormat, + width: layout.width, + height: layout.height, + mipmapped: false + ) + textureDescriptor.usage = [.shaderRead] + textureDescriptor.storageMode = .shared + guard let texture = buffer.makeTexture( + descriptor: textureDescriptor, + offset: layout.storageOffset, + bytesPerRow: layout.stride + ) else { + throw DesktopMetalScanoutLayoutError.metalAlignmentMismatch + } + return DesktopMetalWorkerScanout( + texture: texture, + buffer: buffer, + retirement: retirement, + retiresImportedTextureOnDeinit: false + ) + } + case .sharedTexture: + return try update.presentation.withSharedTextureHandle { handle in + guard let texture = device.makeSharedTexture(handle: handle), + texture.device === device, + texture.textureType == .type2D, + texture.pixelFormat == geometry.pixelFormat, + texture.width == geometry.width, + texture.height == geometry.height, + texture.depth == 1, + texture.arrayLength == 1, + texture.mipmapLevelCount == 1, + texture.sampleCount == 1, + texture.storageMode == .private, + texture.usage.contains(.shaderRead) else { + throw DesktopMetalScanoutLayoutError.invalidGeometry + } + return DesktopMetalWorkerScanout( + texture: texture, + buffer: nil, + retirement: DesktopMetalWorkerLeaseRetirement( + presentation: update.presentation + ), + retiresImportedTextureOnDeinit: true + ) + } + } + } + + private func render( + texture: (any MTLTexture)?, + sourceRect: VirtioGPURect, + backingWidth: UInt32, + backingHeight: UInt32, + yOriginTop: Bool, + workerScanout: DesktopMetalWorkerScanout?, + completion: (@Sendable (Bool) -> Void)? = nil + ) -> Bool { + guard !deviceFailed else { + if workerScanout != nil { + logWorkerScanoutRejection(stage: "render-device-failed") + } + return false + } + guard let metalLayer = layer as? CAMetalLayer else { + if workerScanout != nil { + logWorkerScanoutRejection(stage: "render-layer-unavailable") + } + return false + } + guard metalLayer.device === device else { + if workerScanout != nil { + logWorkerScanoutRejection(stage: "render-device-mismatch") + } + return false + } + guard let drawable = metalLayer.nextDrawable() else { + if workerScanout != nil { + let drawableSize = metalLayer.drawableSize + logWorkerScanoutRejection( + stage: "render-drawable-unavailable", + detail: "window=\(window != nil) visible=\(window?.isVisible ?? false) " + + "bounds=\(Int(bounds.width))x\(Int(bounds.height)) " + + "drawable=\(Int(drawableSize.width))x\(Int(drawableSize.height))" + ) + } + return false + } + guard let commandBuffer = commandQueue.makeCommandBuffer() else { + failDevice("Metal command buffer allocation failed") + return false + } + commandBuffer.label = "Dory desktop present" + let pass = MTLRenderPassDescriptor() + pass.colorAttachments[0].texture = drawable.texture + pass.colorAttachments[0].loadAction = .clear + pass.colorAttachments[0].storeAction = .store + pass.colorAttachments[0].clearColor = MTLClearColor( + red: 0.025, + green: 0.03, + blue: 0.04, + alpha: 1 + ) + guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else { + failDevice("Metal render encoder allocation failed") + return false + } + if let texture { + let target = scanoutContentRect(in: metalLayer.drawableSize) + encoder.setViewport(MTLViewport( + originX: target.minX, + originY: target.minY, + width: target.width, + height: target.height, + znear: 0, + zfar: 1 + )) + var sourceUV = DesktopScanoutTextureCoordinates.sourceUV( + sourceRect: sourceRect, + backingWidth: backingWidth, + backingHeight: backingHeight, + yOriginTop: yOriginTop + ) + encoder.setRenderPipelineState(pipeline) + encoder.setVertexBytes(&sourceUV, length: MemoryLayout>.stride, index: 0) + encoder.setFragmentTexture(texture, index: 0) + encoder.setFragmentSamplerState(sampler, index: 0) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + } + encoder.endEncoding() + let failureSink = onDeviceFailure + commandBuffer.addCompletedHandler { buffer in + if buffer.status == .completed { + workerScanout?.markPresented() + completion?(true) + } else if let failureSink { + completion?(false) + let reason = buffer.error?.localizedDescription + ?? "Metal presentation ended with status \(buffer.status.rawValue)" + failureSink(reason) + } else { + completion?(false) + } + } + commandBuffer.present(drawable) + commandBuffer.commit() + return true + } + + private func logWorkerScanoutRejection(stage: String, detail: String = "") { + let key = "failure:\(stage)" + guard workerScanoutDiagnosticStages.insert(key).inserted else { return } + let suffix = detail.isEmpty ? "" : " detail=\(detail)" + FileHandle.standardError.write(Data( + "dory-hv: Metal worker scanout rejected stage=\(stage)\(suffix)\n".utf8 + )) + } + + private func logWorkerScanoutProgress(stage: String) { + let key = "progress:\(stage)" + guard workerScanoutDiagnosticStages.insert(key).inserted else { return } + FileHandle.standardError.write(Data( + "dory-hv: Metal worker scanout progress scanout=\(scanoutID) stage=\(stage)\n".utf8 + )) + } + + private func failDevice(_ reason: String) { + guard !deviceFailed else { return } + deviceFailed = true + resourceLifetime.unbind() + currentCPUTexture = nil + onDeviceFailure?(reason) + } + + private static func pixelFormat(for virtioFormat: UInt32) -> MTLPixelFormat? { + switch virtioFormat { + case 1, 2: .bgra8Unorm + case 3, 4, 67, 68, 121, 134: .rgba8Unorm + default: nil + } + } + + private static func saturatingAdd(_ value: UInt64, _ increment: UInt64) -> UInt64 { + let (sum, overflow) = value.addingReportingOverflow(increment) + return overflow ? UInt64.max : sum + } + + private static let shaderSource = """ + #include + using namespace metal; + + struct DoryDesktopVertexOutput { + float4 position [[position]]; + float2 textureCoordinate; + }; + + vertex DoryDesktopVertexOutput doryDesktopVertex( + uint vertexID [[vertex_id]], + constant float4 &sourceUV [[buffer(0)]]) { + const float2 positions[6] = { + float2(-1.0, -1.0), float2( 1.0, -1.0), float2(-1.0, 1.0), + float2(-1.0, 1.0), float2( 1.0, -1.0), float2( 1.0, 1.0) + }; + const float2 unitCoordinates[6] = { + float2(0.0, 0.0), float2(1.0, 0.0), float2(0.0, 1.0), + float2(0.0, 1.0), float2(1.0, 0.0), float2(1.0, 1.0) + }; + DoryDesktopVertexOutput output; + output.position = float4(positions[vertexID], 0.0, 1.0); + float2 unit = unitCoordinates[vertexID]; + output.textureCoordinate = float2( + mix(sourceUV.x, sourceUV.z, unit.x), + mix(sourceUV.y, sourceUV.w, unit.y)); + return output; + } + + fragment half4 doryDesktopFragment( + DoryDesktopVertexOutput input [[stage_in]], + texture2d source [[texture(0)]], + sampler sourceSampler [[sampler(0)]]) { + return source.sample(sourceSampler, input.textureCoordinate); + } + """ +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift new file mode 100644 index 00000000..e607b446 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopMode.swift @@ -0,0 +1,3196 @@ +import AppKit +import Darwin +import DoryCore +import DoryFSWorkerContracts +import DoryHV +import DoryOperations +import DoryVMContracts +import DorydKit +import DoryVMMKit +import Foundation + +private final class DoryDesktopCameraAttachment: @unchecked Sendable { + enum Result: Sendable, Equatable { + case attached + case unavailable(String) + + var detailSuffix: String { + switch self { + case .attached: + return "; Dory UVC Camera attached" + case .unavailable(let detail): + return "; camera unavailable: \(detail)" + } + } + } + + private let backend: DoryMacCameraBackend + private let handler: UsbControlHandler + private let log: @Sendable (String) -> Void + + init( + backend: DoryMacCameraBackend, + handler: UsbControlHandler, + log: @escaping @Sendable (String) -> Void + ) { + self.backend = backend + self.handler = handler + self.log = log + } + + /// Camera access is an optional host capability. A denied TCC grant, disconnected device, or + /// failed UVC attach must be reported without tearing down an otherwise healthy desktop. + func attachIfAvailable() async -> Result { + log("dory-hv desktop: preparing Mac camera for Linux attachment") + do { + try backend.prepareAndAuthorize() + } catch { + backend.stop() + let detail = String(describing: error) + log("dory-hv desktop: camera unavailable: \(detail)") + return .unavailable(detail) + } + do { + let attachment = try await handler.attach(busID: DoryVirtualUVCCamera.busID) + log( + "dory-hv desktop: Dory UVC Camera attached on Linux VHCI port " + + "\(attachment.port)" + ) + return .attached + } catch { + backend.stop() + let detail = String(describing: error) + log("dory-hv desktop: camera unavailable: \(detail)") + return .unavailable(detail) + } + } + + func unavailableWithoutGuestTools() -> Result { + let detail = "Dory Tools usb-vhci@1 is not installed in this Linux guest" + backend.stop() + log("dory-hv desktop: camera unavailable: \(detail)") + return .unavailable(detail) + } +} + +final class RawDeviceTelemetryRegistry: @unchecked Sendable { + private struct Entry { + var id: String + var kind: DoryDeviceTelemetryKind + var transport: VirtioMMIOTransport + var storage: VirtioBlk? + var network: VirtioNet? + var sharedDirectory: VirtioFS? + var input: VirtioInput? + var audioMetrics: (@Sendable () -> DoryMacAudioRuntimeMetrics?)? + var displayMetrics: (@Sendable () -> DesktopFrameMailboxMetrics?)? + var presentationBudgetMetrics: + (@Sendable () -> DesktopCPUPresentationBudgetMetrics?)? + var graphicsMetrics: (@Sendable () -> VirtioGPUStatistics?)? + var unavailableMetrics: [(DoryDeviceTelemetryMetricKind, DoryDeviceTelemetryMetricUnit)] + var previousTransportStatistics: VirtioMMIOTransportStatistics? + var previousStorageStatistics: VirtioBlkStatistics? + var previousAudioDrops: UInt64? + var previousShareInvalidationFailures: UInt64? + var previousDisplayDrops: UInt64? + var previousGraphicsFenceRegistrationFailures: UInt64? + var previousGraphicsFenceTimeouts: UInt64? + var previousGraphicsDeviceLosses: UInt64? + var consecutiveUncompletedNotificationSamples: UInt8 + var queueStallReported: Bool + } + + private let machineID: String + private let operationID: String + private let lock = NSLock() + private var sampleSequence: UInt64 = 0 + private var eventSequence: UInt64 = 0 + private var eventHistory = [DoryDeviceTelemetryEvent]() + private var entries = [Entry]() + private var resolvedPortForwardHealthProvider: + (@Sendable () -> ResolvedPortForwardHealthSnapshot?)? + private var previousResolvedPortForwardHealth: Bool? + + private static let queueStallSampleThreshold: UInt8 = 3 + private static let maximumEventHistory = 256 + + init(machineID: String, operationID: UUID) { + self.machineID = machineID + self.operationID = DoryOperationIdentity.canonical(operationID) + } + + func registerResolvedPortForwardHealth( + _ provider: @escaping @Sendable () -> ResolvedPortForwardHealthSnapshot? + ) { + lock.withLock { resolvedPortForwardHealthProvider = provider } + } + + func register( + slot: Int, + backend: any VirtioDeviceBackend, + transport: VirtioMMIOTransport, + audioMetrics: (@Sendable () -> DoryMacAudioRuntimeMetrics?)? = nil, + displayMetrics: (@Sendable () -> DesktopFrameMailboxMetrics?)? = nil, + presentationBudgetMetrics: + (@Sendable () -> DesktopCPUPresentationBudgetMetrics?)? = nil, + graphicsMetrics: (@Sendable () -> VirtioGPUStatistics?)? = nil + ) { + let effectiveGraphicsMetrics: (@Sendable () -> VirtioGPUStatistics?)? + if let graphicsMetrics { + effectiveGraphicsMetrics = graphicsMetrics + } else if let graphics = backend as? VirtioGPU { + effectiveGraphicsMetrics = { [weak graphics] in graphics?.statistics } + } else { + effectiveGraphicsMetrics = nil + } + let kind: DoryDeviceTelemetryKind + var unavailable: [(DoryDeviceTelemetryMetricKind, DoryDeviceTelemetryMetricUnit)] + switch backend { + case is VirtioBlk: + kind = .storage + unavailable = [] + case is VirtioGPU: + kind = .graphics + unavailable = [] + if effectiveGraphicsMetrics == nil { + unavailable.append(contentsOf: [ + (.graphicsFences, .count), + (.graphicsDeviceLosses, .count), + ]) + } + if displayMetrics == nil { + unavailable.append(contentsOf: [ + (.displayFrames, .count), + (.displayDrops, .count), + (.displayBudgetRejectedFrames, .count), + ]) + } + if presentationBudgetMetrics == nil { + unavailable.append(contentsOf: [ + (.graphicsPresentationResidentBytes, .bytes), + (.graphicsPresentationPeakResidentBytes, .bytes), + (.graphicsPresentationRejectedReservations, .count), + ]) + } + case is VirtioSound: + kind = .audio + unavailable = audioMetrics == nil ? [(.audioDrops, .count)] : [] + case is VirtioFS: + kind = .sharedDirectory + unavailable = [] + case is VirtioNet, is VirtioDisconnectedNet: + kind = .network + unavailable = backend is VirtioNet ? [] : [ + (.transmittedFrames, .count), + (.transmittedBytes, .bytes), + (.transmitDrops, .count), + (.transmitMalformed, .count), + (.transmitOversized, .count), + (.transmitInvalidDescriptors, .count), + (.transmitBackpressure, .count), + (.receivedFrames, .count), + (.receivedBytes, .bytes), + (.receiveDeferred, .count), + (.receiveDrops, .count), + (.receiveTruncations, .count), + (.receiveMalformed, .count), + (.receiveInvalidDescriptors, .count), + (.receiveInsufficientCapacity, .count), + (.receiveBacklogDrops, .count), + (.receiveInactiveDrops, .count), + (.receiveSocketErrors, .count), + (.receiveActivationFailures, .count), + ] + case is VirtioBalloon: + kind = .balloon + unavailable = [] + case is VirtioRng: + kind = .entropy + unavailable = [] + case is VirtioInput: + kind = .input + unavailable = [] + case is VirtioVsock: + kind = .socket + unavailable = [] + default: + kind = .platform + unavailable = [] + } + let entry = Entry( + id: "virtio-\(kind.rawValue)-\(slot)", + kind: kind, + transport: transport, + storage: backend as? VirtioBlk, + network: backend as? VirtioNet, + sharedDirectory: backend as? VirtioFS, + input: backend as? VirtioInput, + audioMetrics: audioMetrics, + displayMetrics: displayMetrics, + presentationBudgetMetrics: presentationBudgetMetrics, + graphicsMetrics: effectiveGraphicsMetrics, + unavailableMetrics: unavailable, + previousTransportStatistics: nil, + previousStorageStatistics: nil, + previousAudioDrops: nil, + previousShareInvalidationFailures: nil, + previousDisplayDrops: nil, + previousGraphicsFenceRegistrationFailures: nil, + previousGraphicsFenceTimeouts: nil, + previousGraphicsDeviceLosses: nil, + consecutiveUncompletedNotificationSamples: 0, + queueStallReported: false + ) + lock.withLock { entries.append(entry) } + } + + func snapshot() -> DoryDeviceTelemetrySnapshot { + lock.withLock { + sampleSequence &+= 1 + let sampledAtUnixMilliseconds = UInt64(Date().timeIntervalSince1970 * 1_000) + let monotonicNanoseconds = DispatchTime.now().uptimeNanoseconds + let unavailableReason = "raw-HV backend does not expose this counter yet" + var devices = [DoryDeviceTelemetryDevice]() + devices.reserveCapacity(entries.count + 1) + + for index in entries.indices { + let transport = entries[index].transport.statistics + var health = DoryDeviceTelemetryHealth.healthy + if let previous = entries[index].previousTransportStatistics { + let resetCount = Self.monotonicDelta( + current: transport.deviceResets, + previous: previous.deviceResets + ) + if resetCount > 0 { + appendEvent( + deviceID: entries[index].id, + kind: .reset, + occurrences: resetCount, + monotonicNanoseconds: monotonicNanoseconds + ) + health = .degraded + } + + let notificationsAdvanced = + transport.queueNotifications > previous.queueNotifications + let completionOrLifecycleAdvanced = + transport.usedInterrupts > previous.usedInterrupts + || transport.queueStateChanges > previous.queueStateChanges + || transport.deviceResets > previous.deviceResets + if completionOrLifecycleAdvanced { + entries[index].consecutiveUncompletedNotificationSamples = 0 + entries[index].queueStallReported = false + } else if notificationsAdvanced + || entries[index].consecutiveUncompletedNotificationSamples > 0 { + if entries[index].consecutiveUncompletedNotificationSamples < UInt8.max { + entries[index].consecutiveUncompletedNotificationSamples += 1 + } + } + if entries[index].consecutiveUncompletedNotificationSamples + >= Self.queueStallSampleThreshold { + health = .degraded + if !entries[index].queueStallReported { + appendEvent( + deviceID: entries[index].id, + kind: .queueStall, + occurrences: 1, + monotonicNanoseconds: monotonicNanoseconds + ) + entries[index].queueStallReported = true + } + } + } + entries[index].previousTransportStatistics = transport + + var metrics: [DoryDeviceTelemetryMetric] = [ + .measured(.queueNotifications, value: transport.queueNotifications), + .measured(.queueStateChanges, value: transport.queueStateChanges), + .measured(.usedInterrupts, value: transport.usedInterrupts), + .measured(.configurationInterrupts, value: transport.configurationInterrupts), + .measured(.deviceResets, value: transport.deviceResets), + ] + if let network = entries[index].network?.statistics { + metrics.append(contentsOf: Self.networkMetrics(network)) + } + if let storage = entries[index].storage?.statistics { + let (storageQueueFaults, storageQueueFaultOverflow) = + storage.queuePopFaults.addingReportingOverflow(storage.completionFaults) + metrics.append(contentsOf: [ + .measured(.storageFlushes, value: storage.flushes), + .measured( + .maximumStorageFlushLatencyNanoseconds, + value: storage.maximumFlushLatencyNanoseconds + ), + .measured(.storageInvalidRequests, value: storage.invalidRequests), + .measured( + .storageQueueFaults, + value: storageQueueFaultOverflow ? UInt64.max : storageQueueFaults + ), + .measured( + .storageBoundedDrainStops, + value: storage.boundedDrainStops + ), + ]) + let previousSlowFlushes = entries[index].previousStorageStatistics?.slowFlushes ?? 0 + let newSlowFlushes = Self.monotonicDelta( + current: storage.slowFlushes, + previous: previousSlowFlushes + ) + if newSlowFlushes > 0 { + appendEvent( + deviceID: entries[index].id, + kind: .storageFlushSlow, + occurrences: newSlowFlushes, + monotonicNanoseconds: monotonicNanoseconds + ) + health = .degraded + } + let previousQueueFaults: UInt64 + if let previous = entries[index].previousStorageStatistics { + let (sum, overflow) = previous.queuePopFaults.addingReportingOverflow( + previous.completionFaults + ) + previousQueueFaults = overflow ? UInt64.max : sum + } else { + previousQueueFaults = 0 + } + let currentQueueFaults = storageQueueFaultOverflow + ? UInt64.max : storageQueueFaults + let newQueueFaults = Self.monotonicDelta( + current: currentQueueFaults, + previous: previousQueueFaults + ) + if newQueueFaults > 0 { + appendEvent( + deviceID: entries[index].id, + kind: .storageQueueFault, + occurrences: newQueueFaults, + monotonicNanoseconds: monotonicNanoseconds + ) + health = .degraded + } + entries[index].previousStorageStatistics = storage + } + if let audio = entries[index].audioMetrics?() { + let (sum, overflow) = audio.droppedPlaybackPeriods.addingReportingOverflow( + audio.droppedCapturePeriods + ) + let drops = overflow ? UInt64.max : sum + metrics.append(.measured(.audioDrops, value: drops)) + let previousDrops = entries[index].previousAudioDrops ?? 0 + let newDrops = Self.monotonicDelta(current: drops, previous: previousDrops) + if newDrops > 0 { + appendEvent( + deviceID: entries[index].id, + kind: .audioDrop, + occurrences: newDrops, + monotonicNanoseconds: monotonicNanoseconds + ) + health = .degraded + } + entries[index].previousAudioDrops = drops + } + if let share = entries[index].sharedDirectory?.statistics { + let performance = entries[index].sharedDirectory?.performanceStatistics + metrics.append(contentsOf: [ + .measured(.shareInvalidations, value: share.invalidations), + .measured( + .shareInvalidationFailures, + value: share.invalidationFailures + ), + ]) + if let performance { + metrics.append(contentsOf: [ + .measured( + .shareRequestPayloadBytes, + value: performance.requestPayloadBytes + ), + .measured( + .shareWorkerResponsePayloadBytes, + value: performance.workerResponsePayloadBytes + ), + .measured( + .shareGuestPublishedResponseBytes, + value: performance.guestPublishedResponseBytes + ), + .measured( + .shareCompletedRequests, + value: performance.completedRequests + ), + .measured( + .shareFailedRequests, + value: performance.failedRequests + ), + .measured( + .shareInFlightRequests, + value: performance.inFlightRequests + ), + .measured( + .sharePeakInFlightRequests, + value: performance.peakInFlightRequests + ), + .measured( + .shareTotalRequestLatencyNanoseconds, + value: performance.totalRequestLatencyNanoseconds + ), + .measured( + .shareMaximumRequestLatencyNanoseconds, + value: performance.maximumRequestLatencyNanoseconds + ), + ]) + } + let previousFailures = + entries[index].previousShareInvalidationFailures ?? 0 + let newFailures = Self.monotonicDelta( + current: share.invalidationFailures, + previous: previousFailures + ) + if newFailures > 0 { + appendEvent( + deviceID: entries[index].id, + kind: .shareInvalidationFailure, + occurrences: newFailures, + monotonicNanoseconds: monotonicNanoseconds + ) + health = .degraded + } + if share.invalidationFailureLatched { + health = .failed + } + entries[index].previousShareInvalidationFailures = + share.invalidationFailures + } + if let input = entries[index].input?.statistics { + metrics.append(contentsOf: Self.inputMetrics(input)) + } + if let display = entries[index].displayMetrics?() { + metrics.append(contentsOf: [ + .measured(.displayFrames, value: display.presentedFrames), + .measured(.displayDrops, value: display.droppedFrames), + .measured( + .displayBudgetRejectedFrames, + value: display.budgetRejectedFrames + ), + .measured( + .displayReceivedFrameBytes, + value: display.receivedFrameBytes + ), + .measured( + .displayStagingCopyBytes, + value: display.stagingCopyBytes + ), + .measured( + .displayDrainCopyBytes, + value: display.drainCopyBytes + ), + .measured( + .displayUploadedFrameBytes, + value: display.uploadedFrameBytes + ), + .measured( + .displayDroppedFrameBytes, + value: display.droppedFrameBytes + ), + .measured( + .displayPendingFrameBytes, + value: display.pendingFrameBytes + ), + .measured( + .displayPendingFrameDepth, + value: display.pendingFrameDepth + ), + ]) + let previousDrops = entries[index].previousDisplayDrops ?? 0 + if Self.monotonicDelta( + current: display.droppedFrames, + previous: previousDrops + ) > 0 { + health = .degraded + } + entries[index].previousDisplayDrops = display.droppedFrames + } + if let budget = entries[index].presentationBudgetMetrics?() { + metrics.append(contentsOf: [ + .measured( + .graphicsPresentationResidentBytes, + value: UInt64(max(0, budget.residentBytes)) + ), + .measured( + .graphicsPresentationPeakResidentBytes, + value: UInt64(max(0, budget.peakResidentBytes)) + ), + .measured( + .graphicsPresentationRejectedReservations, + value: budget.rejectedReservations + ), + ]) + } + if let graphics = entries[index].graphicsMetrics?() { + metrics.append(contentsOf: [ + .measured(.graphicsFences, value: graphics.fences), + .measured( + .graphicsDeviceLosses, + value: graphics.rendererDeviceLosses + ), + ]) + let previousRegistrationFailures = + entries[index].previousGraphicsFenceRegistrationFailures ?? 0 + if Self.monotonicDelta( + current: graphics.fenceRegistrationFailures, + previous: previousRegistrationFailures + ) > 0 { + health = .degraded + } + let previousTimeouts = entries[index].previousGraphicsFenceTimeouts ?? 0 + let newTimeouts = Self.monotonicDelta( + current: graphics.fenceTimeouts, + previous: previousTimeouts + ) + if newTimeouts > 0 { + appendEvent( + deviceID: entries[index].id, + kind: .graphicsFenceTimeout, + occurrences: newTimeouts, + monotonicNanoseconds: monotonicNanoseconds + ) + } + if graphics.hasTimedOutPendingFence { + health = .failed + } + let previousDeviceLosses = + entries[index].previousGraphicsDeviceLosses ?? 0 + let newDeviceLosses = Self.monotonicDelta( + current: graphics.rendererDeviceLosses, + previous: previousDeviceLosses + ) + if newDeviceLosses > 0 { + appendEvent( + deviceID: entries[index].id, + kind: .graphicsDeviceLoss, + occurrences: newDeviceLosses, + monotonicNanoseconds: monotonicNanoseconds + ) + } + if graphics.hasLostRendererDevice { + health = .failed + } + entries[index].previousGraphicsFenceRegistrationFailures = + graphics.fenceRegistrationFailures + entries[index].previousGraphicsFenceTimeouts = graphics.fenceTimeouts + entries[index].previousGraphicsDeviceLosses = + graphics.rendererDeviceLosses + } + metrics.append(contentsOf: entries[index].unavailableMetrics.map { + .unavailable($0.0, unit: $0.1, reason: unavailableReason) + }) + devices.append(DoryDeviceTelemetryDevice( + id: entries[index].id, + kind: entries[index].kind, + health: health, + metrics: metrics + )) + } + + if let health = resolvedPortForwardHealthProvider?(), health.isValid { + if let previous = previousResolvedPortForwardHealth, + previous != health.healthy { + appendEvent( + deviceID: "resolved-port-forwards", + kind: health.healthy + ? .portForwardRecovered : .portForwardUnavailable, + occurrences: 1, + monotonicNanoseconds: monotonicNanoseconds + ) + } else if previousResolvedPortForwardHealth == nil, !health.healthy { + appendEvent( + deviceID: "resolved-port-forwards", + kind: .portForwardUnavailable, + occurrences: 1, + monotonicNanoseconds: monotonicNanoseconds + ) + } + previousResolvedPortForwardHealth = health.healthy + devices.append(DoryDeviceTelemetryDevice( + id: "resolved-port-forwards", + kind: .network, + health: health.healthy ? .healthy : .degraded, + metrics: [ + .measured( + .configuredPortForwards, + value: health.configuredForwards + ), + .measured(.activePortForwards, value: health.activeForwards), + .measured( + .portForwardReconciliationFailures, + value: health.failedReconciliations + ), + ] + )) + } + + return DoryDeviceTelemetrySnapshot( + machineID: machineID, + operationID: operationID, + backend: .doryHypervisor, + sampleSequence: sampleSequence, + sampledAtUnixMilliseconds: sampledAtUnixMilliseconds, + monotonicNanoseconds: monotonicNanoseconds, + devices: devices, + events: eventHistory + ) + } + } + + /// Keep the public diagnostic schema lossless with respect to the bounded virtio-net backend. + /// Aggregate drops alone cannot distinguish malformed guest chains from host socket pressure, + /// inactive queue epochs, or an activation failure—the exact distinction needed to repair a + /// failed physical qualification without speculative changes to guest configuration. + static func networkMetrics( + _ network: VirtioNetStatistics + ) -> [DoryDeviceTelemetryMetric] { + [ + .measured(.transmittedFrames, value: network.transmitPackets), + .measured(.transmittedBytes, value: network.transmitBytes), + .measured(.transmitDrops, value: network.transmitDrops), + .measured(.transmitMalformed, value: network.transmitMalformed), + .measured(.transmitOversized, value: network.transmitOversized), + .measured( + .transmitInvalidDescriptors, + value: network.transmitInvalidDescriptors + ), + .measured(.transmitBackpressure, value: network.transmitBackpressure), + .measured(.receivedFrames, value: network.receivePackets), + .measured(.receivedBytes, value: network.receiveBytes), + .measured(.receiveDeferred, value: network.receiveDeferred), + .measured(.receiveDrops, value: network.receiveDrops), + .measured(.receiveTruncations, value: network.receiveTruncations), + .measured(.receiveMalformed, value: network.receiveMalformed), + .measured( + .receiveInvalidDescriptors, + value: network.receiveInvalidDescriptors + ), + .measured( + .receiveInsufficientCapacity, + value: network.receiveInsufficientCapacity + ), + .measured(.receiveBacklogDrops, value: network.receiveBacklogDrops), + .measured(.receiveInactiveDrops, value: network.receiveInactiveDrops), + .measured(.receiveSocketErrors, value: network.receiveSocketErrors), + .measured( + .receiveActivationFailures, + value: network.receiveActivationFailures + ), + ] + } + + /// A device-owned snapshot is copied under the input backend's small state lock. It never + /// acquires the transport/register lock, walks the guest ring, or derives input behavior from + /// transport counters. Keeping this projection one-to-one makes queue pressure and scheduling + /// evidence diagnosable without giving the telemetry sampler lifecycle authority. + static func inputMetrics( + _ input: VirtioInputStatistics + ) -> [DoryDeviceTelemetryMetric] { + [ + .measured(.inputSubmittedFrames, value: input.submittedFrames), + .measured(.inputPublishedFrames, value: input.publishedFrames), + .measured(.inputPublishedEvents, value: input.publishedEvents), + .measured(.inputCoalescedMotionFrames, value: input.coalescedMotionFrames), + .measured(.inputDroppedFrames, value: input.droppedFrames), + .measured(.inputRejectedFrames, value: input.rejectedFrames), + .measured( + .inputStateReconciliationEvents, + value: input.stateReconciliationEvents + ), + .measured(.inputInvalidEventBuffers, value: input.invalidEventBuffers), + .measured(.inputInvalidStatusBuffers, value: input.invalidStatusBuffers), + .measured(.inputStatusEvents, value: input.statusEvents), + .measured(.inputQueueFaults, value: input.queueFaults), + .measured(.inputBoundedDrainStops, value: input.boundedDrainStops), + .measured(.inputWorkerTurns, value: input.workerTurns), + .measured(.inputWorkerYields, value: input.workerYields), + .measured(.inputCoalescedWorkerRequests, value: input.coalescedWorkerRequests), + .measured(.inputRevokedWorkerTurns, value: input.revokedWorkerTurns), + .measured( + .inputPendingFrameSaturationEvents, + value: input.pendingFrameSaturationEvents + ), + .measured(.inputPendingFrameDepth, value: input.pendingFrameDepth), + .measured( + .inputPendingFrameHighWatermark, + value: input.pendingFrameHighWatermark + ), + .measured( + .inputAvailableEventBufferDepth, + value: input.availableEventBufferDepth + ), + .measured( + .inputAvailableEventBufferHighWatermark, + value: input.availableEventBufferHighWatermark + ), + .measured(.inputEventQueueDepth, value: input.eventQueueDepth), + .measured(.inputEventQueueHighWatermark, value: input.eventQueueHighWatermark), + .measured(.inputStatusQueueDepth, value: input.statusQueueDepth), + .measured(.inputStatusQueueHighWatermark, value: input.statusQueueHighWatermark), + .measured( + .inputPublicationLatencyNanoseconds, + value: input.publicationLatencyNanoseconds + ), + .measured( + .inputMaximumPublicationLatencyNanoseconds, + value: input.maximumPublicationLatencyNanoseconds + ), + ] + } + + private func appendEvent( + deviceID: String, + kind: DoryDeviceTelemetryEventKind, + occurrences: UInt64, + monotonicNanoseconds: UInt64 + ) { + guard eventSequence < UInt64.max else { return } + eventSequence += 1 + eventHistory.append(DoryDeviceTelemetryEvent( + sequence: eventSequence, + monotonicNanoseconds: monotonicNanoseconds, + deviceID: deviceID, + kind: kind, + occurrences: occurrences + )) + if eventHistory.count > Self.maximumEventHistory { + eventHistory.removeFirst(eventHistory.count - Self.maximumEventHistory) + } + } + + private static func monotonicDelta(current: UInt64, previous: UInt64) -> UInt64 { + current >= previous ? current - previous : 0 + } +} + +/// One-shot LIFO rollback for side effects acquired by a throwing initializer. Registered actions +/// remain armed until `commit`; an explicit rollback and `deinit` are both safe and idempotent. +final class DesktopInitializationRollback { + private var actions = [() -> Void]() + private var finished = false + + func register(_ action: @escaping () -> Void) { + precondition(!finished, "cannot register initialization rollback after completion") + actions.append(action) + } + + func commit() { + guard !finished else { return } + finished = true + actions.removeAll() + } + + func performIfNeeded() { + guard !finished else { return } + finished = true + let pending = Array(actions.reversed()) + actions.removeAll() + for action in pending { action() } + } + + deinit { + performIfNeeded() + } +} + +enum DesktopGPUShutdownBoundaryResult: Equatable, Sendable { + case completed(epoch: UInt64) + case failed(epoch: UInt64, fault: VirtioGPURendererHealthFault) + case timedOut(epoch: UInt64) + + var logDescription: String { + switch self { + case .completed(let epoch): + return "completed at GPU epoch \(epoch)" + case .failed(let epoch, let fault): + return "failed at GPU epoch \(epoch): \(fault)" + case .timedOut(let epoch): + return "timed out at GPU epoch \(epoch)" + } + } + + var failure: VMError? { + switch self { + case .completed: + return nil + case .failed(let epoch, let fault): + return .unexpectedExit( + "GPU shutdown quiescence failed at epoch \(epoch): \(fault)" + ) + case .timedOut(let epoch): + return .unexpectedExit( + "GPU shutdown quiescence timed out at epoch \(epoch)" + ) + } + } +} + +/// The process-owned destruction boundary for virtio-gpu. Display detachment must run after the +/// device has published every release, but before a worker waits for renderer retirement. Keeping +/// these operations separate prevents AppKit release acknowledgements from being blocked by a +/// wait on the main actor. +enum DesktopGPUShutdownBoundary { + static let timeoutSeconds: TimeInterval = 5 + + static func begin( + quiesce: () -> VirtioGPUQuiescence, + detachPresentations: () -> Void + ) -> VirtioGPUQuiescence { + let receipt = quiesce() + detachPresentations() + return receipt + } + + static func wait( + for receipt: VirtioGPUQuiescence, + timeout: TimeInterval = timeoutSeconds + ) -> DesktopGPUShutdownBoundaryResult { + guard let outcome = receipt.wait(timeout: timeout) else { + return .timedOut(epoch: receipt.epoch) + } + switch outcome { + case .completed: + return .completed(epoch: receipt.epoch) + case .failed(let fault): + return .failed(epoch: receipt.epoch, fault: fault) + } + } +} + +/// Orders guest setup against the first synchronized renderer presentation before readiness is +/// published. Generic media must prove graphics first because it has no Dory-owned boot barrier; +/// managed images must release their display-manager barrier first so a renderer-backed frame can +/// exist. Any failure escapes before `publish`, preserving the fail-closed handoff contract. +/// Optional host capabilities activate only after publication so a permission prompt or missing +/// device cannot keep an otherwise healthy desktop out of the running state. +enum DesktopGuestReadinessBoundary { + static func complete( + genericGuest: Bool, + prepare: () throws -> Prepared, + waitForSynchronizedPresentation: () throws -> Void, + publish: (Prepared) async throws -> Void, + activateOptionalCapabilities: (Prepared) async -> Void = { _ in } + ) async rethrows { + let prepared: Prepared + if genericGuest { + try waitForSynchronizedPresentation() + prepared = try prepare() + } else { + prepared = try prepare() + try waitForSynchronizedPresentation() + } + try await publish(prepared) + await activateOptionalCapabilities(prepared) + } +} + +enum DesktopMachineExecutionState: Equatable, Sendable { + case notStarted + case running + case ended + + var isTerminalBoundary: Bool { + switch self { + case .notStarted, .ended: true + case .running: false + } + } +} + +/// Dispatch signal sources run on a dedicated queue, while every controller operation is isolated +/// to the main actor. Keep the source callback itself nonisolated and use the AppKit run-loop relay +/// for the actor hop. Capturing a `Controller` directly in `setEventHandler` makes Swift annotate +/// the callback as main-actor code even though libdispatch invokes it on the signal queue, causing +/// a deliberate executor-precondition crash before SIGTERM can request a clean guest shutdown. +enum DesktopSignalEventRelay { + static func makeHandler( + _ operation: @escaping @MainActor @Sendable () -> Void + ) -> @Sendable () -> Void { + { DesktopAppRunLoop.perform(operation) } + } +} + +enum DesktopMode { + enum RootDiskBacking: Equatable { + case legacyPath(String) + case resolvedDescriptor(descriptor: Int32, capacityBytes: UInt64) + + var virtualHardwareDiskAuthorityKind: RawHVVirtualHardwareDiskAuthorityKind { + switch self { + case .legacyPath: .legacyPath + case .resolvedDescriptor: .resolvedDescriptor + } + } + + static func resolve( + legacyPath: String?, + runtimeLaunchEnvelope: RuntimeLaunchEnvelope? + ) throws -> Self { + switch (legacyPath, runtimeLaunchEnvelope) { + case let (.some(path), nil) where !path.isEmpty: + return .legacyPath(path) + case let (nil, .some(envelope)): + let slot = try envelope.validatedResolvedRawHVSystemDisk() + guard fcntl(slot.descriptor, F_GETFD) >= 0 else { + throw VMError.invalidConfiguration( + "resolved systemDisk descriptor \(slot.descriptor) is not inherited" + ) + } + return .resolvedDescriptor( + descriptor: slot.descriptor, + capacityBytes: slot.capacityBytes + ) + default: + throw VMError.invalidConfiguration( + "desktop requires exactly one root-disk authority mode" + ) + } + } + + func makeBackend(queueCount: Int) throws -> VirtioBlk { + switch self { + case .legacyPath(let path): + return try VirtioBlk( + path: path, + identity: "dory-rootfs", + queueCount: queueCount + ) + case .resolvedDescriptor(let descriptor, let capacityBytes): + var info = stat() + guard fstat(descriptor, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == geteuid(), + info.st_nlink == 1, + info.st_size > 0, + (info.st_mode & 0o077) == 0, + UInt64(info.st_size) == capacityBytes else { + throw VMError.invalidConfiguration( + "inherited systemDisk failed owner/link/mode/capacity validation" + ) + } + return try VirtioBlk( + fileDescriptor: descriptor, + identity: "dory-rootfs", + queueCount: queueCount + ) + } + } + } + + struct Configuration { + var machineID: String + var operationID: UUID + var stateDirectory: String + var bootPayload: MachineBootPayload + var rootDisk: RootDiskBacking + var rootDevice: String + var genericGuest: Bool + var gvproxyPath: String + var handoffSocketPath: String + var agentSocketPath: String + var shellSocketPath: String + var consoleSocketPath: String + var controlSocketPath: String + var usbControlSocketPath: String? + var sshAgentSocketPath: String? + var memoryMB: UInt64 + var cpuCount: Int + /// Exact guest-visible system-disk queue topology. Resolved launches take this value only + /// from the canonical runtime envelope; legacy launches retain one queue. + var systemDiskQueueCount: Int = 1 + var shares: [DoryMachineShareConfiguration] + var environment: [String: String] + var legacyGraphicsBackend: DoryDesktopGraphicsBackend? = nil + var resolvedGraphics: DoryGraphicsAccelerationLevel? + /// Fully authenticated before `DesktopMode.run`; nil for every software, host-display, + /// and legacy launch. The controller never starts or discovers a renderer process. + var rendererWorkerLaunch: DesktopRendererWorkerLaunch? = nil + var resolvedPlanSHA256: String? = nil + var resolvedPlanRevision: UInt64? = nil + var resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? + var resolvedPortForwards: [DoryVMPortForward]? + var rawHVVirtualHardwareTopology: DoryRawHVVirtualHardwareTopology? + var resolvedSystemDiskLogicalID: DoryVirtualDeviceID? = nil + var displayPresentation: DoryMachineDisplayPresentation = .windowed + + /// Shares actually materialized by the resolved directory-sharing policy. Guest setup must + /// consume this same inventory so it cannot try to mount a tag whose device was omitted. + var attachedShares: [DoryMachineShareConfiguration] { + resolvedDevices?.directorySharing == false ? [] : shares + } + + var rawHVBootAuthorityKind: RawHVVirtualHardwareBootAuthorityKind { + switch bootPayload { + case .legacyPaths: .legacyPaths + case .immutableBytes: .resolvedImmutableBytes + } + } + } + + enum NetworkPlan: Equatable { + case sharedNAT + case hostOnly + case disconnected + + init(resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?) throws { + switch resolvedDevices?.networkAttachment ?? .sharedNAT { + case .sharedNAT: + self = .sharedNAT + case .disconnected: + self = .disconnected + case .isolated: + self = .hostOnly + case .bridged: + throw VMError.bootFailure( + "resolved device contract contains a network mode not implemented by raw-HV" + ) + } + } + + var startsGVProxy: Bool { self != .disconnected } + var attachesNetworkDevice: Bool { true } + var gvproxyConfigurationYAML: String? { + self == .hostOnly ? GVProxyDesktopLaunchPlan.hostOnlyConfigurationYAML : nil + } + } + + struct DisplayPlan: Equatable { + var id: String + var scanoutID: UInt32 + var widthPixels: UInt32 + var heightPixels: UInt32 + var backingScaleFactor: UInt8 + var guestUIScaleFactor: UInt8 + + private init( + display: DoryVirtualMachineDisplayCapabilityRequest, + scanoutID: UInt32 + ) throws { + guard display.isValid, scanoutID < 16 else { + throw VMError.bootFailure( + "resolved display geometry is outside the supported pixel bounds" + ) + } + id = display.id + self.scanoutID = scanoutID + widthPixels = display.widthPixels + heightPixels = display.heightPixels + backingScaleFactor = display.backingScaleFactor + guestUIScaleFactor = display.guestUIScaleFactor + } + + /// Source-compatible primary-display bridge for existing callers and tests. + init(resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?) throws { + self = try Self.resolve(resolvedDevices: resolvedDevices)[0] + } + + static func resolve( + resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest? + ) throws -> [DisplayPlan] { + let displays = resolvedDevices?.displays ?? [DoryVMMDisplayDefaults.capability] + guard !displays.isEmpty, displays.count <= 16 else { + throw VMError.bootFailure( + "raw-HV desktop launch requires between one and sixteen displays" + ) + } + guard Set(displays.map(\.id)).count == displays.count else { + throw VMError.bootFailure( + "resolved display identifiers must be unique" + ) + } + guard Set(displays.map(\.guestUIScaleFactor)).count == 1 else { + throw VMError.bootFailure( + "raw-HV guest tools require one UI scale across all displays" + ) + } + return try displays.enumerated().map { index, display in + try DisplayPlan(display: display, scanoutID: UInt32(index)) + } + } + + var windowSize: NSSize { + NSSize( + width: max(1, CGFloat(widthPixels) / CGFloat(backingScaleFactor)), + height: max(1, CGFloat(heightPixels) / CGFloat(backingScaleFactor)) + ) + } + } + + struct ClipboardPlan: Equatable { + var policy: DoryVMClipboardPolicy? + + init( + resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?, + environment: [String: String], + genericGuest: Bool + ) throws { + guard let resolvedDevices else { + policy = genericGuest ? nil + : DoryDesktopClipboardPolicy( + environment: environment + ).virtualMachinePolicy + return + } + guard resolvedDevices.clipboard else { + guard resolvedDevices.clipboardPolicy?.isEnabled != true else { + throw VMError.bootFailure( + "resolved clipboard device and directional policy disagree" + ) + } + policy = nil + return + } + let selected = resolvedDevices.clipboardPolicy + ?? DoryDesktopClipboardPolicy(environment: environment).virtualMachinePolicy + guard selected.isEnabled else { + throw VMError.bootFailure( + "resolved clipboard device and directional policy disagree" + ) + } + // Files use the daemon-owned Dory Tools sync channel. Keep their direction in the + // exact policy while this display-local coordinator handles only text and images. + policy = selected + } + } + + enum GenericGuestShareReadiness: Equatable, Sendable { + case mounted(Int) + case unavailableMissingCapability([String]) + case unavailableMissingTools([String]) + + var detailSuffix: String { + switch self { + case .mounted(let count): + count == 0 + ? "" + : "; \(count) virtio-fs share(s) proven mounted by Dory Tools" + case .unavailableMissingCapability(let tags): + "; requested virtio-fs shares unavailable because Dory Tools does not advertise virtiofs-mount@1: " + + tags.joined(separator: ", ") + case .unavailableMissingTools(let tags): + tags.isEmpty + ? "" + : "; requested virtio-fs shares unavailable because guest tools are not installed: " + + tags.joined(separator: ", ") + } + } + } + + enum ShutdownPlan: Equatable { + case guestAssisted + case immediate + + init(resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?) { + self = resolvedDevices?.gracefulShutdown == false + ? .immediate : .guestAssisted + } + } + + private struct ResolvedGraphics { + var backend: DoryDesktopGraphicsBackend + var rendererWorkerLaunch: DesktopRendererWorkerLaunch? + } + + @MainActor + static func run(_ configuration: Configuration) throws { + let controller = try Controller(configuration: configuration) + try controller.run() + } + + @MainActor + private final class Controller: NSObject, NSApplicationDelegate, NSWindowDelegate { + private struct MaterializedVirtioBackend { + let request: DoryRawHVVirtualDeviceRequest + let backend: any VirtioDeviceBackend + } + + private let configuration: Configuration + private let application = NSApplication.shared + private let stateLock: EngineStateDirectoryLock + private let serialLog: FileHandle + private let serialOutput: BoundedSerialConsolePublisher + #if arch(arm64) + private let serialConsoleInput: RawHVSerialConsoleInput + #endif + private let machine: Machine + private let machineRunner: RawHVMachineRunner + private let gpu: VirtioGPU + private let graphicsBackend: DoryDesktopGraphicsBackend + private let rendererWorkerLaunch: DesktopRendererWorkerLaunch? + private let rendererRuntimeFailureLatch: DesktopRendererRuntimeFailureLatch? + private let keyboardInput: VirtioInput + private let pointerInput: VirtioInput + private let mailboxes: [DesktopFrameMailbox] + private let displays: [DesktopDisplayView] + private let windows: [NSWindow] + private let displayAssignments: [DoryGuestDisplayPresentationAssignment?] + private let vsock: VirtioVsock + private let audio: DoryMacAudioBackend + private let gvproxy: Process? + private let networkSocketPaths: [String] + private let resolvedPortForwardReconciler: ResolvedPortForwardReconciler? + private let agentBridge: GuestVsockSocketBridge + private let shellBridge: GuestVsockSocketBridge + private let sshAgentBridge: HostSSHAgentBridge? + private let usbipManager: UsbipManager + private let cameraAttachment: DoryDesktopCameraAttachment? + private let usbControlServer: UsbControlServer? + private let clipboard: DoryDesktopClipboardCoordinator? + private let firstFrame: FirstFrameGate + private let deviceTelemetry: RawDeviceTelemetryRegistry + private let lifecycleReceiptServer: VmmLifecycleReceiptServer + private let graphicsSelection: DoryRuntimeGraphicsSelection? + private let guestFSEventBridge: GuestFSEventBridge? + private var filesystemWorker: DoryFilesystemWorkerLaunch? + private var hostShareCoherence: DoryHostShareCoherenceBridge? + private var signalSources = [DispatchSourceSignal]() + private var stopError: Error? + private var stopping = false + private var gpuShutdownReceipt: VirtioGPUQuiescence? + private var gpuShutdownResult: DesktopGPUShutdownBoundaryResult? + private var gpuShutdownWaitScheduled = false + private var machineExecutionState = DesktopMachineExecutionState.notStarted + private let signalQueue = DispatchQueue( + label: "dev.dory.dory-hv.desktop-signals", + qos: .userInitiated + ) + + init(configuration: Configuration) throws { + let virtualHardwareAttachmentMode = try RawHVVirtualHardwareAttachmentPlan.launchMode( + diskAuthority: configuration.rootDisk.virtualHardwareDiskAuthorityKind, + bootAuthority: configuration.rawHVBootAuthorityKind, + topology: configuration.rawHVVirtualHardwareTopology, + resolvedGraphics: configuration.resolvedGraphics, + resolvedDevices: configuration.resolvedDevices, + resolvedPortForwards: configuration.resolvedPortForwards, + resolvedSystemDiskLogicalID: configuration.resolvedSystemDiskLogicalID, + directoryShareStableIDs: configuration.shares.map(\.tag) + ) + self.configuration = configuration + let deviceTelemetry = RawDeviceTelemetryRegistry( + machineID: configuration.machineID, + operationID: configuration.operationID + ) + self.deviceTelemetry = deviceTelemetry + self.lifecycleReceiptServer = VmmLifecycleReceiptServer( + socketPath: configuration.controlSocketPath, + deviceTelemetryProvider: { deviceTelemetry.snapshot() } + ) + try FileManager.default.createDirectory( + atPath: configuration.stateDirectory, + withIntermediateDirectories: true + ) + self.stateLock = try EngineStateDirectoryLock(stateDirectory: configuration.stateDirectory) + self.serialLog = try Self.openAppendLog("\(configuration.stateDirectory)/serial.log") + try Self.appendBootSessionMarker( + to: serialLog, + machineID: configuration.machineID, + operationID: configuration.operationID + ) + self.serialOutput = try BoundedSerialConsolePublisher(destinations: [ + .init(fileHandle: FileHandle.standardError), + .init(fileHandle: serialLog, synchronizeOnStop: true), + ]) + let resolvedGraphics = try Self.resolveGraphics( + legacyBackend: configuration.legacyGraphicsBackend, + exactLevel: configuration.resolvedGraphics, + rendererWorkerLaunch: configuration.rendererWorkerLaunch + ) + self.graphicsBackend = resolvedGraphics.backend + self.rendererWorkerLaunch = resolvedGraphics.rendererWorkerLaunch + let rendererRuntimeFailureLatch = resolvedGraphics.rendererWorkerLaunch == nil + ? nil : DesktopRendererRuntimeFailureLatch() + self.rendererRuntimeFailureLatch = rendererRuntimeFailureLatch + self.graphicsSelection = try Self.graphicsSelection( + configuration: configuration, + resolvedBackend: resolvedGraphics.backend, + rendererWorkerLaunch: resolvedGraphics.rendererWorkerLaunch + ) + let networkPlan = try NetworkPlan(resolvedDevices: configuration.resolvedDevices) + let networkInterface = configuration.resolvedDevices?.networkInterface + if let networkInterface, !networkInterface.isValid { + throw VMError.bootFailure( + "resolved network interface identity or MTU is invalid" + ) + } + let displayPlans = try DisplayPlan.resolve( + resolvedDevices: configuration.resolvedDevices + ) + if let devices = configuration.resolvedDevices { + guard devices.directorySharing == !configuration.shares.isEmpty else { + throw VMError.bootFailure( + "resolved directory-sharing contract does not match the launch shares" + ) + } + } + let clipboardPlan = try ClipboardPlan( + resolvedDevices: configuration.resolvedDevices, + environment: configuration.environment, + genericGuest: configuration.genericGuest + ) + let machine = try Machine(configuration: MachineConfiguration( + bootPayload: configuration.bootPayload, + commandLine: Self.kernelCommandLine( + machineID: configuration.machineID, + operationID: configuration.operationID, + rootDevice: configuration.rootDevice, + graphicsBackend: resolvedGraphics.backend, + genericGuest: configuration.genericGuest + ), + memoryBytes: configuration.memoryMB << 20, + cpuCount: configuration.cpuCount + )) + self.machine = machine + self.machineRunner = RawHVMachineRunner( + machine: machine, + threadName: "dory-hv.desktop.vcpu0" + ) + #if arch(arm64) + let uart = Self.attachPlatformDevices(to: machine, serialOutput: serialOutput) + self.serialConsoleInput = try RawHVSerialConsoleInput( + socketPath: configuration.consoleSocketPath, + uart: uart + ) + #endif + + self.keyboardInput = VirtioInput(profile: .keyboard) + self.pointerInput = VirtioInput(profile: .absolutePointer) + let rendererWorkerLaunch = resolvedGraphics.rendererWorkerLaunch + var mailboxes = [DesktopFrameMailbox]() + var cursorMailboxes = [DesktopCursorMailbox]() + var displays = [DesktopDisplayView]() + let presentationBudget = DesktopCPUPresentationBudget.processDefault + let pointerTopology = DesktopPointerTopology(sizes: displayPlans.map { + VirtioGPUScanoutSize(width: $0.widthPixels, height: $0.heightPixels) + }) + for plan in displayPlans { + let mailbox = DesktopFrameMailbox( + scanoutID: plan.scanoutID, + sharedCPUPresentationBudget: presentationBudget + ) + let cursorMailbox = DesktopCursorMailbox() + let metalDisplay = try DesktopMetalView( + frame: NSRect(origin: .zero, size: plan.windowSize), + keyboardInput: keyboardInput, + pointerInput: pointerInput, + guestBackingScaleFactor: CGFloat(plan.backingScaleFactor), + scanoutID: plan.scanoutID, + pointerTopology: pointerTopology + ) + metalDisplay.onDeviceFailure = { + [ + weak machine, + weak rendererWorkerLaunch, + rendererRuntimeFailureLatch, + ] reason in + rendererRuntimeFailureLatch?.record( + kind: .metalDevice, + reason: reason + ) + rendererWorkerLaunch?.failSynchronizedPresentation(reason) + rendererWorkerLaunch?.teardown(reason: reason) + machine?.requestStop(.crash("Metal display failed closed: \(reason)")) + } + metalDisplay.onWorkerPresentationCompleted = { + [weak rendererWorkerLaunch] workerGeneration in + rendererWorkerLaunch?.recordSynchronizedPresentation( + workerGeneration: workerGeneration + ) + } + let display: DesktopDisplayView = metalDisplay + mailbox.view = display + cursorMailbox.view = display + mailboxes.append(mailbox) + cursorMailboxes.append(cursorMailbox) + displays.append(display) + } + self.mailboxes = mailboxes + self.displays = displays + let firstFrame = FirstFrameGate(requiredScanoutCount: displayPlans.count) + self.firstFrame = firstFrame + + let hostVisibleMemory = try rendererWorkerLaunch != nil + ? VirtioGPUHostVisibleMemory(guestBase: GuestLayout.daxWindowBase) + : nil + let gpu = VirtioGPU( + hostMemoryBase: GuestLayout.daxWindowBase, + scanoutSizes: displayPlans.map { + VirtioGPUScanoutSize(width: $0.widthPixels, height: $0.heightPixels) + }, + rendererWorkerCandidate: rendererWorkerLaunch?.commandLane, + hostVisibleMemory: hostVisibleMemory, + onScanoutFrame: { [mailboxes, firstFrame] frame in + guard mailboxes.indices.contains(Int(frame.scanoutID)) else { return } + mailboxes[Int(frame.scanoutID)].submit(frame) + firstFrame.signal(scanoutID: frame.scanoutID) + }, + onMetalScanout: { [mailboxes] update in + guard mailboxes.indices.contains(Int(update.scanoutID)) else { + update.presentation.discardWithoutPresentation() + return + } + mailboxes[Int(update.scanoutID)].submit(update) + }, + onScanoutResourceReleased: { [mailboxes] release in + for mailbox in mailboxes { + mailbox.release(release) + } + }, + onScanoutDisabled: { [mailboxes] scanoutID in + guard mailboxes.indices.contains(Int(scanoutID)) else { return } + mailboxes[Int(scanoutID)].disable() + }, + onCursorUpdate: { [cursorMailboxes] update in + guard let update else { + for mailbox in cursorMailboxes { mailbox.submit(nil) } + return + } + guard cursorMailboxes.indices.contains(Int(update.scanoutID)) else { return } + cursorMailboxes[Int(update.scanoutID)].submit(update) + }, + onRendererWorkerFailure: { + [ + weak machine, + weak rendererWorkerLaunch, + rendererRuntimeFailureLatch, + ] reason in + rendererRuntimeFailureLatch?.record( + kind: .worker, + reason: reason + ) + rendererWorkerLaunch?.failSynchronizedPresentation(reason) + rendererWorkerLaunch?.teardown(reason: reason) + machine?.requestStop(.crash( + "renderer worker failed closed: \(reason)" + )) + } + ) + self.gpu = gpu + let initializationRollback = DesktopInitializationRollback() + defer { initializationRollback.performIfNeeded() } + initializationRollback.register { + let receipt = DesktopGPUShutdownBoundary.begin( + quiesce: { gpu.quiesce(reason: .shutdown) }, + detachPresentations: { + for mailbox in mailboxes { mailbox.deliver() } + } + ) + let result = DesktopGPUShutdownBoundary.wait(for: receipt) + Self.log( + "dory-hv desktop: GPU initialization rollback \(result.logDescription)" + ) + } + let vsock = VirtioVsock(guestCID: 3) + self.vsock = vsock + initializationRollback.register { _ = vsock.quiesce() } + let usbipManager = UsbipManager(log: Self.log) + try usbipManager.attachListener(to: vsock) + initializationRollback.register { + // Initialization rollback runs before startMachine(), so guest execution never + // acquired vhci state. Use the same explicit terminal boundary as normal teardown. + switch usbipManager.stopAfterGuestExecutionEnded() { + case .completed: + break + case .authorityRetained(let busIDs): + let detail = busIDs.isEmpty + ? "pending listener, bridge, or device drain" + : "claims: \(busIDs.joined(separator: ", "))" + Self.log( + "dory-hv desktop: USB/IP initialization retirement retained authority asynchronously (\(detail))" + ) + } + } + self.usbipManager = usbipManager + let cameraBackend = configuration.resolvedDevices?.cameraInput == true + ? DoryMacCameraBackend(log: Self.log) : nil + if let cameraBackend { + initializationRollback.register { cameraBackend.stop() } + } + let usbControlHandler = UsbControlHandler( + manager: usbipManager, + allowedOpenModes: [.userAuthorized], + ensureSupported: { + Self.log("dory-hv desktop: USB camera opening Dory Tools capability channel") + let control = AgentControl(configuration: .init( + directSocketPath: configuration.agentSocketPath + )) + defer { control.disconnect() } + let info = try control.info() + guard info.protocolVersion == DoryCore.protocolVersion(), + info.capabilitiesAreCanonical, + info.supports("usb-vhci", minimumVersion: 1) else { + throw UsbControlError.guestAgentRPCUnavailable + } + Self.log("dory-hv desktop: USB camera confirmed Dory Tools usb-vhci@1") + }, + openDevice: { busID, mode in + if busID == DoryVirtualUVCCamera.busID, let cameraBackend { + return HostUsbDevice( + descriptor: DoryVirtualUVCCamera.descriptor(), + backend: DoryVirtualUVCCameraBackend(frameSource: cameraBackend), + timeout: 5, + maxConcurrentRequests: 8, + maxInFlightBytes: 16 * 1_024 * 1_024, + shutdownTimeout: 2 + ) + } + return try HostUsbDeviceFactory.open(busID: busID, mode: mode) + }, + notifyAttach: { request in + Self.log("dory-hv desktop: USB camera requesting Linux VHCI attachment") + let control = AgentControl(configuration: .init( + directSocketPath: configuration.agentSocketPath + )) + defer { control.disconnect() } + try control.usbVhciAttach( + busID: request.busid, + port: UInt32(request.port), + vsockPort: request.vsock_port, + deviceID: request.device_id, + speed: request.speed + ) + Self.log("dory-hv desktop: USB camera Linux VHCI attachment acknowledged") + }, + notifyDetach: { request in + Self.log("dory-hv desktop: USB camera requesting Linux VHCI detach") + let control = AgentControl(configuration: .init( + directSocketPath: configuration.agentSocketPath + )) + defer { control.disconnect() } + try control.usbVhciDetach( + busID: request.busid, + port: UInt32(request.port) + ) + Self.log("dory-hv desktop: USB camera Linux VHCI detach acknowledged") + }, + trace: { Self.log("dory-hv desktop: USB camera \($0)") } + ) + self.cameraAttachment = cameraBackend.map { + DoryDesktopCameraAttachment( + backend: $0, + handler: usbControlHandler, + log: Self.log + ) + } + self.usbControlServer = configuration.usbControlSocketPath.map { + UsbControlServer(path: $0, handler: usbControlHandler) + } + self.audio = DoryMacAudioBackend(log: Self.log) + var audioDirections = [VirtioSoundDirection]() + if configuration.resolvedDevices?.audioOutput != false { + audioDirections.append(.output) + } + if configuration.resolvedDevices?.audioInput != false { + audioDirections.append(.input) + } + let sound = audioDirections.isEmpty + ? nil + : VirtioSound( + host: audio, + enabledDirections: audioDirections, + log: Self.log + ) + let balloon = VirtioBalloon(memory: machine.memory) { message in + Self.log(message) + } + + let runtimeDirectory = (configuration.agentSocketPath as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: runtimeDirectory, withIntermediateDirectories: true) + guard chmod(runtimeDirectory, 0o700) == 0 else { + throw VMError.bootFailure( + "could not secure raw-HV runtime socket directory: \(errno)" + ) + } + let token = String(configuration.machineID.prefix(12)) + let networkRuntime = try Self.prepareNetwork( + plan: networkPlan, + networkInterface: networkInterface, + gvproxyPath: configuration.gvproxyPath, + runtimeDirectory: runtimeDirectory, + token: token, + resolvedPortForwards: configuration.resolvedPortForwards + ) + initializationRollback.register { + networkRuntime.portForwardReconciler?.stop() + if let process = networkRuntime.process { + ChildProcessTerminator.terminateAndReap(process) + } + for path in networkRuntime.socketPaths { unlink(path) } + } + self.gvproxy = networkRuntime.process + self.networkSocketPaths = networkRuntime.socketPaths + self.resolvedPortForwardReconciler = networkRuntime.portForwardReconciler + if let reconciler = networkRuntime.portForwardReconciler { + deviceTelemetry.registerResolvedPortForwardHealth { [weak reconciler] in + reconciler?.healthSnapshot() + } + } + do { + let rootDisk = try configuration.rootDisk.makeBackend( + queueCount: configuration.systemDiskQueueCount + ) + let entropy = VirtioRng() + var backends: [any VirtioDeviceBackend] = [ + rootDisk, + gpu, + entropy, + balloon, + vsock, + ] + if configuration.resolvedDevices?.keyboard != false { + backends.append(keyboardInput) + } + if configuration.resolvedDevices?.pointer != false { + backends.append(pointerInput) + } + if let sound { + backends.append(sound) + } + let attachedShares = configuration.attachedShares + let rawShares = try attachedShares.map { share in + try VirtioFSShareConfiguration( + tag: share.tag, + path: share.hostPath, + readOnly: share.readOnly, + guestMountPoint: share.guestPath + ) + } + try VirtioFSShareConfiguration.validateWritableTopology(rawShares) + let coherencePolicyByTag = Dictionary(uniqueKeysWithValues: rawShares.map { + share in + ( + share.tag, + share.readOnly || configuration.genericGuest + ? DoryFSShareCoherencePolicy.invalidationOnly + : .invalidationAndWatcherNudge + ) + }) + let filesystemWorker = rawShares.isEmpty + ? nil + : try DoryFilesystemWorkerLauncher.startBlocking( + shares: rawShares, + coherencePolicyByTag: coherencePolicyByTag + ) + self.filesystemWorker = filesystemWorker + if let filesystemWorker { + initializationRollback.register { + filesystemWorker.client.invalidate() + } + } + var shareBackends = [(share: DoryMachineShareConfiguration, + backend: any VirtioDeviceBackend)]() + var coherenceEndpoints = [DoryHostShareCoherenceEndpoint]() + for (share, rawShare) in zip(attachedShares, rawShares) { + guard let filesystemWorker else { + throw VMError.invalidConfiguration( + "filesystem worker missing for attached share" + ) + } + let backend = try rawShare.makeBackend( + broker: filesystemWorker.broker(for: rawShare), + requestQueueCount: min(8, max(1, configuration.cpuCount)), + onWorkerLifecycle: { [weak machine] event in + Self.log("dory-hv desktop: \(event.diagnostic)") + if case .failure(let reason) = event { + machine?.requestStop(.crash(reason)) + } + } + ) + backends.append(backend) + shareBackends.append((share, backend)) + coherenceEndpoints.append(try DoryHostShareCoherenceEndpoint( + capabilityID: filesystemWorker.capability(for: rawShare), + backend: backend, + guestRoot: share.guestPath, + policy: coherencePolicyByTag[share.tag] ?? .disabled + )) + } + var configuredGuestFSEventBridge: GuestFSEventBridge? + if let filesystemWorker { + let guestFSEventBridge = GuestFSEventBridge(vsock: vsock) + let hostShareCoherence = DoryHostShareCoherenceBridge( + endpoints: coherenceEndpoints, + guestEvents: guestFSEventBridge + ) { [weak machine] reason in + Self.log("dory-hv desktop: \(reason)") + machine?.requestStop(.crash(reason)) + } + guard filesystemWorker.installCoherenceHandler({ batch in + try await hostShareCoherence.process(batch) + }) else { + throw VMError.invalidConfiguration( + "desktop filesystem coherence handler was already installed" + ) + } + filesystemWorker.installLifecycleHandler { [weak hostShareCoherence] event in + hostShareCoherence?.failStop( + "filesystem worker coherence channel \(event)" + ) + } + try filesystemWorker.client.prepareCoherence() + self.hostShareCoherence = hostShareCoherence + if coherenceEndpoints.contains(where: { + $0.policy == .invalidationAndWatcherNudge + }) { + configuredGuestFSEventBridge = guestFSEventBridge + } + } + guestFSEventBridge = configuredGuestFSEventBridge + if let network = networkRuntime.backend { + backends.append(network) + } + + let attachments: [(slot: Int, backend: any VirtioDeviceBackend)] + let resolvedAssignments: [RawHVVirtualHardwareAttachmentAssignment]? + switch virtualHardwareAttachmentMode { + case .legacy: + resolvedAssignments = nil + case .resolved(let assignments): + resolvedAssignments = assignments + } + if let preflightAssignments = resolvedAssignments { + let authorizedDevices = preflightAssignments.map(\.request) + var materialized = [MaterializedVirtioBackend]() + materialized.append(try Self.singletonMaterialization( + role: .systemDisk, + authorizedDevices: authorizedDevices, + backend: rootDisk + )) + materialized.append(try Self.singletonMaterialization( + role: .graphics, + authorizedDevices: authorizedDevices, + backend: gpu + )) + materialized.append(try Self.singletonMaterialization( + role: .entropy, + authorizedDevices: authorizedDevices, + backend: entropy + )) + materialized.append(try Self.singletonMaterialization( + role: .balloon, + authorizedDevices: authorizedDevices, + backend: balloon + )) + materialized.append(try Self.singletonMaterialization( + role: .vsock, + authorizedDevices: authorizedDevices, + backend: vsock + )) + if configuration.resolvedDevices?.keyboard == true { + materialized.append(try Self.singletonMaterialization( + role: .keyboard, + authorizedDevices: authorizedDevices, + backend: keyboardInput + )) + } + if configuration.resolvedDevices?.pointer == true { + materialized.append(try Self.singletonMaterialization( + role: .pointer, + authorizedDevices: authorizedDevices, + backend: pointerInput + )) + } + if let sound { + materialized.append(try Self.singletonMaterialization( + role: .audio, + authorizedDevices: authorizedDevices, + backend: sound + )) + } + for entry in shareBackends { + materialized.append(MaterializedVirtioBackend( + request: DoryRawHVVirtualDeviceRequest( + logicalID: try DoryVirtualDeviceID.derived( + namespace: .directoryShare, + stableID: entry.share.tag + ), + role: .directoryShare + ), + backend: entry.backend + )) + } + guard let network = networkRuntime.backend, + let networkInterface = configuration.resolvedDevices?.networkInterface else { + throw VMError.invalidConfiguration( + "resolved RawHV topology requires its stable network function" + ) + } + materialized.append(MaterializedVirtioBackend( + request: DoryRawHVVirtualDeviceRequest( + logicalID: try DoryVirtualDeviceID.derived( + namespace: .network, + stableID: networkInterface.id + ), + role: .network + ), + backend: network + )) + guard let topology = configuration.rawHVVirtualHardwareTopology else { + throw VMError.invalidConfiguration( + "resolved RawHV preflight lost its durable topology" + ) + } + let assignments = try RawHVVirtualHardwareAttachmentPlan.assignments( + topology: topology, + materializedDevices: materialized.map(\.request) + ) + guard assignments == preflightAssignments else { + throw VMError.invalidConfiguration( + "materialized RawHV assignments differ from preflight" + ) + } + attachments = try assignments.map { assignment in + guard let materializedBackend = materialized.first(where: { + $0.request == assignment.request + }) else { + throw VMError.invalidConfiguration( + "authorized RawHV device has no materialized backend" + ) + } + return (assignment.mmioSlot, materializedBackend.backend) + } + } else { + attachments = backends.enumerated().map { ($0.offset, $0.element) } + } + + for attachment in attachments { + let slot = attachment.slot + let backend = attachment.backend + let transport = try Self.attachBackend( + backend, + to: machine, + slot: slot + ) + let audioMetrics: (@Sendable () -> DoryMacAudioRuntimeMetrics?)? + if let sound, backend === sound { + audioMetrics = { [weak audio] in audio?.runtimeMetrics } + } else { + audioMetrics = nil + } + let displayMetrics: (@Sendable () -> DesktopFrameMailboxMetrics?)? + if backend === gpu { + displayMetrics = { [mailboxes] in + mailboxes.map(\.metrics).reduce( + DesktopFrameMailboxMetrics( + presentedFrames: 0, + droppedFrames: 0, + budgetRejectedFrames: 0 + ) + ) { partial, next in + DesktopFrameMailboxMetrics( + presentedFrames: partial.presentedFrames.addingClamped( + next.presentedFrames + ), + droppedFrames: partial.droppedFrames.addingClamped( + next.droppedFrames + ), + budgetRejectedFrames: + partial.budgetRejectedFrames.addingClamped( + next.budgetRejectedFrames + ), + receivedFrameBytes: partial.receivedFrameBytes.addingClamped( + next.receivedFrameBytes + ), + stagingCopyBytes: partial.stagingCopyBytes.addingClamped( + next.stagingCopyBytes + ), + drainCopyBytes: partial.drainCopyBytes.addingClamped( + next.drainCopyBytes + ), + uploadedFrameBytes: partial.uploadedFrameBytes.addingClamped( + next.uploadedFrameBytes + ), + droppedFrameBytes: partial.droppedFrameBytes.addingClamped( + next.droppedFrameBytes + ), + pendingFrameBytes: partial.pendingFrameBytes.addingClamped( + next.pendingFrameBytes + ), + pendingFrameDepth: partial.pendingFrameDepth.addingClamped( + next.pendingFrameDepth + ) + ) + } + } + } else { + displayMetrics = nil + } + let presentationBudgetMetrics: + (@Sendable () -> DesktopCPUPresentationBudgetMetrics?)? + if backend === gpu { + presentationBudgetMetrics = { presentationBudget.metrics } + } else { + presentationBudgetMetrics = nil + } + deviceTelemetry.register( + slot: slot, + backend: backend, + transport: transport, + audioMetrics: audioMetrics, + displayMetrics: displayMetrics, + presentationBudgetMetrics: presentationBudgetMetrics + ) + if backend === gpu, configuration.resolvedDevices?.dynamicDisplay != false { + for (index, display) in displays.enumerated() { + let scanoutID = UInt32(index) + display.onDrawableSizeChange = { + [weak gpu, weak transport] width, height in + guard let gpu, let transport else { return } + pointerTopology.update( + scanoutID: scanoutID, + width: width, + height: height + ) + gpu.updateScanoutSize( + scanoutID: scanoutID, + width: width, + height: height, + transport: transport + ) + } + } + } + } + if let resolvedAssignments { + guard machine.attachedVirtioSlots.map(\.slot) + == resolvedAssignments.map(\.mmioSlot) else { + throw VMError.invalidConfiguration( + "materialized MMIO layout differs from the durable RawHV topology" + ) + } + } + try machine.loadBootPayload() + } catch { + initializationRollback.performIfNeeded() + throw error + } + + self.agentBridge = GuestVsockSocketBridge( + socketPath: configuration.agentSocketPath, + guestPort: VsockPorts.agent, + service: .agentSocket, + log: Self.log + ) + self.shellBridge = GuestVsockSocketBridge( + socketPath: configuration.shellSocketPath, + guestPort: 1027, + service: .shell, + log: Self.log + ) + let rollbackAgentBridge = self.agentBridge + let rollbackShellBridge = self.shellBridge + initializationRollback.register { + rollbackAgentBridge.stop() + rollbackShellBridge.stop() + } + try agentBridge.attach(to: vsock) + try shellBridge.attach(to: vsock) + if let sshAgentSocketPath = configuration.sshAgentSocketPath { + let bridge = try HostSSHAgentBridge( + socketPath: sshAgentSocketPath, + log: Self.log + ) + initializationRollback.register { bridge.stop() } + try bridge.attach(to: vsock) + self.sshAgentBridge = bridge + } else { + self.sshAgentBridge = nil + } + if let clipboardPolicy = clipboardPlan.policy { + let clipboardControl = DorydKit.AgentControl(configuration: .init( + directSocketPath: configuration.agentSocketPath + )) + let clipboardInput = self.keyboardInput + self.clipboard = DoryDesktopClipboardCoordinator( + policy: clipboardPolicy, + execute: { argv, stdin, timeoutMs, outputLimitBytes in + try clipboardControl.execWithInput( + argv: argv, + stdin: stdin, + timeoutMs: timeoutMs, + outputLimitBytes: outputLimitBytes + ) + }, + sendShortcut: { keyCode in + clipboardInput.send(frame: [ + VirtioInputEvent(type: 1, code: 125, value: 0), + VirtioInputEvent(type: 1, code: 126, value: 0), + VirtioInputEvent(type: 1, code: 29, value: 1), + VirtioInputEvent(type: 1, code: keyCode, value: 1), + VirtioInputEvent(type: 1, code: keyCode, value: 0), + VirtioInputEvent(type: 1, code: 29, value: 0), + ]) + }, + log: Self.log + ) + } else { + self.clipboard = nil + } + + var windows = [NSWindow]() + let displayAssignments = displayPlans.map { + configuration.displayPresentation.assignment(forGuestDisplayID: $0.id) + } + for (index, plan) in displayPlans.enumerated() { + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: plan.windowSize), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = displayPlans.count == 1 + ? "\(configuration.machineID) — Dory Desktop" + : "\(configuration.machineID) — Dory Desktop — Display \(index + 1)" + // Keep the Metal surface bound to the window's *actual* content layout. A + // dedicated display can transiently remain a normal titled window while AppKit + // enters its fullscreen Space. Installing a fixed-size display view directly as the + // content view left its original 1920x1080 bounds clipped inside a 1920x1020 + // visible content area, so the framebuffer and absolute tablet normalized + // different coordinate spaces. The AppKit-managed container always follows the + // titlebar/fullscreen transition; constraints then make display pixels and input + // use the same rectangle. + let contentView = NSView( + frame: NSRect(origin: .zero, size: window.contentLayoutRect.size) + ) + let display = displays[index] + display.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(display) + window.contentView = contentView + NSLayoutConstraint.activate([ + display.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + display.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + display.topAnchor.constraint(equalTo: contentView.topAnchor), + display.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + window.minSize = NSSize(width: 640, height: 400) + window.collectionBehavior.insert(.fullScreenPrimary) + window.tabbingMode = .disallowed + window.center() + if index > 0, let first = windows.first { + window.setFrameOrigin(NSPoint( + x: first.frame.minX + CGFloat(index * 36), + y: first.frame.minY - CGFloat(index * 36) + )) + } + windows.append(window) + } + self.windows = windows + self.displayAssignments = displayAssignments + super.init() + for window in windows { window.delegate = self } + for display in displays { + display.onMacShortcut = { [weak clipboard] event in + clipboard?.handleMacShortcut(event) ?? false + } + } + clipboard?.start() + initializationRollback.commit() + } + + func run() throws { + defer { + ensureGPUShutdownBeforeTeardown() + cleanup() + } + try usbControlServer?.start() + do { + try lifecycleReceiptServer.start() + } catch { + usbControlServer?.stop() + throw error + } + DoryDesktopApplicationIdentity.install(on: application) + application.setActivationPolicy(.regular) + application.delegate = self + installApplicationMenu() + installSignalHandlers() + for window in windows { window.makeKeyAndOrderFront(nil) } + application.activate() + DesktopAppRunLoop.perform { [weak self] in + guard let self else { return } + for (window, assignment) in zip(self.windows, self.displayAssignments) { + _ = DoryHostDisplayPresentation.enterDedicatedFullscreen( + window: window, + assignment: assignment + ) + } + } + try startMachine() + application.run() + if let stopError { throw stopError } + } + + private func installApplicationMenu() { + let mainMenu = NSMenu() + + let applicationItem = NSMenuItem(title: "Dory Desktop", action: nil, keyEquivalent: "") + let applicationMenu = NSMenu(title: "Dory Desktop") + applicationMenu.addItem( + withTitle: "Quit Dory Desktop", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q" + ) + applicationItem.submenu = applicationMenu + mainMenu.addItem(applicationItem) + + let viewItem = NSMenuItem(title: "View", action: nil, keyEquivalent: "") + let viewMenu = NSMenu(title: "View") + let fullScreenItem = NSMenuItem( + title: "Enter Full Screen", + action: #selector(toggleFullScreen(_:)), + keyEquivalent: "f" + ) + fullScreenItem.keyEquivalentModifierMask = [.command, .control] + fullScreenItem.target = self + viewMenu.addItem(fullScreenItem) + viewItem.submenu = viewMenu + mainMenu.addItem(viewItem) + + application.mainMenu = mainMenu + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + for window in windows { window.makeKeyAndOrderFront(nil) } + return true + } + + func applicationDidChangeScreenParameters(_ notification: Notification) { + for (window, assignment) in zip(windows, displayAssignments) { + _ = DoryHostDisplayPresentation.recoverDisconnectedDisplay( + window: window, + assignment: assignment + ) + } + } + + @objc private func toggleFullScreen(_ sender: Any?) { + (application.keyWindow ?? windows.first)?.toggleFullScreen(sender) + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + requestGuestShutdown() + return .terminateCancel + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + sender.orderOut(nil) + return false + } + + func windowDidResignKey(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + let index = windows.firstIndex(of: window) else { return } + displays[index].releasePressedInput() + } + + func applicationDidResignActive(_ notification: Notification) { + for display in displays { display.releasePressedInput() } + } + + private func startMachine() throws { + let machine = self.machine + machineExecutionState = .running + do { + try machineRunner.start { [weak self] result in + DesktopAppRunLoop.perform { + switch result { + case .success(let reason): + self?.finish( + error: Self.error(for: reason), + machineExecutionEnded: true + ) + case .failure(let error): + self?.finish(error: error, machineExecutionEnded: true) + } + } + } + } catch { + machineExecutionState = .notStarted + throw error + } + + let configuration = self.configuration + let graphicsDisplayName = graphicsBackend.displayName + let firstFrame = self.firstFrame + let cameraAttachment = self.cameraAttachment + let guestFSEventBridge = self.guestFSEventBridge + let filesystemWorker = self.filesystemWorker + Task.detached(priority: .userInitiated) { [weak self] in + do { + if let guestFSEventBridge { + try await guestFSEventBridge.establishReadiness() + Self.log( + "dory-hv desktop: host-share watcher bridge ready on guest vsock:\(VsockPorts.fsevents)" + ) + } + try filesystemWorker?.client.activateCoherence() + if filesystemWorker != nil { + Self.log("dory-hv desktop: host-share coherence delivery active") + } + if configuration.genericGuest { + try await DesktopGuestReadinessBoundary.complete( + genericGuest: true, + prepare: { + guard configuration.rendererWorkerLaunch != nil + || firstFrame.wait(timeout: 90) else { + throw VMError.bootFailure( + "generic Linux guest did not publish a graphics frame within 90s" + ) + } + return try Self.prepareGenericGuestIntegration( + configuration: configuration, + timeout: 15 + ) + }, + waitForSynchronizedPresentation: { + if let rendererWorkerLaunch = + configuration.rendererWorkerLaunch { + // Generic media has no Dory-owned display-manager barrier, so + // retain the existing renderer-first readiness contract. + try rendererWorkerLaunch + .waitForFirstSynchronizedPresentation(timeout: 90) + } + }, + publish: { integration in + switch integration { + case let .tools(info, shareState): + DesktopAppRunLoop.perform { [weak self] in + self?.clipboard?.markGuestReady() + } + try configuration.rendererWorkerLaunch? + .claimSynchronizedPresentationForPublication() + try VmmHandoffClient.send( + path: configuration.handoffSocketPath, + ready: VmmReadyMessage( + machineID: configuration.machineID, + operationID: DoryOperationIdentity.canonical( + configuration.operationID + ), + agentBuild: info.agentBuild, + agentProtocolVersion: info.protocolVersion, + agentCapabilities: info.capabilities, + agentSocketPath: configuration.agentSocketPath, + shellSocketPath: configuration.shellSocketPath, + controlSocketPath: configuration.controlSocketPath, + graphicsSelection: self?.graphicsSelection, + detail: "raw-HV generic Linux running with \(graphicsDisplayName) graphics and Dory Tools protocol \(info.protocolVersion)\(shareState.detailSuffix)" + ) + ) + case .unavailable: + let shareState = GenericGuestShareReadiness + .unavailableMissingTools( + configuration.attachedShares.map(\.tag) + ) + try configuration.rendererWorkerLaunch? + .claimSynchronizedPresentationForPublication() + try VmmHandoffClient.send( + path: configuration.handoffSocketPath, + ready: VmmReadyMessage( + machineID: configuration.machineID, + operationID: DoryOperationIdentity.canonical( + configuration.operationID + ), + agentBuild: "dory-hv/generic-linux", + controlSocketPath: configuration.controlSocketPath, + graphicsSelection: self?.graphicsSelection, + detail: "raw-HV generic Linux running with \(graphicsDisplayName) graphics; guest tools are not installed\(shareState.detailSuffix)" + ) + ) + } + }, + activateOptionalCapabilities: { integration in + switch integration { + case .tools: + _ = await cameraAttachment?.attachIfAvailable() + case .unavailable: + _ = cameraAttachment?.unavailableWithoutGuestTools() + } + } + ) + return + } + try await DesktopGuestReadinessBoundary.complete( + genericGuest: false, + prepare: { + // prepareGuest writes /var/lib/dory/host-configured. The managed + // display manager is deliberately blocked on that marker, so it must + // exist before a renderer-backed presentation can be required. + try Self.prepareGuest(configuration: configuration) + }, + waitForSynchronizedPresentation: { + if let rendererWorkerLaunch = + configuration.rendererWorkerLaunch { + // The immutable receipt and kernel/fence authority select the + // candidate, but handoff still requires a real worker-backed frame + // across the producer-fence wait and Metal completion boundary. + try rendererWorkerLaunch + .waitForFirstSynchronizedPresentation(timeout: 90) + } + }, + publish: { info in + DesktopAppRunLoop.perform { [weak self] in + self?.clipboard?.markGuestReady() + } + try configuration.rendererWorkerLaunch? + .claimSynchronizedPresentationForPublication() + try VmmHandoffClient.send( + path: configuration.handoffSocketPath, + ready: VmmReadyMessage( + machineID: configuration.machineID, + operationID: DoryOperationIdentity.canonical( + configuration.operationID + ), + agentBuild: info.agentBuild, + agentProtocolVersion: info.protocolVersion, + agentCapabilities: info.capabilities, + agentSocketPath: configuration.agentSocketPath, + shellSocketPath: configuration.shellSocketPath, + controlSocketPath: configuration.controlSocketPath, + graphicsSelection: self?.graphicsSelection, + detail: "raw-HV desktop running with \(graphicsDisplayName) graphics; dory-agent answered protocol \(info.protocolVersion)" + ) + ) + }, + activateOptionalCapabilities: { _ in + _ = await cameraAttachment?.attachIfAvailable() + } + ) + } catch { + configuration.rendererWorkerLaunch?.teardown( + reason: "desktop readiness failed: \(error)" + ) + machine.requestStop(.crash("desktop readiness failed: \(error)")) + DesktopAppRunLoop.perform { + self?.finish(error: error) + } + } + } + } + + private enum GenericGuestIntegration: Sendable { + case tools(DoryAgentInfo, GenericGuestShareReadiness) + case unavailable + } + + private nonisolated static func prepareGenericGuestIntegration( + configuration: Configuration, + timeout: TimeInterval + ) throws -> GenericGuestIntegration { + let deadline = Date().addingTimeInterval(timeout) + var lastToolsError: Error? + repeat { + let control = AgentControl(configuration: .init( + directSocketPath: configuration.agentSocketPath + )) + var toolsAnswered = false + do { + defer { control.disconnect() } + let info = try control.info() + toolsAnswered = true + guard info.protocolVersion == DoryCore.protocolVersion() else { + throw AgentControlError.incompatibleProtocol( + expected: DoryCore.protocolVersion(), + actual: info.protocolVersion + ) + } + guard info.capabilitiesAreCanonical else { + throw AgentControlError.invalidCapabilities + } + guard !configuration.attachedShares.isEmpty else { + return .tools(info, .mounted(0)) + } + guard info.supports("virtiofs-mount", minimumVersion: 1) else { + return .tools( + info, + .unavailableMissingCapability( + configuration.attachedShares.map(\.tag) + ) + ) + } + for share in configuration.attachedShares { + _ = try control.virtioFSMount( + tag: share.tag, + mountPath: share.guestPath, + readOnly: share.readOnly + ) + } + return .tools(info, .mounted(configuration.attachedShares.count)) + } catch { + if toolsAnswered { + // The typed operation is idempotent. A lost response can therefore retry + // until readiness expires without an Exec fallback or an extra mount layer. + lastToolsError = error + } + } + Thread.sleep(forTimeInterval: 0.25) + } while Date() < deadline + if let lastToolsError { + throw VMError.bootFailure( + "generic Linux virtio-fs integration failed after Dory Tools answered: \(lastToolsError)" + ) + } + return .unavailable + } + + private func requestGuestShutdown() { + guard !stopping else { return } + stopping = true + for window in windows { window.orderOut(nil) } + let machine = self.machine + if ShutdownPlan(resolvedDevices: configuration.resolvedDevices) == .immediate { + machine.requestStop(.powerOff) + return + } + if configuration.genericGuest { + // Linux maps KEY_POWER to logind's normal power-button action. This provides a + // clean integration-free shutdown path until Dory guest tools are installed. + keyboardInput.send(frame: [ + VirtioInputEvent(type: 1, code: 116, value: 1), + VirtioInputEvent(type: 1, code: 116, value: 0), + ]) + } else { + do { + let control = AgentControl(configuration: .init( + directSocketPath: configuration.agentSocketPath + )) + defer { control.disconnect() } + try Self.requireSuccess(control.exec( + argv: ["/bin/sh", "-c", GuestShutdownCommand.detachedDesktopRequest()], + timeoutMs: 5_000, + outputLimitBytes: 64 * 1024 + ), operation: "desktop guest shutdown request") + } catch { + Self.log("dory-hv desktop: graceful guest shutdown request failed: \(error)") + } + } + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + DoryEngineShutdownTiming.helperWatchdogSeconds + ) { + machine.requestStop(.crash("guest shutdown timed out")) + } + } + + private func finish(error: Error?, machineExecutionEnded: Bool = false) { + if machineExecutionEnded { + machineExecutionState = .ended + } + if stopError == nil { + stopError = rendererRuntimeFailureLatch?.failure ?? error + } + let receipt = beginGPUShutdownIfNeeded() + guard !gpuShutdownWaitScheduled else { + stopApplicationAfterGPUShutdown() + return + } + gpuShutdownWaitScheduled = true + DispatchQueue.global(qos: .userInitiated).async { [weak self, receipt] in + let result = DesktopGPUShutdownBoundary.wait(for: receipt) + DesktopAppRunLoop.perform { [weak self] in + guard let self else { return } + self.recordGPUShutdownResult(result) + self.stopApplicationAfterGPUShutdown() + } + } + } + + private func beginGPUShutdownIfNeeded() -> VirtioGPUQuiescence { + if let gpuShutdownReceipt { return gpuShutdownReceipt } + let receipt = DesktopGPUShutdownBoundary.begin( + quiesce: { gpu.quiesce(reason: .shutdown) }, + detachPresentations: { + for mailbox in mailboxes { mailbox.deliver() } + } + ) + gpuShutdownReceipt = receipt + Self.log( + "dory-hv desktop: waiting for GPU shutdown quiescence at epoch \(receipt.epoch)" + ) + return receipt + } + + private func ensureGPUShutdownBeforeTeardown() { + guard gpuShutdownResult == nil else { return } + let receipt = beginGPUShutdownIfNeeded() + // This is the setup-failure and abnormal-run-loop fallback. A final main-actor drain + // makes every release acknowledgement visible before the bounded synchronous wait. + for mailbox in mailboxes { mailbox.deliver() } + recordGPUShutdownResult(DesktopGPUShutdownBoundary.wait(for: receipt)) + } + + private func recordGPUShutdownResult(_ result: DesktopGPUShutdownBoundaryResult) { + guard gpuShutdownResult == nil else { return } + gpuShutdownResult = result + Self.log("dory-hv desktop: GPU shutdown quiescence \(result.logDescription)") + if stopError == nil { + stopError = desktopGPUShutdownFailure( + result, + rendererFailureLatch: rendererRuntimeFailureLatch + ) + } + } + + private func stopApplicationAfterGPUShutdown() { + guard machineExecutionState.isTerminalBoundary, gpuShutdownResult != nil else { + return + } + application.stop(nil) + if let wakeEvent = NSEvent.otherEvent( + with: .applicationDefined, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + subtype: 0, + data1: 0, + data2: 0 + ) { + application.postEvent(wakeEvent, atStart: false) + } + } + + private func cleanup() { + if machineExecutionState == .running { + machine.requestStop(.crash("AppKit run loop ended before guest execution")) + } + if machineExecutionState != .notStarted { + do { + _ = try machineRunner.wait() + } catch { + Self.log("dory-hv desktop: machine owner-thread join failed: \(error)") + if stopError == nil { stopError = error } + } + machineExecutionState = .ended + } + filesystemWorker?.client.close() + filesystemWorker = nil + hostShareCoherence = nil + lifecycleReceiptServer.stop() + #if arch(arm64) + serialConsoleInput.stop() + #endif + usbControlServer?.stop() + precondition( + machineExecutionState.isTerminalBoundary, + "desktop USB authority cannot retire while Machine.run() is executing" + ) + // application.run() is stopped only by finish(), which is published after Machine.run() + // returns. The guest is therefore terminal before physical USB authority is released. + switch usbipManager.stopAfterGuestExecutionEnded() { + case .completed: + break + case .authorityRetained(let busIDs): + let detail = busIDs.isEmpty + ? "pending listener, bridge, or device drain" + : "claims: \(busIDs.joined(separator: ", "))" + Self.log( + "dory-hv desktop: USB/IP terminal retirement retained authority asynchronously (\(detail))" + ) + } + clipboard?.stop() + agentBridge.stop() + shellBridge.stop() + sshAgentBridge?.stop() + _ = vsock.quiesce() + resolvedPortForwardReconciler?.stop() + signalSources.forEach { $0.cancel() } + signalSources.removeAll() + if let gvproxy { + ChildProcessTerminator.terminateAndReap(gvproxy) + } + for path in networkSocketPaths { unlink(path) } + let serialReceipt = serialOutput.stop() + if !serialReceipt.isClean { + Self.log( + "dory-hv desktop: serial publisher retired with faults: " + + serialReceipt.diagnosticSummary + ) + } + try? serialLog.close() + } + + private func installSignalHandlers() { + for number in [SIGTERM, SIGINT] { + signal(number, SIG_IGN) + let source = DispatchSource.makeSignalSource( + signal: number, + queue: signalQueue + ) + source.setEventHandler(handler: DesktopSignalEventRelay.makeHandler { + [weak self] in self?.requestGuestShutdown() + }) + source.resume() + signalSources.append(source) + } + + // dory-hv is a command-line helper rather than an app bundle, so LaunchServices cannot + // reliably activate it from Dory.app. Give the UI a narrow same-user signal that asks + // the helper itself to raise its hidden or covered display window. + signal(SIGUSR1, SIG_IGN) + let raiseSource = DispatchSource.makeSignalSource( + signal: SIGUSR1, + queue: signalQueue + ) + raiseSource.setEventHandler(handler: DesktopSignalEventRelay.makeHandler { + [weak self] in + guard let self else { return } + for window in self.windows { window.makeKeyAndOrderFront(nil) } + self.windows.first?.makeKey() + self.application.activate() + }) + raiseSource.resume() + signalSources.append(raiseSource) + } + + private nonisolated static func prepareGuest(configuration: Configuration) throws -> DoryAgentInfo { + let deadline = Date().addingTimeInterval(90) + var lastError: Error? + while Date() < deadline { + do { + let control = AgentControl(configuration: .init( + directSocketPath: configuration.agentSocketPath + )) + let info = try control.info() + let operationToken = DoryOperationIdentity.canonical( + configuration.operationID + ) + try requireSuccess(control.exec( + argv: [ + "/bin/sh", "-c", + "mkdir -p /run/dory && chmod 700 /run/dory && umask 077 && printf '%s\\n' \"$DORY_OPERATION_ID\" > /run/dory/operation-id", + ], + env: [DoryExecEnvironment( + key: "DORY_OPERATION_ID", + value: operationToken + )], + timeoutMs: 10_000, + outputLimitBytes: 16 * 1_024 + ), operation: "bind lifecycle operation") + if let display = configuration.resolvedDevices?.display { + guard let command = DoryVMMGuestDisplayScale.persistenceCommand( + scaleFactor: display.guestUIScaleFactor + ) else { + throw VMError.bootFailure( + "resolved guest UI scale is not supported by Dory Tools" + ) + } + try requireSuccess(control.exec( + argv: command, + timeoutMs: 10_000, + outputLimitBytes: 64 * 1_024 + ), operation: "persist guest UI scale") + } + var guestEnvironment = configuration.environment + guestEnvironment["DORY_OPERATION_ID"] = operationToken + try requireSuccess(control.exec( + argv: ["/usr/lib/dory/configure-machine"], + env: guestEnvironment.sorted(by: { $0.key < $1.key }).map { + DoryExecEnvironment(key: $0.key, value: $0.value) + }, + timeoutMs: 30_000, + outputLimitBytes: 64 * 1024 + ), operation: "guest account configuration") + for share in configuration.attachedShares { + _ = try control.virtioFSMount( + tag: share.tag, + mountPath: share.guestPath, + readOnly: share.readOnly + ) + } + try requireSuccess(control.exec( + argv: ["/usr/bin/touch", "/var/lib/dory/host-configured"], + timeoutMs: 10_000, + outputLimitBytes: 64 * 1024 + ), operation: "complete desktop configuration") + return info + } catch { + lastError = error + Thread.sleep(forTimeInterval: 0.25) + } + } + throw lastError ?? VMError.bootFailure("desktop agent did not become ready") + } + + private nonisolated static func requireSuccess(_ result: DoryExecResult, operation: String) throws { + guard !result.timedOut, result.exitCode == 0 else { + let stderr = String(decoding: result.stderr.prefix(4_096), as: UTF8.self) + throw VMError.bootFailure( + "\(operation) failed (exit=\(result.exitCode), timedOut=\(result.timedOut)): \(stderr)" + ) + } + } + + private static func waitForSocket(path: String, process: Process) throws { + for _ in 0..<100 { + if FileManager.default.fileExists(atPath: path) { return } + guard process.isRunning else { + throw VMError.bootFailure("gvproxy exited before publishing its network socket") + } + usleep(50_000) + } + throw VMError.bootFailure("gvproxy did not publish its network socket") + } + + private struct NetworkRuntime { + let process: Process? + let socketPaths: [String] + let backend: (any VirtioDeviceBackend)? + let portForwardReconciler: ResolvedPortForwardReconciler? + } + + private static func prepareNetwork( + plan: NetworkPlan, + networkInterface: DoryVirtualMachineNetworkInterfaceCapabilityRequest?, + gvproxyPath: String, + runtimeDirectory: String, + token: String, + resolvedPortForwards: [DoryVMPortForward]? + ) throws -> NetworkRuntime { + guard resolvedPortForwards == nil || networkInterface != nil else { + throw VMError.bootFailure( + "resolved port forwards require an exact resolved network device contract" + ) + } + let portIntents = resolvedPortForwards ?? [] + guard let portForwards = PublishedPortForwardPlan.resolvedForwards( + portIntents, + guestIP: "192.168.127.2" + ) else { + throw VMError.bootFailure("resolved port-forward contract is invalid") + } + if !portIntents.isEmpty, plan == .disconnected { + throw VMError.bootFailure("disconnected networking cannot publish host ports") + } + if plan != .sharedNAT, + portIntents.contains(where: { $0.exposure == .lan }) { + throw VMError.bootFailure("LAN port exposure requires shared NAT") + } + let resolvedMTU = networkInterface?.maximumTransmissionUnit + ?? UInt16(DoryNetworkMTU.resolved()) + if plan == .disconnected { + let mac = networkInterface?.macAddressOctets ?? VirtioNet.guestMAC + return NetworkRuntime( + process: nil, + socketPaths: [], + backend: VirtioDisconnectedNet( + macAddress: mac, + maximumTransmissionUnit: resolvedMTU + ), + portForwardReconciler: nil + ) + } + guard plan.startsGVProxy, plan.attachesNetworkDevice else { + return NetworkRuntime( + process: nil, + socketPaths: [], + backend: nil, + portForwardReconciler: nil + ) + } + let gvproxySocket = "\(runtimeDirectory)/\(token)-gv.sock" + let vmNetworkSocket = "\(runtimeDirectory)/\(token)-vm.sock" + let apiSocket = "\(runtimeDirectory)/\(token)-api.sock" + let configurationYAML: String? + if let networkInterface { + configurationYAML = GVProxyDesktopLaunchPlan.configurationYAML( + hostOnly: plan == .hostOnly, + guestMAC: networkInterface.macAddress + ) + } else { + configurationYAML = plan.gvproxyConfigurationYAML + } + let configurationPath = configurationYAML.map { _ in + "\(runtimeDirectory)/\(token)-network.yaml" + } + let socketPaths = [gvproxySocket, vmNetworkSocket, apiSocket] + + [configurationPath].compactMap { $0 } + for path in socketPaths { unlink(path) } + if let configurationPath, let yaml = configurationYAML { + try yaml.write(toFile: configurationPath, atomically: true, encoding: .utf8) + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: gvproxyPath) + process.arguments = GVProxyDesktopLaunchPlan.arguments( + mtu: Int(resolvedMTU), + datapathSocket: gvproxySocket, + apiSocket: apiSocket, + configurationPath: configurationPath + ) + process.standardOutput = FileHandle.standardError + process.standardError = FileHandle.standardError + do { + try process.run() + try waitForSocket(path: gvproxySocket, process: process) + try publishResolvedPortForwards(portForwards, apiSocket: apiSocket) + let reconciler = portForwards.isEmpty ? nil : ResolvedPortForwardReconciler( + desired: portForwards, + apiSocketPath: apiSocket, + log: Self.log + ) + if let reconciler, !reconciler.reconcileNow() { + throw VMError.bootFailure( + "could not verify the resolved gvproxy port-forward registry" + ) + } + reconciler?.start() + return NetworkRuntime( + process: process, + socketPaths: socketPaths, + backend: try VirtioNet( + socketPath: vmNetworkSocket, + remotePath: gvproxySocket, + macAddress: networkInterface?.macAddressOctets ?? VirtioNet.guestMAC, + maximumTransmissionUnit: resolvedMTU + ), + portForwardReconciler: reconciler + ) + } catch { + ChildProcessTerminator.terminateAndReap(process) + for path in socketPaths { unlink(path) } + throw error + } + } + + private static func publishResolvedPortForwards( + _ forwards: Set, + apiSocket: String + ) throws { + for forward in forwards.sorted(by: portForwardOrder) { + let bodyData = try JSONSerialization.data(withJSONObject: [ + "local": forward.localEndpoint, + "remote": forward.remoteEndpoint, + "protocol": forward.protocol.rawValue, + ]) + guard let body = String(data: bodyData, encoding: .utf8) else { + throw VMError.bootFailure("could not encode resolved gvproxy forward") + } + var published = false + for _ in 0..<100 { + let curl = Process() + curl.executableURL = URL(fileURLWithPath: "/usr/bin/curl") + curl.arguments = [ + "--fail", "--silent", "--show-error", + "--connect-timeout", "1", "--max-time", "1", + "--unix-socket", apiSocket, + "--request", "POST", + "--data-binary", body, + "http://gvproxy/services/forwarder/expose", + ] + curl.standardOutput = FileHandle.nullDevice + curl.standardError = FileHandle.nullDevice + if (try? curl.run()) != nil { + curl.waitUntilExit() + if curl.terminationStatus == 0 { + published = true + break + } + } + usleep(20_000) + } + guard published else { + throw VMError.bootFailure( + "gvproxy could not publish \(forward.localEndpoint)/\(forward.protocol.rawValue)" + ) + } + } + } + + private static func portForwardOrder( + _ lhs: PublishedPortForward, + _ rhs: PublishedPortForward + ) -> Bool { + if lhs.protocol != rhs.protocol { + return lhs.protocol.rawValue < rhs.protocol.rawValue + } + if lhs.localHost != rhs.localHost { return lhs.localHost < rhs.localHost } + return lhs.localPort < rhs.localPort + } + + private static func singletonMaterialization( + role: DoryVirtualDeviceRole, + authorizedDevices: [DoryRawHVVirtualDeviceRequest], + backend: any VirtioDeviceBackend + ) throws -> MaterializedVirtioBackend { + let matches = authorizedDevices.filter { $0.role == role } + guard matches.count == 1, let request = matches.first else { + throw VMError.invalidConfiguration( + "authorized RawHV materialization requires exactly one \(role.rawValue) function" + ) + } + return MaterializedVirtioBackend( + request: request, + backend: backend + ) + } + + @discardableResult + private static func attachBackend( + _ backend: any VirtioDeviceBackend, + to machine: Machine, + slot: Int + ) throws -> VirtioMMIOTransport { + let interrupt = GuestLayout.virtioFirstIRQ + UInt32(slot) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase + UInt64(slot) * GuestLayout.virtioSlotSize, + backend: backend, + memory: machine.memory + ) { [weak machine] in + machine?.raiseGSI(interrupt) + } + try machine.attachVirtioSlot(transport, at: slot) + return transport + } + + #if arch(arm64) + private static func attachPlatformDevices( + to machine: Machine, + serialOutput: BoundedSerialConsolePublisher + ) -> PL011 { + machine.bus.attach(PL031(baseAddress: GuestLayout.rtcBase)) + let uart = PL011( + baseAddress: GuestLayout.uartBase, + sink: { byte in + serialOutput.enqueue(byte) + }, + setInterrupt: { [weak machine] asserted in + machine?.setGSI(GuestLayout.uartIRQ, asserted: asserted) + } + ) + machine.attachConsole(uart) + return uart + } + #endif + + private static func resolveGraphics( + legacyBackend: DoryDesktopGraphicsBackend?, + exactLevel: DoryGraphicsAccelerationLevel?, + rendererWorkerLaunch: DesktopRendererWorkerLaunch? + ) throws -> ResolvedGraphics { + if let exactLevel { + guard legacyBackend == nil else { + throw VMError.bootFailure( + "resolved graphics cannot coexist with a legacy graphics selection" + ) + } + switch exactLevel { + case .hardwareAccelerated3D: + guard let rendererWorkerLaunch else { + throw VMError.bootFailure( + "resolved Venus launch is missing its authenticated renderer worker" + ) + } + return ResolvedGraphics( + backend: .virglVenus, + rendererWorkerLaunch: rendererWorkerLaunch + ) + case .hostAcceleratedDisplay: + guard rendererWorkerLaunch == nil else { + throw VMError.bootFailure( + "host-display graphics cannot carry renderer-worker authority" + ) + } + throw VMError.bootFailure( + "resolved host-accelerated display is not implemented by the RawHV Metal display contract" + ) + case .software: + guard rendererWorkerLaunch == nil else { + throw VMError.bootFailure( + "software graphics cannot carry renderer-worker authority" + ) + } + return ResolvedGraphics( + backend: .software, + rendererWorkerLaunch: nil + ) + case .none: + throw VMError.bootFailure( + "raw-HV desktop cannot satisfy a no-graphics resolved plan" + ) + } + } + + guard let legacyBackend else { + throw VMError.bootFailure("desktop launch is missing typed graphics authority") + } + guard rendererWorkerLaunch == nil else { + throw VMError.bootFailure( + "legacy graphics cannot carry renderer-worker authority" + ) + } + switch legacyBackend { + case .virgl: + throw VMError.bootFailure( + "legacy in-process VirGL desktop presentation was removed; " + + "launch with a resolved signed renderer worker or select software graphics" + ) + case .software: + return ResolvedGraphics( + backend: .software, + rendererWorkerLaunch: nil + ) + case .virglVenus: + throw VMError.bootFailure( + "legacy in-process VirGL2 + Venus desktop presentation was removed; " + + "launch with a resolved signed renderer worker or select software graphics" + ) + } + } + + private static func graphicsSelection( + configuration: Configuration, + resolvedBackend: DoryDesktopGraphicsBackend, + rendererWorkerLaunch: DesktopRendererWorkerLaunch? + ) throws -> DoryRuntimeGraphicsSelection? { + guard let exactLevel = configuration.resolvedGraphics else { + // Legacy software compatibility launches have no immutable plan generation. Their + // display can remain available for migration, but never becomes resolved evidence. + return nil + } + guard let planSHA256 = configuration.resolvedPlanSHA256, + let planRevision = configuration.resolvedPlanRevision, + planRevision > 0 else { + throw VMError.bootFailure( + "resolved graphics selection is missing plan-generation authority" + ) + } + let selection: DoryRuntimeGraphicsSelection + switch (exactLevel, resolvedBackend, rendererWorkerLaunch) { + case (.software, .software, nil): + selection = DoryRuntimeGraphicsSelection.resolvedSoftware( + operationID: configuration.operationID, + resolvedPlanSHA256: planSHA256, + planRevision: planRevision + ) + case let (.hardwareAccelerated3D, .virglVenus, launch?): + selection = DoryRuntimeGraphicsSelection( + operationID: DoryOperationIdentity.canonical( + configuration.operationID + ), + resolvedPlanSHA256: planSHA256, + planRevision: planRevision, + accelerationLevel: .hardwareAccelerated3D, + backend: .virglVenus, + rendererGeneration: launch.workerGeneration.rawValue, + rendererWorkerReceiptSHA256: + launch.rendererWorkerReceiptSHA256, + guestProducerFenceProofSHA256: + launch.qualifiedProducerFenceAuthoritySHA256 + ) + default: + throw VMError.bootFailure( + "resolved graphics selection lacks its exact renderer authority" + ) + } + guard selection.isValid else { + throw VMError.bootFailure("resolved software graphics selection is invalid") + } + return selection + } + + private static func kernelCommandLine( + machineID: String, + operationID: UUID, + rootDevice: String, + graphicsBackend: DoryDesktopGraphicsBackend, + genericGuest: Bool + ) -> String { + var arguments = [ + "console=ttyAMA0", + "earlycon=pl011,mmio32,0x0c000000", + "root=\(rootDevice)", + "rw", + "rootwait", + "panic=1", + "dory.machine_id=\(machineID)", + "dory.operation_id=\(DoryOperationIdentity.canonical(operationID))", + graphicsBackend.kernelArgument, + ] + if genericGuest { + // Installer initramfs images commonly default to their live-media boot path + // (Ubuntu's casper is one example). The same kernel/initramfs can boot the + // installed root directly when the local-root path is selected explicitly. + arguments.append("boot=local") + // Direct-kernel boot does not consume the EFI System Partition. Installer + // kernels can omit optional FAT/NLS modules that a distro's installed fstab + // expects for /boot/efi, so keep that nonessential mount out of the boot + // transaction without modifying the guest filesystem. + arguments.append("systemd.mask=boot-efi.mount") + } + return arguments.joined(separator: " ") + } + + private static func openAppendLog(_ path: String) throws -> FileHandle { + if !FileManager.default.fileExists(atPath: path) { + guard FileManager.default.createFile(atPath: path, contents: nil) else { + throw VMError.bootFailure("could not create serial log: \(path)") + } + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path) + } + let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: path)) + try handle.seekToEnd() + return handle + } + + private static func appendBootSessionMarker( + to log: FileHandle, + machineID: String, + operationID: UUID + ) throws { + let marker = "\n--- DORY BOOT \(Date().formatted(.iso8601)) machine=\(machineID) operation=\(DoryOperationIdentity.canonical(operationID)) runtime=raw-hv-desktop ---\n" + try log.write(contentsOf: Data(marker.utf8)) + try log.synchronize() + } + + private static func error(for reason: GuestStopReason) -> Error? { + switch reason { + case .powerOff: nil + case .reset: VMError.unexpectedExit("desktop guest requested reset") + case let .crash(detail): VMError.unexpectedExit(detail) + } + } + + private nonisolated static func log(_ message: String) { + FileHandle.standardError.write(Data("dory-hv desktop: \(message)\n".utf8)) + } + } +} + +private final class FirstFrameGate: @unchecked Sendable { + private let condition = NSCondition() + private let requiredScanoutCount: Int + private var readyScanoutIDs = Set() + + init(requiredScanoutCount: Int = 1) { + self.requiredScanoutCount = max(1, requiredScanoutCount) + } + + func signal(scanoutID: UInt32 = 0) { + condition.lock() + readyScanoutIDs.insert(scanoutID) + condition.broadcast() + condition.unlock() + } + + func wait(timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + condition.lock() + while readyScanoutIDs.count < requiredScanoutCount { + if !condition.wait(until: deadline) { break } + } + let result = readyScanoutIDs.count >= requiredScanoutCount + condition.unlock() + return result + } +} + +private extension UInt64 { + func addingClamped(_ other: UInt64) -> UInt64 { + let (result, overflow) = addingReportingOverflow(other) + return overflow ? .max : result + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopRendererRuntimeFailure.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopRendererRuntimeFailure.swift new file mode 100644 index 00000000..05cfe354 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopRendererRuntimeFailure.swift @@ -0,0 +1,55 @@ +import DoryOperations +import Foundation + +enum DesktopRendererRuntimeFailureKind: String, Equatable, Sendable { + case worker = "renderer-worker" + case metalDevice = "metal-device" + case gpuQuiescence = "gpu-quiescence" +} + +/// Candidate-scoped failure emitted only after hardware acceleration admitted an exact renderer +/// worker. The type is the local classification boundary consumed by `main.swift`; ordinary guest +/// and readiness errors deliberately never become this type. +struct DesktopRendererRuntimeFailure: Error, Equatable, Sendable, CustomStringConvertible { + let kind: DesktopRendererRuntimeFailureKind + let reason: String + + var description: String { "\(kind.rawValue): \(reason)" } +} + +/// Worker and Metal callbacks arrive on different queues. Preserve the first candidate-scoped +/// failure so a secondary teardown fault cannot replace the cause that actually stopped the VM. +final class DesktopRendererRuntimeFailureLatch: @unchecked Sendable { + private let lock = NSLock() + private var storedFailure: DesktopRendererRuntimeFailure? + + func record(kind: DesktopRendererRuntimeFailureKind, reason: String) { + lock.withLock { + guard storedFailure == nil else { return } + storedFailure = DesktopRendererRuntimeFailure(kind: kind, reason: reason) + } + } + + var failure: DesktopRendererRuntimeFailure? { + lock.withLock { storedFailure } + } +} + +func desktopHelperExitStatus(for error: any Error) -> DoryDesktopHelperExitStatus { + error is DesktopRendererRuntimeFailure + ? .rendererCandidateFailure + : .generalFailure +} + +func desktopGPUShutdownFailure( + _ result: DesktopGPUShutdownBoundaryResult, + rendererFailureLatch: DesktopRendererRuntimeFailureLatch? +) -> (any Error)? { + guard let genericFailure = result.failure else { return nil } + guard let rendererFailureLatch else { return genericFailure } + rendererFailureLatch.record( + kind: .gpuQuiescence, + reason: result.logDescription + ) + return rendererFailureLatch.failure ?? genericFailure +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/DesktopRendererWorkerLaunch.swift b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopRendererWorkerLaunch.swift new file mode 100644 index 00000000..4b7604a0 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/DesktopRendererWorkerLaunch.swift @@ -0,0 +1,409 @@ +import CryptoKit +import Darwin +import DoryHV +import DoryOperations +import DoryRendererWorkerContracts +import DorydKit +import Foundation + +enum DesktopRendererWorkerLaunchError: Error, Equatable, CustomStringConvertible { + case unexpectedBootstrapAuthority + case missingBootstrapAuthority + case invalidBootstrapDescriptor + case invalidBootstrapObject + case bootstrapReadFailed + case bootstrapChangedDuringRead + case bootstrapDigestMismatch + case managedKernelDigestMismatch + case bootstrapQualificationUnavailable + case bootstrapQualificationMismatch + case synchronizedPresentationTimedOut + case synchronizedPresentationFailed(String) + + var description: String { + switch self { + case .unexpectedBootstrapAuthority: + "renderer bootstrap authority is forbidden for this graphics level" + case .missingBootstrapAuthority: + "hardware-accelerated 3D is missing its renderer bootstrap authority" + case .invalidBootstrapDescriptor: + "renderer bootstrap did not arrive in the fixed inherited descriptor slot" + case .invalidBootstrapObject: + "renderer bootstrap is not the exact private anonymous read-only object" + case .bootstrapReadFailed: + "renderer bootstrap changed or ended during its exact descriptor read" + case .bootstrapChangedDuringRead: + "renderer bootstrap object identity changed during its exact descriptor read" + case .bootstrapDigestMismatch: + "renderer bootstrap failed exact SHA-256 validation" + case .managedKernelDigestMismatch: + "renderer producer-fence authority does not bind the admitted guest kernel" + case .bootstrapQualificationUnavailable: + "packaged renderer bootstrap qualification is unavailable or untrusted" + case .bootstrapQualificationMismatch: + "live renderer capabilities do not match the packaged bootstrap qualification" + case .synchronizedPresentationTimedOut: + "the guest did not complete a synchronized worker-backed Metal presentation" + case .synchronizedPresentationFailed(let reason): + "synchronized worker-backed Metal presentation failed: \(reason)" + } + } +} + +/// Thread-safe one-shot bridge between Metal's completion callback and the background readiness +/// publisher. It is deliberately separate from immutable launch receipts so tests and production +/// cannot accidentally satisfy live readiness with a digest alone. +final class DesktopRendererWorkerLiveReadinessGate: @unchecked Sendable { + private enum State { + case waiting + case presented + case published + case failed(String) + } + + private let expectedWorkerGeneration: UInt64 + private let condition = NSCondition() + private var state: State = .waiting + + init(expectedWorkerGeneration: UInt64) { + precondition(expectedWorkerGeneration != 0) + self.expectedWorkerGeneration = expectedWorkerGeneration + } + + func record(workerGeneration receivedGeneration: UInt64) { + condition.lock() + defer { condition.unlock() } + guard case .waiting = state else { return } + guard receivedGeneration == expectedWorkerGeneration else { + state = .failed( + "worker generation drift (expected \(expectedWorkerGeneration), got " + + "\(receivedGeneration))" + ) + condition.broadcast() + return + } + state = .presented + condition.broadcast() + } + + func fail(_ reason: String) { + condition.lock() + defer { condition.unlock() } + switch state { + case .waiting, .presented: + state = .failed(reason) + condition.broadcast() + case .published, .failed: + return + } + } + + func wait(timeout: TimeInterval) throws { + let deadline = Date().addingTimeInterval(max(0, timeout)) + condition.lock() + defer { condition.unlock() } + while case .waiting = state { + guard condition.wait(until: deadline) else { + state = .failed("presentation deadline expired") + throw DesktopRendererWorkerLaunchError.synchronizedPresentationTimedOut + } + } + switch state { + case .presented, .published: + return + case .failed(let reason): + throw DesktopRendererWorkerLaunchError.synchronizedPresentationFailed(reason) + case .waiting: + preconditionFailure("renderer readiness wait escaped while still waiting") + } + } + + /// Linearizes the live frame edge with handoff publication. A worker failure that wins this + /// lock prevents publication; once this transition wins, the synchronized frame was live at + /// the exact logical readiness boundary and any later failure tears the running VM down. + func claimForPublication() throws { + condition.lock() + defer { condition.unlock() } + switch state { + case .presented: + state = .published + case .published: + return + case .failed(let reason): + throw DesktopRendererWorkerLaunchError.synchronizedPresentationFailed(reason) + case .waiting: + throw DesktopRendererWorkerLaunchError.synchronizedPresentationFailed( + "no synchronized presentation is ready for publication" + ) + } + } +} + +/// One authenticated renderer generation prepared before any machine or vCPU starts. +/// +/// The two SHA-256 values are durable launch-authority evidence. They do not claim that a frame +/// reached the display. `waitForFirstSynchronizedPresentation` is the separate live gate that +/// closes only after the worker producer fence and Metal command buffer both complete. +final class DesktopRendererWorkerLaunch: @unchecked Sendable { + typealias Connector = @Sendable (Data) async throws -> DoryRendererWorkerBroker + typealias QualificationProvider = @Sendable () throws + -> DoryVerifiedRendererBootstrapQualification + + static let initialDeviceGeneration: UInt64 = 1 + static let bootstrapReadByteCount = DoryRendererWorkerBootstrapCodec.fixedByteCount + + let broker: DoryRendererWorkerBroker + let commandLane: DoryRendererWorkerVirtioCommandLane + let workerGeneration: DoryRendererWorkerGeneration + let rendererWorkerReceiptSHA256: String + let qualifiedProducerFenceAuthoritySHA256: String + + private let readinessGate: DesktopRendererWorkerLiveReadinessGate + private let teardownLock = NSLock() + private var tornDown = false + + private init( + broker: DoryRendererWorkerBroker, + commandLane: DoryRendererWorkerVirtioCommandLane, + rendererWorkerReceiptSHA256: String, + qualifiedProducerFenceAuthoritySHA256: String + ) { + self.broker = broker + self.commandLane = commandLane + self.workerGeneration = broker.bootstrap.generation + self.readinessGate = DesktopRendererWorkerLiveReadinessGate( + expectedWorkerGeneration: broker.bootstrap.generation.rawValue + ) + self.rendererWorkerReceiptSHA256 = rendererWorkerReceiptSHA256 + self.qualifiedProducerFenceAuthoritySHA256 = + qualifiedProducerFenceAuthoritySHA256 + } + + deinit { + teardown(reason: "renderer launch authority released") + } + + /// Consumes FD6 only for hardware-accelerated 3D. Other resolved graphics levels must not + /// carry or start a renderer worker, even if a stale daemon accidentally leaves the slot open. + static func prepare( + resolvedGraphics: DoryGraphicsAccelerationLevel?, + rendererBootstrapAuthority: RuntimeLaunchEnvelope.InheritedFileDescriptorSlot?, + exactManagedKernelSHA256: String?, + connector: @escaping Connector = { bytes in + try await DoryRendererWorkerBroker.connect(exactBootstrapBytes: bytes) + }, + qualificationProvider: @escaping QualificationProvider = { + try DoryVerifiedRendererBootstrapQualification + .loadRuntimeCandidate() + } + ) async throws -> DesktopRendererWorkerLaunch? { + guard resolvedGraphics == .hardwareAccelerated3D else { + guard rendererBootstrapAuthority == nil else { + if let descriptor = rendererBootstrapAuthority?.descriptor, descriptor >= 3 { + Darwin.close(descriptor) + } + throw DesktopRendererWorkerLaunchError.unexpectedBootstrapAuthority + } + return nil + } + guard let authority = rendererBootstrapAuthority, + let exactManagedKernelSHA256 else { + throw DesktopRendererWorkerLaunchError.missingBootstrapAuthority + } + + let exactBytes = try readAndConsumeBootstrap(authority) + let bootstrap = try DoryRendererWorkerBootstrapCodec.decode(exactBytes) + guard hexadecimal(bootstrap.artifacts.managedGuestKernel.bytes) + == exactManagedKernelSHA256 else { + throw DesktopRendererWorkerLaunchError.managedKernelDigestMismatch + } + + let broker = try await connector(exactBytes) + do { + guard broker.bootstrap == bootstrap, + broker.capabilityReceipt.productionAccelerationIsAdmissible, + broker.capabilityReceipt.producerFenceContract + == bootstrap.producerFenceContract else { + throw DoryRendererWorkerBrokerError.incompleteCapabilityReceipt + } + let qualification: DoryVerifiedRendererBootstrapQualification + do { + qualification = try qualificationProvider() + } catch { + throw DesktopRendererWorkerLaunchError + .bootstrapQualificationUnavailable + } + try verifyLiveQualification( + bootstrap: bootstrap, + receipt: broker.capabilityReceipt, + qualification: qualification + ) + let lane = try DoryRendererWorkerVirtioCommandLane( + broker: broker, + deviceGeneration: initialDeviceGeneration + ) + let receiptBytes = DoryRendererCapabilityReceiptCodec.encode( + broker.capabilityReceipt + ) + return DesktopRendererWorkerLaunch( + broker: broker, + commandLane: lane, + rendererWorkerReceiptSHA256: sha256(receiptBytes), + qualifiedProducerFenceAuthoritySHA256: + qualifiedProducerFenceAuthoritySHA256(for: bootstrap) + ) + } catch { + await broker.invalidate() + throw error + } + } + + /// A build-time bootstrap is not permission to skip live initialization. The runner compares + /// every stable capability fact returned by this new worker generation before it creates the + /// virtio command lane. Workspace and generation remain live-only and are already bound by + /// `DoryRendererCapabilityReceiptCodec.decode(accepting:)`; the managed kernel is also compared + /// with the packaged candidate receipt because it supplies producer-fence authority. + static func verifyLiveQualification( + bootstrap: DoryRendererWorkerBootstrap, + receipt: DoryRendererCapabilityReceipt, + qualification: DoryVerifiedRendererBootstrapQualification + ) throws { + guard qualification.authorizes( + bootstrap: bootstrap, + liveReceipt: receipt + ) else { + throw DesktopRendererWorkerLaunchError + .bootstrapQualificationMismatch + } + } + + /// Called only by the Metal command-buffer completion edge. The worker core publishes an + /// update only after its producer fence signals, so this exact-generation signal proves both + /// halves of the live synchronized presentation boundary. + func recordSynchronizedPresentation(workerGeneration receivedGeneration: UInt64) { + readinessGate.record(workerGeneration: receivedGeneration) + } + + func failSynchronizedPresentation(_ reason: String) { + readinessGate.fail(reason) + } + + func waitForFirstSynchronizedPresentation(timeout: TimeInterval) throws { + try readinessGate.wait(timeout: timeout) + } + + func claimSynchronizedPresentationForPublication() throws { + try readinessGate.claimForPublication() + } + + func teardown(reason: String = "renderer launch teardown") { + let shouldTearDown = teardownLock.withLock { () -> Bool in + guard !tornDown else { return false } + tornDown = true + return true + } + guard shouldTearDown else { return } + failSynchronizedPresentation(reason) + commandLane.invalidate(deviceGeneration: Self.initialDeviceGeneration) + Task { await broker.invalidate() } + } + + static func qualifiedProducerFenceAuthoritySHA256( + for bootstrap: DoryRendererWorkerBootstrap + ) -> String { + var authority = Data("dory.renderer.qualified-producer-fence-authority.v1\0".utf8) + var contract = bootstrap.producerFenceContract.rawValue.littleEndian + withUnsafeBytes(of: &contract) { authority.append(contentsOf: $0) } + authority.append(bootstrap.artifacts.managedGuestKernel.bytes) + return sha256(authority) + } + + /// The descriptor parameter exists so the exact object reader can be exercised without + /// stealing process-global FD6 in a parallel test runner. Production never supplies it and + /// therefore always requires RuntimeLaunchEnvelope.rendererBootstrapDescriptor. + static func readAndConsumeBootstrap( + _ authority: RuntimeLaunchEnvelope.InheritedFileDescriptorSlot, + requiredDescriptor: Int32 = RuntimeLaunchEnvelope.rendererBootstrapDescriptor + ) throws -> Data { + let descriptor = authority.descriptor + guard descriptor >= 3 else { + throw DesktopRendererWorkerLaunchError.invalidBootstrapDescriptor + } + defer { Darwin.close(descriptor) } + guard authority.name == RuntimeLaunchEnvelope.rendererBootstrapSlotName, + descriptor == requiredDescriptor, + authority.access == .readOnly, + authority.byteCount == UInt64(bootstrapReadByteCount), + authority.logicalDeviceID == nil, + let expectedSHA256 = authority.contentSHA256, + isLowercaseSHA256(expectedSHA256) else { + throw DesktopRendererWorkerLaunchError.invalidBootstrapDescriptor + } + + let accessFlags = fcntl(descriptor, F_GETFL) + var before = stat() + guard accessFlags >= 0, + accessFlags & O_ACCMODE == O_RDONLY, + fstat(descriptor, &before) == 0, + before.st_mode & S_IFMT == S_IFREG, + before.st_uid == geteuid(), + before.st_nlink == 0, + before.st_size == off_t(bootstrapReadByteCount), + before.st_mode & 0o077 == 0 else { + throw DesktopRendererWorkerLaunchError.invalidBootstrapObject + } + + var data = Data(count: bootstrapReadByteCount) + try data.withUnsafeMutableBytes { raw in + guard let base = raw.baseAddress else { + throw DesktopRendererWorkerLaunchError.bootstrapReadFailed + } + var offset = 0 + while offset < raw.count { + let result = pread( + descriptor, + base.advanced(by: offset), + raw.count - offset, + off_t(offset) + ) + if result > 0 { + offset += result + } else if result < 0, errno == EINTR { + continue + } else { + throw DesktopRendererWorkerLaunchError.bootstrapReadFailed + } + } + } + + var after = stat() + guard fstat(descriptor, &after) == 0, + before.st_dev == after.st_dev, + before.st_ino == after.st_ino, + before.st_size == after.st_size, + before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, + before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec, + before.st_ctimespec.tv_sec == after.st_ctimespec.tv_sec, + before.st_ctimespec.tv_nsec == after.st_ctimespec.tv_nsec else { + throw DesktopRendererWorkerLaunchError.bootstrapChangedDuringRead + } + guard sha256(data) == expectedSHA256 else { + throw DesktopRendererWorkerLaunchError.bootstrapDigestMismatch + } + return data + } + + private static func sha256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func hexadecimal(_ data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } + + private static func isLowercaseSHA256(_ value: String) -> Bool { + value.utf8.count == 64 && value.utf8.allSatisfy { + (48...57).contains($0) || (97...102).contains($0) + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift index 08db9c12..f6d787ea 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift @@ -1,5 +1,8 @@ +import Darwin import DoryCore +import DoryFSWorkerContracts import DoryHV +import DoryOperations import Foundation import Synchronization @@ -57,6 +60,44 @@ package final class PortReconcileSignalRegistration: @unchecked Sendable { } } +/// Owns both host-to-vsock listeners for the complete engine run. These bridges remove their Unix +/// sockets in `deinit`; constructing either as an attach-only temporary silently retires the +/// Docker inventory path while the separate doryd dataplane remains healthy. +package final class EngineVsockBridgeLifetime: @unchecked Sendable { + private let dockerBridge: DockerSocketBridge + private let agentBridge: AgentVsockForward? + + package init( + dockerSocketPath: String, + agentSocketPath: String?, + guestCID: UInt32 = 3, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { + dockerBridge = DockerSocketBridge(socketPath: dockerSocketPath, log: log) + agentBridge = agentSocketPath.map { + AgentVsockForward( + socketPath: $0, + guestCID: guestCID, + log: log + ) + } + } + + package func attach(to vsock: VirtioVsock) throws { + try agentBridge?.attach(to: vsock) + try dockerBridge.attach(to: vsock) + } + + package func stop() { + dockerBridge.stop() + agentBridge?.stop() + } + + deinit { + stop() + } +} + /// `dory-hv engine`: the production mode SharedVMProvisioner spawns. Owns the full lifecycle: /// pulls docker:dind once, boots the VMM with networking, and publishes the Docker API at the /// unix socket the app already consumes. @@ -65,6 +106,157 @@ package final class PortReconcileSignalRegistration: @unchecked Sendable { /// boot so system state can never rot; DOCKER STATE lives on a separate journaled ext4 mounted /// at /var/lib/docker, so images, containers, and volumes survive restarts and unclean exits. enum EngineMode { + static let inheritedDockerDataDiskFileDescriptor: Int32 = + DockerDataDiskLaunchContract.childFileDescriptor + + enum DockerDataDiskAuthority: Equatable, Sendable { + /// Production authority inherited from doryd. The helper must use this exact descriptor and + /// must not resolve the metadata pathname as a fallback. + case inherited(fileDescriptor: Int32, expectedFilesystemUUID: UUID) + /// Explicit developer compatibility for launching dory-hv without the daemon's descriptor + /// handoff. This is deliberately reachable only through the legacy --data-disk argument. + case standalonePath(String) + } + + enum DockerDataDiskLaunchSelection: Equatable, Sendable { + case inherited( + fileDescriptor: Int32, + expectedFilesystemUUID: UUID, + dataDriveArgument: String + ) + case standaloneDataDrive(String) + case standalonePath(String) + } + + enum DockerDataDiskArgumentError: Error, Equatable, CustomStringConvertible { + case duplicate(String) + case invalidLegacyPath + case invalidDataDrive + case invalidFileDescriptor(String) + case invalidFilesystemUUID(String) + case conflictingAuthorities + case missingFileDescriptor + case missingFilesystemUUID + case missingDataDrive + case missingAuthority + + var description: String { + switch self { + case .duplicate(let argument): + "\(argument) may only be specified once" + case .invalidLegacyPath: + "--data-disk requires a non-empty absolute path" + case .invalidDataDrive: + "--data-drive requires a non-empty absolute .dorydrive path" + case .invalidFileDescriptor(let value): + "--docker-data-disk-fd requires inherited supervisor descriptor \(EngineMode.inheritedDockerDataDiskFileDescriptor), got \(value)" + case .invalidFilesystemUUID(let value): + "--docker-data-disk-uuid requires a canonical lowercase UUID, got \(value)" + case .conflictingAuthorities: + "--data-disk cannot be combined with --data-drive or inherited Docker data-disk authority" + case .missingFileDescriptor: + "--docker-data-disk-uuid requires --docker-data-disk-fd" + case .missingFilesystemUUID: + "--docker-data-disk-fd requires --docker-data-disk-uuid" + case .missingDataDrive: + "inherited Docker data-disk authority requires exactly one --data-drive" + case .missingAuthority: + "engine requires inherited Docker data-disk authority with --data-drive, or explicit standalone --data-drive/--data-disk" + } + } + } + + struct DockerDataDiskArguments { + private(set) var legacyPath: String? + private(set) var dataDriveArgument: String? + private(set) var inheritedFileDescriptor: Int32? + private(set) var expectedFilesystemUUID: UUID? + + mutating func setLegacyPath(_ value: String) throws { + guard legacyPath == nil else { + throw DockerDataDiskArgumentError.duplicate("--data-disk") + } + guard !value.isEmpty, value.hasPrefix("/") else { + throw DockerDataDiskArgumentError.invalidLegacyPath + } + legacyPath = value + } + + mutating func setDataDrive(_ value: String) throws { + guard dataDriveArgument == nil else { + throw DockerDataDiskArgumentError.duplicate("--data-drive") + } + guard !value.isEmpty, value.hasPrefix("/") else { + throw DockerDataDiskArgumentError.invalidDataDrive + } + dataDriveArgument = value + } + + mutating func setInheritedFileDescriptor(_ value: String) throws { + guard inheritedFileDescriptor == nil else { + throw DockerDataDiskArgumentError.duplicate( + DockerDataDiskLaunchContract.fileDescriptorArgument + ) + } + guard let descriptor = Int32(value), + descriptor == EngineMode.inheritedDockerDataDiskFileDescriptor else { + throw DockerDataDiskArgumentError.invalidFileDescriptor(value) + } + inheritedFileDescriptor = descriptor + } + + mutating func setExpectedFilesystemUUID(_ value: String) throws { + guard expectedFilesystemUUID == nil else { + throw DockerDataDiskArgumentError.duplicate( + DockerDataDiskLaunchContract.filesystemUUIDArgument + ) + } + guard value.utf8.count == 36, + value == value.lowercased(), + let identifier = UUID(uuidString: value), + identifier.uuidString.lowercased() == value else { + throw DockerDataDiskArgumentError.invalidFilesystemUUID(value) + } + expectedFilesystemUUID = identifier + } + + func resolvedSelection() throws -> DockerDataDiskLaunchSelection { + if legacyPath != nil, + dataDriveArgument != nil + || inheritedFileDescriptor != nil + || expectedFilesystemUUID != nil { + throw DockerDataDiskArgumentError.conflictingAuthorities + } + switch ( + inheritedFileDescriptor, + expectedFilesystemUUID, + dataDriveArgument, + legacyPath + ) { + case let (.some(descriptor), .some(identifier), .some(dataDrive), nil): + return .inherited( + fileDescriptor: descriptor, + expectedFilesystemUUID: identifier, + dataDriveArgument: dataDrive + ) + case (.none, .some, _, nil): + throw DockerDataDiskArgumentError.missingFileDescriptor + case (.some, .none, _, nil): + throw DockerDataDiskArgumentError.missingFilesystemUUID + case (.some, .some, .none, nil): + throw DockerDataDiskArgumentError.missingDataDrive + case let (.none, .none, .some(dataDrive), nil): + return .standaloneDataDrive(dataDrive) + case let (.none, .none, .none, .some(path)): + return .standalonePath(path) + case (.none, .none, .none, .none): + throw DockerDataDiskArgumentError.missingAuthority + default: + throw DockerDataDiskArgumentError.conflictingAuthorities + } + } + } + struct Configuration { var engineSocket: String var kernelPath: String @@ -72,16 +264,25 @@ enum EngineMode { var memoryMB: UInt64 var cpus: Int var stateDirectory: String - /// Durable user-data drive path. Runtime sockets/rootfs clones stay in stateDirectory. - var dockerDataDiskPath: String? - /// Canonical root of the managed data drive, when one owns dockerDataDiskPath. + /// Exact production descriptor authority or an explicit standalone developer path. + var dockerDataDiskAuthority: DockerDataDiskAuthority + /// Canonical root of the managed data drive, when one owns the inherited descriptor. var dataDriveRoot: String? + /// The drive's Docker disk path is metadata only in production descriptor mode. It may be + /// compared with an explicit legacy path, but must never be opened as a descriptor fallback. + var dataDriveDiskPath: String? /// Offline builds pass a decompressed engine rootfs here so first launch needs no network; /// online builds leave it nil and the engine fetches the image once. var bundledRootfs: String? var shares: [VirtioFSShareConfiguration] = [] var directIP: DirectIPBridgeConfiguration? var gpuMode: GPUAccelerationMode = .off + /// Launch-plan policy. The helper never reads ambient process environment to select guest + /// reclaim behavior. + var reclaimPolicy: ReclaimPolicy = .dropCaches + /// Explicit queue policy supplied by the daemon. Automatic derives a bounded value from + /// the admitted vCPU count; fixed values are validated when the command line is parsed. + var fuseRequestQueuePolicy: FuseRequestQueuePolicy = .automatic /// Register FEX's seccomp-correct binfmt handler and Dory OCI runtime so /// `--platform linux/amd64` images run on the arm64 engine. var amd64Emulation: Bool = false @@ -97,11 +298,52 @@ enum EngineMode { var guestAgentPath: String? } + static func validateMemoryMB(_ memoryMB: UInt64) throws { + guard memoryMB <= UInt64(DoryEngineMemoryPolicy.maximumMemoryMB) else { + throw VMError.invalidConfiguration( + "engine RAM must not exceed \(DoryEngineMemoryPolicy.maximumMemoryMB) MiB: " + + "ARM guest RAM begins at 2 GiB and must end within the 64-GiB " + + "Hypervisor.framework guest-physical aperture" + ) + } + } + enum GPUAccelerationMode: String { case off case venus } + enum ReclaimPolicy: String, Equatable, Sendable { + case dropCaches = "drop-caches" + case senpai + } + + enum FuseRequestQueuePolicy: Equatable, Sendable { + case automatic + case fixed(Int) + + static let maximum = 8 + + init(fixedCount: Int) throws { + guard (1...Self.maximum).contains(fixedCount) else { + throw VMError.invalidConfiguration( + "VirtioFS request queue count must be between 1 and \(Self.maximum)" + ) + } + self = .fixed(fixedCount) + } + + func resolved(cpuCount: Int) -> Int { + switch self { + case .automatic: + return min(Self.maximum, max(1, cpuCount)) + case .fixed(let count): + precondition((1...Self.maximum).contains(count)) + return count + } + } + } + /// gvproxy is launched and stopped under the same lock so a shutdown signal cannot race the /// post-spawn registration window or run cleanup twice. The forced-exit watchdog must call the /// cleanup directly because `exit` does not unwind Swift `defer` blocks. @@ -162,10 +404,6 @@ enum EngineMode { } } - static var reclaimModeIsSenpai: Bool { - (ProcessInfo.processInfo.environment["DORY_ENGINE_RECLAIM_MODE"]?.lowercased() ?? "dropcaches") == "senpai" - } - // P1.2 host-pressure tier: when macOS reports memory pressure, ping the guest's reclaim listener so // it hands memory back exactly when the host needs it (the free-page-reporting moat, on demand). // Mirrors the shutdown channel: a forwarded unix socket → guest tcp 2378. Gated to senpai mode. @@ -263,10 +501,13 @@ enum EngineMode { reason: String, now: @escaping @Sendable () -> Date = Date.init ) async { - let connection = vsock.connect(port: VsockPorts.agent) - let channel = AgentChannel(connection: connection) let hostEpochNanoseconds = Int64((now().timeIntervalSince1970 * 1_000_000_000).rounded()) do { + let connection = try vsock.connectForServiceIfCapacity( + port: VsockPorts.agent, + service: .agentRPC + ) + let channel = AgentChannel(connection: connection) let result = try await channel.syncClock(hostEpochNanoseconds: hostEpochNanoseconds) note("clock sync \(reason): \(result.synced ? "ok" : "agent declined")") } catch { @@ -319,6 +560,7 @@ enum EngineMode { var guardState = GVProxyDatapathGuard(failureThreshold: 3) var reportedInconclusive = false + var reportedAwaitingReadiness = false while !Task.isCancelled { let canaryResponse = UnixSocketHTTPClient.get( socketPath: healthSocket, @@ -346,8 +588,16 @@ enum EngineMode { gvproxyCanaryReachable: canaryReachable, dockerAPIReachable: dockerReachable ) { - case .healthy, .restartAlreadyRequested: + case .healthy: + // A previous engine invocation may have left a failure receipt in this durable + // state directory. Once this invocation proves the canary, that receipt no + // longer describes the live engine. + try? FileManager.default.removeItem(atPath: diagnosticPath) + reportedInconclusive = false + reportedAwaitingReadiness = false + case .restartAlreadyRequested: reportedInconclusive = false + reportedAwaitingReadiness = false case .recovered(let previousFailures): persistGVProxyDiagnostic( path: diagnosticPath, @@ -359,6 +609,25 @@ enum EngineMode { ) note("gvproxy datapath canary recovered after \(previousFailures) failed probe(s)") reportedInconclusive = false + reportedAwaitingReadiness = false + case .awaitingReadiness: + // A canary that has never answered cannot prove that a previously working + // datapath stopped forwarding. Treat that as a startup/configuration fault, + // preserve the healthy Docker VM, and let the required dorycfg boot contract + // surface the underlying guest setup failure instead of entering a restart loop. + if !reportedAwaitingReadiness { + persistGVProxyDiagnostic( + path: diagnosticPath, + reason: "awaiting-canary-readiness", + consecutiveFailures: 0, + gvproxyPID: gvproxyPID, + apiSocket: apiSocket, + statistics: network.statistics + ) + note("gvproxy datapath canary has not completed its initial readiness probe; restart suppressed") + reportedAwaitingReadiness = true + } + reportedInconclusive = false case .inconclusive: // Do not blame gvproxy when the independent guest witness is unavailable. Log // once per inconclusive run and leave VM lifecycle to the Docker supervisor. @@ -366,8 +635,10 @@ enum EngineMode { note("gvproxy datapath probe inconclusive: Docker witness is unavailable; no restart requested") reportedInconclusive = true } + reportedAwaitingReadiness = false case .suspected(let failures): reportedInconclusive = false + reportedAwaitingReadiness = false persistGVProxyDiagnostic( path: diagnosticPath, reason: "suspected", @@ -452,7 +723,70 @@ enum EngineMode { var gvproxyStats: String? } - static func run(_ configuration: Configuration) async throws { + /// Runtime sockets share this directory with disposable boot state, so creating it with the + /// process umask is not sufficient: a normal 022 umask produces 0755 and the USB control + /// listener correctly refuses that boundary. Acquire the directory without following a final + /// symlink, verify ownership, and enforce the same 0700 invariant expected by every socket + /// publisher before any engine artifact or sidecar is created. + static func prepareStateDirectory(_ rawPath: String) throws -> String { + let path = URL(fileURLWithPath: rawPath).standardizedFileURL.path + if mkdir(path, mode_t(0o700)) != 0, errno != EEXIST { + let code = errno + throw VMError.invalidConfiguration( + "cannot create owner-private engine state directory \(path): errno \(code)" + ) + } + + let descriptor = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + guard descriptor >= 0 else { + let code = errno + throw VMError.invalidConfiguration( + "cannot open owner-private engine state directory \(path): errno \(code)" + ) + } + defer { close(descriptor) } + + var opened = stat() + guard fstat(descriptor, &opened) == 0, + opened.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR), + opened.st_uid == geteuid() else { + throw VMError.invalidConfiguration( + "engine state directory is not owned by the current user: \(path)" + ) + } + guard fchmod(descriptor, mode_t(0o700)) == 0 else { + let code = errno + throw VMError.invalidConfiguration( + "cannot secure engine state directory \(path): errno \(code)" + ) + } + + var secured = stat() + var linked = stat() + guard fstat(descriptor, &secured) == 0, + lstat(path, &linked) == 0, + secured.st_dev == opened.st_dev, + secured.st_ino == opened.st_ino, + linked.st_dev == secured.st_dev, + linked.st_ino == secured.st_ino, + linked.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR), + linked.st_uid == geteuid(), + linked.st_mode & mode_t(0o7777) == mode_t(0o700) else { + throw VMError.invalidConfiguration( + "engine state directory did not retain owner-private identity: \(path)" + ) + } + return path + } + + static func run(_ configuration: Configuration) throws { + try validateMemoryMB(configuration.memoryMB) + guard configuration.gpuMode == .off else { + throw VMError.invalidConfiguration( + "container-engine GPU acceleration is unavailable; the retired in-process " + + "VirGL loader is not a fallback for the isolated Linux desktop worker" + ) + } try DockerSocketBridge.validateSocketPath(configuration.engineSocket) if let forwardSocket = configuration.agentVsockForward { try AgentVsockForward.validateSocketPath(forwardSocket) @@ -466,29 +800,103 @@ enum EngineMode { configuration.directIP?.subnetCIDR ?? DoryIPv4BridgeNetwork.defaultCIDR ) let sourcePreservingLAN = configuration.publishHost == "0.0.0.0" - let state = URL(fileURLWithPath: configuration.stateDirectory).standardizedFileURL.path - try FileManager.default.createDirectory(atPath: state, withIntermediateDirectories: true) + let state = try prepareStateDirectory(configuration.stateDirectory) let stateDirectoryLock = try EngineStateDirectoryLock(stateDirectory: state) defer { withExtendedLifetime(stateDirectoryLock) {} } let pristineRootfs = state + "/rootfs-pristine.ext4" let bootRootfs = state + "/rootfs-boot.ext4" - let dataDisk = URL(fileURLWithPath: configuration.dockerDataDiskPath ?? (state + "/docker-data.ext4")) - .standardizedFileURL.path - let dataDiskDirectory = URL(fileURLWithPath: dataDisk).deletingLastPathComponent().path + let standaloneDataDiskPath: String? + switch configuration.dockerDataDiskAuthority { + case .inherited: + standaloneDataDiskPath = nil + case .standalonePath(let path): + standaloneDataDiskPath = URL(fileURLWithPath: path).standardizedFileURL.path + } let dataDriveLock: EngineStateDirectoryLock? - if let dataDriveRoot = configuration.dataDriveRoot { + if case .inherited = configuration.dockerDataDiskAuthority { + guard configuration.dataDriveRoot != nil, + configuration.dataDriveDiskPath != nil else { + throw VMError.invalidConfiguration( + "inherited Docker data-disk authority requires managed data-drive metadata" + ) + } + // doryd retains drive.lock across the complete managed helper generation. Reacquiring + // it here would deadlock the launch and would add no authority to the inherited FD. + dataDriveLock = nil + } else if let dataDriveRoot = configuration.dataDriveRoot { + guard let dataDriveDiskPath = configuration.dataDriveDiskPath, + dataDriveDiskPath == standaloneDataDiskPath else { + throw VMError.invalidConfiguration( + "standalone data-drive metadata does not match its Docker data-disk path" + ) + } dataDriveLock = try EngineStateDirectoryLock( stateDirectory: dataDriveRoot, lockFileName: "drive.lock" ) - } else if dataDiskDirectory != state { - dataDriveLock = try EngineStateDirectoryLock(stateDirectory: dataDiskDirectory) } else { - dataDriveLock = nil + guard configuration.dataDriveDiskPath == nil, + let standaloneDataDiskPath else { + throw VMError.invalidConfiguration( + "standalone Docker data-disk authority has inconsistent data-drive metadata" + ) + } + let dataDiskDirectory = URL(fileURLWithPath: standaloneDataDiskPath) + .deletingLastPathComponent().path + dataDriveLock = dataDiskDirectory == state + ? nil + : try EngineStateDirectoryLock(stateDirectory: dataDiskDirectory) } defer { withExtendedLifetime(dataDriveLock) {} } + let dataDiskBackend: VirtioBlk + let dataDiskState: DockerDataDiskAdmittedState + let expectedDockerDataDiskUUID: UUID? + switch configuration.dockerDataDiskAuthority { + case let .inherited(fileDescriptor, expectedFilesystemUUID): + dataDiskState = try DockerDataDisk.admittedState( + ofFileDescriptor: fileDescriptor, + description: "inherited Docker data disk descriptor \(fileDescriptor)", + minimumBytes: DockerDataDisk.blankDiskBytes, + maximumBytes: Int64(DockerDataDisk.maximumCapacityGiB) + * DockerDataDisk.bytesPerGiB + ) + dataDiskBackend = try VirtioBlk( + fileDescriptor: fileDescriptor, + identity: "dory-data" + ) + expectedDockerDataDiskUUID = expectedFilesystemUUID + case .standalonePath: + guard let standaloneDataDiskPath else { + throw VMError.invalidConfiguration( + "standalone Docker data-disk path was not resolved" + ) + } + let preparation = try DockerDataDisk.prepare(destination: standaloneDataDiskPath) + switch preparation { + case .alreadyPresent: + break + case .createdBlank: + note("first run: created standalone docker data disk") + } + switch preparation { + case .createdBlank: + dataDiskState = .sparseBlank + case .alreadyPresent: + // Path-based compatibility remains deliberately isolated from production launch. + dataDiskState = try DockerDataDisk.isExt4Image(at: standaloneDataDiskPath) + ? .ext4 + : .sparseBlank + } + dataDiskBackend = try VirtioBlk( + path: standaloneDataDiskPath, + identity: "dory-data" + ) + expectedDockerDataDiskUUID = nil + } + let allowDockerDataFormat = dataDiskState == .sparseBlank + // Both one-time artifacts are built at a temp path and atomically renamed into place, so an // interrupted first run leaves no half-written file that the fileExists guard would then // treat as complete forever. @@ -506,33 +914,36 @@ enum EngineMode { try? FileManager.default.removeItem(atPath: bootRootfs) try FileManager.default.copyItem(atPath: pristineRootfs, toPath: bootRootfs) - let dataDiskPreparation = try DockerDataDisk.prepare(destination: dataDisk) - switch dataDiskPreparation { - case .alreadyPresent: - break - case .createdBlank: - note("first run: created docker data disk") - } - let allowDockerDataFormat: Bool - switch dataDiskPreparation { - case .createdBlank: - allowDockerDataFormat = true - case .alreadyPresent: - // Host validation admits a non-ext4 existing file only when it has zero allocated - // blocks, which is a first-boot sparse blank left by an interrupted earlier launch. - allowDockerDataFormat = try !DockerDataDisk.isExt4Image(at: dataDisk) - } - let bootConfigShare = try writeBootConfiguration(stateDirectory: state, script: guestBootScript( shares: configuration.shares, - gpuMode: configuration.gpuMode, + reclaimPolicy: configuration.reclaimPolicy, amd64Emulation: configuration.amd64Emulation, nativeIPv6: nativeIPv6, bridgeNetwork: bridgeNetwork, sourcePreservingLAN: sourcePreservingLAN, - allowDockerDataFormat: allowDockerDataFormat + allowDockerDataFormat: allowDockerDataFormat, + expectedDockerDataDiskUUID: expectedDockerDataDiskUUID ), guestAgentPath: configuration.guestAgentPath) let guestLogShare = try guestLogShareConfiguration(stateDirectory: state) + let filesystemShares = [bootConfigShare, guestLogShare] + configuration.shares + // `IgnoreSelf` is worker-process scoped, so validate internal and user mounts together. + // An overlapping writable disabled mount could otherwise hide its mutations from a + // coherence-enabled alias in the same worker. + try VirtioFSShareConfiguration.validateWritableTopology(filesystemShares) + var coherencePolicyByTag: [String: DoryFSShareCoherencePolicy] = [ + bootConfigShare.tag: .disabled, + guestLogShare.tag: .disabled, + ] + for share in configuration.shares { + coherencePolicyByTag[share.tag] = share.readOnly + ? .invalidationOnly + : .invalidationAndWatcherNudge + } + let filesystemWorker = try DoryFilesystemWorkerLauncher.startBlocking( + shares: filesystemShares, + coherencePolicyByTag: coherencePolicyByTag + ) + defer { filesystemWorker.client.close() } let machine = try Machine(configuration: MachineConfiguration( kernelPath: configuration.kernelPath, @@ -540,62 +951,108 @@ enum EngineMode { memoryBytes: configuration.memoryMB << 20, cpuCount: configuration.cpus )) - attachPlatformDevices(to: machine) + let serialOutput = try BoundedSerialConsolePublisher(destinations: [ + .init(fileHandle: FileHandle.standardOutput), + ]) + defer { + let receipt = serialOutput.stop() + if !receipt.isClean { + note("serial publisher retired with faults: \(receipt.diagnosticSummary)") + } + } + attachPlatformDevices(to: machine, serialOutput: serialOutput) var backends: [VirtioDeviceBackend] = [] backends.append(try VirtioBlk(path: bootRootfs, identity: "dory-rootfs")) - backends.append(try VirtioBlk(path: dataDisk, identity: "dory-data")) + backends.append(dataDiskBackend) backends.append(VirtioRng()) backends.append(VirtioBalloon(memory: machine.memory) { note($0) }) - var daxSlot: UInt64 = 0 - if configuration.gpuMode == .venus { - let renderer = try VenusModeRequirement.require { - try VirglRenderer.discover() - } - let hostMemoryBase = GuestLayout.daxWindowBase + daxSlot * DaxWindow.defaultSize - let hostVisibleMemory = try VirtioGPUHostVisibleMemory(guestBase: hostMemoryBase) - daxSlot += 1 - backends.append(VirtioGPU( - hostMemoryBase: hostMemoryBase, - renderer: renderer, - hostVisibleMemory: hostVisibleMemory - )) - note( - "experimental gpu=venus: attached virtio-gpu with virglrenderer " - + "\(renderer.libraryPath) and MoltenVK ICD \(renderer.moltenVKICDPath)" - ) - } let vsock = VirtioVsock(guestCID: 3) + defer { _ = vsock.quiesce() } backends.append(vsock) - HostAIBridge(log: { note($0) }).attach(to: vsock) - sshAgentBridge?.attach(to: vsock) - let requestedFuseQueues = ProcessInfo.processInfo.environment["DORY_FUSE_QUEUES"] - .flatMap(Int.init) ?? configuration.cpus - let fuseRequestQueues = min(8, max(1, requestedFuseQueues)) - backends.append(try bootConfigShare.makeBackend(requestQueueCount: fuseRequestQueues)) - backends.append(try guestLogShare.makeBackend(requestQueueCount: fuseRequestQueues)) - var coherenceEndpoints = [HostShareCoherenceEndpoint]() + let hostAIBridge = HostAIBridge(log: { note($0) }) + defer { + sshAgentBridge?.stop() + hostAIBridge.stop() + } + try hostAIBridge.attach(to: vsock) + try sshAgentBridge?.attach(to: vsock) + let fuseRequestQueues = configuration.fuseRequestQueuePolicy.resolved( + cpuCount: configuration.cpus + ) + let workerLifecycle: @Sendable (VirtioFSWorkerLifecycleEvent) -> Void = { event in + note(event.diagnostic) + if case .failure(let reason) = event { + machine.requestStop(.crash(reason)) + } + } + let bootConfigBackend = try bootConfigShare.makeBackend( + broker: filesystemWorker.broker(for: bootConfigShare), + requestQueueCount: fuseRequestQueues, + onWorkerLifecycle: workerLifecycle + ) + let guestLogBackend = try guestLogShare.makeBackend( + broker: filesystemWorker.broker(for: guestLogShare), + requestQueueCount: fuseRequestQueues, + onWorkerLifecycle: workerLifecycle + ) + backends.append(bootConfigBackend) + backends.append(guestLogBackend) + var coherenceEndpoints = [ + try DoryHostShareCoherenceEndpoint( + capabilityID: filesystemWorker.capability(for: bootConfigShare), + backend: bootConfigBackend, + guestRoot: "/mnt/dory-config", + policy: .disabled + ), + try DoryHostShareCoherenceEndpoint( + capabilityID: filesystemWorker.capability(for: guestLogShare), + backend: guestLogBackend, + guestRoot: "/mnt/dory-logs", + policy: .disabled + ), + ] for share in configuration.shares { - let daxBase = share.dax ? GuestLayout.daxWindowBase + daxSlot * DaxWindow.defaultSize : nil - if share.dax { daxSlot += 1 } let backend = try share.makeBackend( - daxGuestBase: daxBase, - requestQueueCount: fuseRequestQueues + broker: filesystemWorker.broker(for: share), + requestQueueCount: fuseRequestQueues, + onWorkerLifecycle: workerLifecycle ) backends.append(backend) - // Read-only shares cannot accept the same-mode watcher nudge, but they still need host - // reverse invalidation so open-file page cache cannot stay stale. Keep metadata caching - // disabled and skip only the fsnotify approximation for those endpoints. - coherenceEndpoints.append(HostShareCoherenceEndpoint( - share: HostFSEventShare( - hostRoot: share.path, - guestRoot: share.guestMountPoint ?? "/mnt/dory/\(share.tag)" - ), + coherenceEndpoints.append(try DoryHostShareCoherenceEndpoint( + capabilityID: filesystemWorker.capability(for: share), backend: backend, - watcherNudgesEnabled: !share.readOnly + guestRoot: share.guestMountPoint ?? "/mnt/dory/\(share.tag)", + policy: coherencePolicyByTag[share.tag] ?? .disabled )) - note("sharing \(share.path) as virtiofs tag \(share.tag)\(share.readOnly ? " (ro)" : "")\(share.dax ? " (dax)" : "")") + note("sharing authorized capability as virtiofs tag \(share.tag)\(share.readOnly ? " (ro)" : "")") + } + let guestFSEventBridge = GuestFSEventBridge(vsock: vsock) + let hostShareCoherence = DoryHostShareCoherenceBridge( + endpoints: coherenceEndpoints, + guestEvents: guestFSEventBridge + ) { reason in + note(reason) + machine.requestStop(.crash(reason)) } + guard filesystemWorker.installCoherenceHandler({ batch in + try await hostShareCoherence.process(batch) + }) else { + throw VMError.invalidConfiguration( + "filesystem coherence handler was already installed" + ) + } + filesystemWorker.installLifecycleHandler { [weak hostShareCoherence] event in + hostShareCoherence?.failStop("filesystem worker coherence channel \(event)") + } + try filesystemWorker.client.prepareCoherence() + let fileServiceResources = FileServiceResourcePublisher( + stateDirectory: state, + worker: filesystemWorker, + frontends: coherenceEndpoints.map(\.backend) + ) + fileServiceResources.start() + defer { fileServiceResources.stop() } let networkPaths = try GVProxyRuntimePaths( stateDirectory: state, @@ -615,10 +1072,11 @@ enum EngineMode { // Install before spawning gvproxy. A signal arriving during the remaining VM setup must use // the watchdog cleanup path rather than taking the default signal action and orphaning it. installGracefulShutdown(shutdownSocket: shutdownSocket) + let networkMTU = DoryNetworkMTU.resolved() let gvproxy = Process() gvproxy.executableURL = URL(fileURLWithPath: configuration.gvproxyPath) gvproxy.arguments = [ - "-mtu", String(DoryNetworkMTU.resolved()), + "-mtu", String(networkMTU), "-listen-vfkit", "unixgram://\(datapathSocket)", "-listen", "unix://\(apiSocket)", ] @@ -663,7 +1121,11 @@ enum EngineMode { if primaryReady && lanReady { break } usleep(50_000) } - let virtioNet = try VirtioNet(socketPath: networkPaths.vmSocket, remotePath: datapathSocket) + let virtioNet = try VirtioNet( + socketPath: networkPaths.vmSocket, + remotePath: datapathSocket, + maximumTransmissionUnit: UInt16(networkMTU) + ) backends.append(virtioNet) var sourcePreservingLANClient: SourcePreservingLANPrivilegedClient? var sourcePreservingLANSessionID: String? @@ -701,7 +1163,7 @@ enum EngineMode { ) { [weak machine] in machine?.raiseGSI(spi) } - machine.attachVirtioSlot(transport) + try machine.attachVirtioSlot(transport, at: slot) } try machine.loadBootPayload() @@ -713,12 +1175,15 @@ enum EngineMode { // The Rust dataplane cannot establish its authoritative agent channel without this forward. // Bind it before publishing engine.sock and propagate any listener error out of run(), so a // configured-but-impossible path terminates dory-hv instead of advertising a half-alive VM. - if let forwardSocket = configuration.agentVsockForward { - try AgentVsockForward(socketPath: forwardSocket, guestCID: 3, log: { note($0) }).attach(to: vsock) - } // engine.sock is the sole Docker API endpoint, so its listener is just as required as the // dataplane forward. Propagate bind/listen/chmod failures before entering machine.run(). - try DockerSocketBridge(socketPath: configuration.engineSocket, log: { note($0) }).attach(to: vsock) + let engineBridges = EngineVsockBridgeLifetime( + dockerSocketPath: configuration.engineSocket, + agentSocketPath: configuration.agentVsockForward, + log: { note($0) } + ) + try engineBridges.attach(to: vsock) + defer { engineBridges.stop() } publishForward(local: shutdownSocket, guestPort: 2377, apiSocket: apiSocket, label: "shutdown channel") publishForward( local: gvproxyHealthSocket, @@ -727,7 +1192,7 @@ enum EngineMode { label: "gvproxy datapath canary" ) installClockSyncSignal(vsock: vsock) - if reclaimModeIsSenpai { + if configuration.reclaimPolicy == .senpai { let reclaimSocket = networkPaths.reclaimSocket publishForward(local: reclaimSocket, guestPort: 2378, apiSocket: apiSocket, label: "host-pressure reclaim channel") installHostPressureReclaim(reclaimSocket: reclaimSocket) @@ -767,20 +1232,66 @@ enum EngineMode { defer { gvproxyDatapathTask.cancel() } note("engine starting: \(configuration.memoryMB)MiB ceiling, \(configuration.cpus) cpus, socket \(configuration.engineSocket)") - // The host usbip bridge exists, but attach/detach is deliberately unavailable until the - // authoritative protobuf agent protocol has a real guest vhci RPC. The capability gate runs - // before HostUsbDeviceFactory.open, so commands fail closed without claiming host hardware. - let usbipManager = UsbipManager() - usbipManager.attachListener(to: vsock) + // USB/IP device data stays on its dedicated vsock connection. Attach/detach authority runs + // over a fresh shared agent-protocol channel for every operation, so an agent restart does + // not strand a long-lived control client and capability is revalidated before hardware is + // claimed and again immediately before the guest vhci mutation. + let usbipManager = UsbipManager(log: { note($0) }) + try usbipManager.attachListener(to: vsock) + defer { + // This scope cannot unwind while Machine.run() is executing: either startup failed + // before guest execution or run() returned and the guest can no longer retain vhci + // state. That terminal boundary safely resolves outcome-unknown guest attachments. + switch usbipManager.stopAfterGuestExecutionEnded() { + case .completed: + break + case .authorityRetained(let busIDs): + let detail = busIDs.isEmpty + ? "pending listener, bridge, or device drain" + : "claims: \(busIDs.joined(separator: ", "))" + note("USB/IP terminal retirement retained authority asynchronously (\(detail))") + } + } let usbControlHandler = UsbControlHandler( manager: usbipManager, - ensureSupported: { throw UsbControlError.guestAgentRPCUnavailable }, + allowedOpenModes: [.userAuthorized], + ensureSupported: { + let channel = AgentChannel( + connection: try vsock.connectForServiceIfCapacity( + port: VsockPorts.agent, + service: .agentRPC + ) + ) + try await channel.requireCapability("usb-vhci", version: 1) + }, openDevice: { busID, mode in try HostUsbDeviceFactory.open(busID: busID, mode: mode) }, - notifyAttach: { _ in throw UsbControlError.guestAgentRPCUnavailable }, - notifyDetach: { _ in throw UsbControlError.guestAgentRPCUnavailable } + notifyAttach: { request in + let channel = AgentChannel( + connection: try vsock.connectForServiceIfCapacity( + port: VsockPorts.agent, + service: .agentRPC + ) + ) + try await channel.requireCapability("usb-vhci", version: 1) + try await channel.usbVhciAttach(request) + }, + notifyDetach: { request in + let channel = AgentChannel( + connection: try vsock.connectForServiceIfCapacity( + port: VsockPorts.agent, + service: .agentRPC + ) + ) + try await channel.requireCapability("usb-vhci", version: 1) + try await channel.usbVhciDetach(request) + } + ) + let usbControlServer = UsbControlServer( + path: state + "/usb-control.sock", + handler: usbControlHandler ) - let usbControlServer = UsbControlServer(path: configuration.stateDirectory + "/usb-control.sock", handler: usbControlHandler) - do { try usbControlServer.start() } catch { note("usb control server unavailable: \(error)") } + try usbControlServer.start() + defer { usbControlServer.stop() } let memory = machine.memory let gauge = DispatchSource.makeTimerSource(queue: .global()) @@ -793,104 +1304,29 @@ enum EngineMode { note("network gauge: tx \(network.transmitPackets)p/\(network.transmitBytes)B drops=\(network.transmitDrops), rx \(network.receivePackets)p/\(network.receiveBytes)B deferred=\(network.receiveDeferred) drops=\(network.receiveDrops) truncated=\(network.receiveTruncations)") } gauge.resume() + defer { gauge.cancel() } - var hostFSEventRelay: HostFSEventRelay? - var cacheReadinessTask: Task? - var hostFSEventDiagnosticsTimer: (any DispatchSourceTimer)? - if !coherenceEndpoints.isEmpty { - let activeEndpoints = coherenceEndpoints - let coordinator = HostShareCoherenceCoordinator( - endpoints: activeEndpoints, - guestEvents: GuestFSEventBridge(vsock: vsock), - onDegraded: { note($0) }, - onRecovered: { note($0) }, - onFatalRecoveryRequired: { reason in - note("host-share coherence requires VM restart: \(reason)") - machine.requestStop(.crash(reason)) - } - ) - let relay = HostFSEventRelay( - shares: activeEndpoints.map(\.share), - observeRootsOnDemand: true, - send: { changes in - try await coordinator.process(changes) - coordinator.relayDeliverySucceeded() - }, - onFailure: { error in - let message = String(describing: error) - // This updates the readiness generation and drops response TTLs synchronously; - // actor bookkeeping cannot race cache activation back on for a failed batch. - coordinator.relayDeliveryFailed("host-share event relay failed: \(message)") - note("host-share event relay: \(message)") - } - ) - let relayStarted = relay.start() - try HostShareCoherenceStartupPolicy.requireEventRelay( - started: relayStarted, - productionShareCount: activeEndpoints.count - ) - for endpoint in activeEndpoints { - endpoint.backend.hostFS.setEventObservationHandler { hostPath in - guard relay.observe(hostPath: hostPath) else { - let reason = "failed to start narrow host-share observation for \(hostPath)" - coordinator.relayDeliveryFailed(reason) - note(reason) - machine.requestStop(.crash(reason)) - return - } - } - } - hostFSEventRelay = relay - writeHostShareResourceDiagnostics(relay.diagnostics, stateDirectory: state) - let diagnosticsTimer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) - diagnosticsTimer.schedule(deadline: .now() + 5, repeating: 5) - diagnosticsTimer.setEventHandler { - writeHostShareResourceDiagnostics(relay.diagnostics, stateDirectory: state) - } - diagnosticsTimer.resume() - hostFSEventDiagnosticsTimer = diagnosticsTimer - let watcherCount = activeEndpoints.filter(\.watcherNudgesEnabled).count - note("host-share invalidation relay active for \(activeEndpoints.count) share(s), watcher nudges on \(watcherCount)") - // The VM has not entered its run loop yet, so FUSE INIT, the 16 stable notify - // buffers, and the guest agent arrive asynchronously. Poll only the fail-closed - // readiness predicate; no environment flag can bypass these gates. - if activeEndpoints.contains(where: \.watcherNudgesEnabled) { - cacheReadinessTask = Task.detached(priority: .userInitiated) { - var lastError: String? - let deadline = ProcessInfo.processInfo.systemUptime + 120 - while ProcessInfo.processInfo.systemUptime < deadline { - guard !Task.isCancelled else { return } - do { - if try await coordinator.activateCachingIfReady() { - note("host-share coherent metadata cache active (\(VirtioFS.maximumCoherentCacheValiditySeconds)s bounded TTL)") - return - } - } catch { - lastError = String(describing: error) - } - do { - try await Task.sleep(nanoseconds: 100_000_000) - } catch { - return - } - } - let detail = lastError.map { ": \($0)" } ?? "" - note("host-share cache readiness timed out; zero-cache safety retained\(detail)") - } - } - } - defer { - cacheReadinessTask?.cancel() - hostFSEventDiagnosticsTimer?.cancel() - for endpoint in coherenceEndpoints { - endpoint.backend.hostFS.setEventObservationHandler(nil) + let machineRunner = RawHVMachineRunner( + machine: machine, + threadName: "dory-hv.engine.vcpu0" + ) + try machineRunner.start() + do { + if coherenceEndpoints.contains(where: { + $0.policy == .invalidationAndWatcherNudge + }) { + try guestFSEventBridge.establishReadinessBlocking() + note("host-share watcher bridge ready on guest vsock:\(VsockPorts.fsevents)") } - hostFSEventRelay?.stop() - try? FileManager.default.removeItem(atPath: state + "/host-share-resources.json") + try filesystemWorker.client.activateCoherence() + note("host-share coherence delivery active") + } catch { + let reason = "host-share coherence did not become ready: \(error)" + machine.requestStop(.crash(reason)) + _ = try? machineRunner.wait() + throw VMError.bootFailure(reason) } - - let stop = try machine.run() - gauge.cancel() + let stop = try machineRunner.wait() note("engine stopped: \(stop)") } @@ -943,19 +1379,24 @@ enum EngineMode { } /// Guest boot: mounts (docker state on the journaled /dev/vdb), DHCP through gvproxy, - /// dockerd on its private Unix socket, an inert HTTP datapath canary, a shutdown listener on tcp + /// dockerd on its private Unix socket, an agent-owned inert HTTP datapath canary, a shutdown listener on tcp /// 2377 (any connection triggers sync + poweroff, giving the host a clean-unmount path), and a light /// workload-aware page-cache cap so free page reporting (which handles free pages automatically /// at 16 KiB granularity) has cold pages to hand back when the engine is idle. - private static func guestBootScript( + static func guestBootScript( shares: [VirtioFSShareConfiguration] = [], - gpuMode: GPUAccelerationMode = .off, + reclaimPolicy: ReclaimPolicy = .dropCaches, amd64Emulation: Bool = false, nativeIPv6: NativeIPv6NetworkPlan? = nil, bridgeNetwork: DoryIPv4BridgeNetwork = try! DoryIPv4BridgeNetwork(), sourcePreservingLAN: Bool = false, - allowDockerDataFormat: Bool = false + allowDockerDataFormat: Bool = false, + expectedDockerDataDiskUUID: UUID? = nil ) -> String { + let dockerDataDiskUUID = expectedDockerDataDiskUUID?.uuidString.lowercased() ?? "" + let dockerDataDiskUUIDArgument = expectedDockerDataDiskUUID == nil + ? "" + : " -U \"$DORY_DOCKER_DATA_UUID\"" var script = [ "export PATH=/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin", "mount -t proc proc /proc", @@ -973,13 +1414,17 @@ enum EngineMode { " ( while [ ! -e /var/log/dory-agent.log ]; do sleep 0.2; done; tail -n +1 -f /var/log/dory-agent.log >/mnt/dory-logs/dory-agent.log 2>&1 ) & true", "fi", "mkdir -p /var/lib/docker", + "[ -b /dev/vdb ] || { echo DATA-DISK-BLOCK-DEVICE-MISSING; sync; poweroff -f; exit 1; }", // First boot receives a sparse blank disk from the host. Format it inside the guest so // the macOS 14 helper does not need Apple's macOS 15-only EXT4 formatter. "DORY_DOCKER_MOUNT_OPTS=noatime,lazytime,commit=30", "DORY_DOCKER_MOUNT_FALLBACK_OPTS=noatime,commit=30", "DORY_ALLOW_DATA_FORMAT=\(allowDockerDataFormat ? 1 : 0)", + "DORY_DOCKER_DATA_UUID='\(dockerDataDiskUUID)'", + DockerDataDiskLaunchContract.guestFilesystemUUIDShellFunction, "dory_mount_docker_data() { mount -t ext4 -o \"$DORY_DOCKER_MOUNT_OPTS\" /dev/vdb /var/lib/docker || mount -t ext4 -o \"$DORY_DOCKER_MOUNT_FALLBACK_OPTS\" /dev/vdb /var/lib/docker || mount -t ext4 /dev/vdb /var/lib/docker; }", - "dory_format_docker_data() { mkfs.ext4 -F -O fast_commit /dev/vdb >/var/log/dory-data-mkfs.log 2>&1 || mkfs.ext4 -F /dev/vdb >>/var/log/dory-data-mkfs.log 2>&1; }", + "dory_verify_docker_data_uuid() { [ -z \"$DORY_DOCKER_DATA_UUID\" ] || [ \"$(\(DockerDataDiskLaunchContract.guestFilesystemUUIDShellCommand))\" = \"$DORY_DOCKER_DATA_UUID\" ]; }", + "dory_format_docker_data() { mkfs.ext4\(dockerDataDiskUUIDArgument) -F -O fast_commit /dev/vdb >/var/log/dory-data-mkfs.log 2>&1 || mkfs.ext4\(dockerDataDiskUUIDArgument) -F /dev/vdb >>/var/log/dory-data-mkfs.log 2>&1; }", "dory_grow_docker_data() {", " DORY_DATA_DEVICE_BYTES=$(blockdev --getsize64 /dev/vdb 2>/dev/null || true)", " DORY_DATA_GEOMETRY=$(dumpe2fs -h /dev/vdb 2>/dev/null | awk '/^Block count:/{blocks=$3} /^Block size:/{size=$3} END{if(blocks && size) print blocks, size}')", @@ -999,12 +1444,13 @@ enum EngineMode { " resize2fs /dev/vdb >>/var/log/dory-data-resize.log 2>&1", "}", "if blkid /dev/vdb 2>/dev/null | grep -q 'TYPE=\"ext4\"'; then", + " dory_verify_docker_data_uuid || { echo DATA-DISK-UUID-MISMATCH; sync; poweroff -f; exit 1; }", " dory_grow_docker_data || { echo DATA-DISK-RESIZE-FAILED; cat /var/log/dory-data-resize.log 2>/dev/null; sync; poweroff -f; exit 1; }", " cp /var/log/dory-data-resize.log /mnt/dory-logs/data-resize.log 2>/dev/null || true", " dory_mount_docker_data || { echo DATA-DISK-MOUNT-FAILED-EXISTING-EXT4; sync; poweroff -f; exit 1; }", "elif [ \"$DORY_ALLOW_DATA_FORMAT\" -eq 1 ]; then", " echo DATA-DISK-FORMAT-PROVEN-BLANK", - " dory_format_docker_data && dory_mount_docker_data || { echo DATA-DISK-FORMAT-OR-MOUNT-FAILED; sync; poweroff -f; exit 1; }", + " dory_format_docker_data && dory_verify_docker_data_uuid && dory_mount_docker_data || { echo DATA-DISK-FORMAT-IDENTITY-OR-MOUNT-FAILED; sync; poweroff -f; exit 1; }", "else", " echo DATA-DISK-UNKNOWN-FILESYSTEM-REFUSING-FORMAT", " sync; poweroff -f; exit 1", @@ -1014,6 +1460,8 @@ enum EngineMode { // not remain physically full even though ext4 reports substantial free space. "fstrim -v /var/lib/docker >/var/log/dory-data-trim.log 2>&1 || true", "cp /var/log/dory-data-trim.log /mnt/dory-logs/data-trim.log 2>/dev/null || true", + "DORY_DATA_MOUNT_IDENTITY=$(awk '$2==\"/var/lib/docker\"{print $1 \" \" $3}' /proc/mounts | tail -n 1)", + "[ \"$DORY_DATA_MOUNT_IDENTITY\" = \"/dev/vdb ext4\" ] || { echo DATA-DISK-MOUNT-IDENTITY-MISMATCH; sync; poweroff -f; exit 1; }", "awk '$2==\"/var/lib/docker\"{print $4}' /proc/mounts >/var/log/dory-data-mount-options.log 2>&1 || true", "ip link set lo up", "ip link set eth0 up", @@ -1041,13 +1489,6 @@ enum EngineMode { "echo 100 > /proc/sys/vm/vfs_cache_pressure 2>/dev/null", "echo 262144 > /proc/sys/vm/min_free_kbytes 2>/dev/null", ] - if gpuMode == .venus { - script += [ - "export DORY_GPU=venus", - "for n in $(seq 1 80); do [ -d /dev/dri ] && break; sleep 0.1; done", - "[ -d /dev/dri ] && chmod a+rw /dev/dri/renderD* /dev/dri/card* 2>/dev/null || echo DORY-GPU-NO-DRI", - ] - } if amd64Emulation { script += BinfmtRegistration.bootCommands() script.append("DORY_AMD64_RUNTIME_ARGS='--add-runtime dory-runc=/usr/local/bin/dory-runc --default-runtime dory-runc'") @@ -1078,35 +1519,30 @@ enum EngineMode { ) script += [ GuestStorageReclaimCommand.periodicLoop(), - GuestDatapathCanary.listener(), GuestShutdownCommand.listener(), GuestMemoryReclaimBootCommand.hostPressureListener( - experimentalSenpai: reclaimModeIsSenpai + experimentalSenpai: reclaimPolicy == .senpai ), // Idle memory reclaim. Default is a gentle pagecache-only drop_caches when the guest is // quiet (no compaction — it re-faults the pages free-page reporting already handed back; - // no root memory.reclaim — write-rejected on the root cgroup). DORY_ENGINE_RECLAIM_MODE=senpai - // swaps in a coldest-first, working-set-protected feeder (MGLRU min_ttl_ms + DAMON_RECLAIM, - // memory.reclaim fallback) per the research §5. Kept opt-in until the memory A/B lands. + // no root memory.reclaim — write-rejected on the root cgroup). The explicit `senpai` + // launch policy swaps in a coldest-first, working-set-protected feeder (MGLRU + // min_ttl_ms + DAMON_RECLAIM, memory.reclaim fallback) per the research §5. Kept + // opt-in until the memory A/B lands. GuestMemoryReclaimBootCommand.idleLoop( - experimentalSenpai: reclaimModeIsSenpai + experimentalSenpai: reclaimPolicy == .senpai ), - // Hand PID 1 to tini (docker-init, shipped in docker:dind) as a reaping init. exec - // replaces the boot shell in place, so tini keeps PID 1 while dockerd and the loops - // above continue as its children. Container shims double-fork and orphan their exited - // children onto PID 1; tini reaps them, so they never pile up as zombies until PID - // exhaustion. If tini is ever missing, fall back to an idle shell (accepting zombies - // over a failed boot). - "[ -x /usr/local/bin/docker-init ] && exec /usr/local/bin/docker-init -s -- sleep 2147483647", - "while true; do sleep 2147483647; done", + // The Rust guest agent owns PID 1, child reaping, the control plane, and the inert + // gvproxy witness. Keeping these lifecycles together prevents a detached boot-shell + // listener from disappearing while Docker remains healthy. + guestAgentExecCommand(), ] return script.joined(separator: "\n") + "\n" } private static func guestAgentStartCommand(shares: [VirtioFSShareConfiguration]) -> String { - // The share copy comes first: the app refreshes ~/.dory/bin/dory-agent-* from its bundle on - // every launch, so preferring it over the rootfs-baked /usr/bin/dory-agent means agent fixes - // ship with app updates instead of waiting for a re-bundled engine rootfs. + // Stage exactly one agent before starting services. The app-bundled copy comes first, so + // agent fixes ship with app updates instead of waiting for a re-bundled engine rootfs. var paths = [String]() paths.append("/mnt/dory-config/dory-agent") for share in shares { @@ -1116,8 +1552,12 @@ enum EngineMode { } paths.append("/usr/bin/dory-agent") let quotedPaths = paths.map(shellQuote).joined(separator: " ") - let ports = HostAIBridge.defaultPorts.map(String.init).joined(separator: ",") - return "( for i in $(seq 1 100); do if pgrep -x dory-agent >/dev/null 2>&1; then exit 0; fi; for p in \(quotedPaths); do if [ -r \"$p\" ]; then cp \"$p\" /run/dory-agent && chmod 0755 /run/dory-agent && DORY_HOST_AI_BRIDGE_PORTS=\(shellQuote(ports)) /run/dory-agent >/var/log/dory-agent.log 2>&1 & exit 0; fi; done; sleep 0.2; done; echo 'dory-agent not found after waiting: \(quotedPaths)' >/var/log/dory-agent.log ) & true" + return "DORY_AGENT_STAGED=0; for i in $(seq 1 100); do for p in \(quotedPaths); do if [ -r \"$p\" ]; then cp \"$p\" /run/dory-agent && chmod 0755 /run/dory-agent && DORY_AGENT_STAGED=1 && break 2; fi; done; sleep 0.2; done; [ \"$DORY_AGENT_STAGED\" -eq 1 ] || { echo 'dory-agent not found after waiting: \(quotedPaths)' >/var/log/dory-agent.log; sync; poweroff -f; exit 1; }" + } + + private static func guestAgentExecCommand() -> String { + let bridgePorts = HostAIBridge.defaultPorts.map(String.init).joined(separator: ",") + return "DORY_HOST_AI_BRIDGE_PORTS=\(shellQuote(bridgePorts)) \(GuestDatapathCanary.agentEnvironmentAssignment()) exec /run/dory-agent >>/var/log/dory-agent.log 2>&1" } /// The full boot script lives on a dedicated ext4 disk (vdc), so the kernel command line stays @@ -1129,18 +1569,21 @@ enum EngineMode { #else let console = "console=ttyS0 earlyprintk=serial,ttyS0,115200" #endif - return "\(console) root=/dev/vda rw panic=0 init=/sbin/init" + return "\(console) root=/dev/vda rw panic=0 init=/sbin/init \(GuestDatapathCanary.requiredBootConfigurationKernelArgument)" } - private static func attachPlatformDevices(to machine: Machine) { + private static func attachPlatformDevices( + to machine: Machine, + serialOutput: BoundedSerialConsolePublisher + ) { #if arch(arm64) machine.bus.attach(PL031(baseAddress: GuestLayout.rtcBase)) machine.attachConsole(PL011(baseAddress: GuestLayout.uartBase) { byte in - FileHandle.standardOutput.write(Data([byte])) + serialOutput.enqueue(byte) }) #else machine.attachConsole(UART16550(basePort: UInt16(truncatingIfNeeded: GuestLayout.uartBase)) { byte in - FileHandle.standardOutput.write(Data([byte])) + serialOutput.enqueue(byte) }) machine.attachRTC(CMOSRTC(basePort: UInt16(truncatingIfNeeded: GuestLayout.rtcBase))) machine.attachResetController(I8042 { [weak machine] in @@ -1154,27 +1597,6 @@ enum EngineMode { "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" } - private static func writeHostShareResourceDiagnostics( - _ diagnostics: HostFSEventRelayDiagnostics, - stateDirectory: String - ) { - let destination = URL(fileURLWithPath: stateDirectory) - .appendingPathComponent("host-share-resources.json") - let temporary = destination.appendingPathExtension("tmp-(getpid())") - do { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.sortedKeys] - let data = try encoder.encode(diagnostics) - try data.write(to: temporary, options: [.atomic]) - _ = chmod(temporary.path, S_IRUSR | S_IWUSR) - _ = rename(temporary.path, destination.path) - } catch { - try? FileManager.default.removeItem(at: temporary) - note("host-share resource diagnostics unavailable: (error)") - } - } - /// Asks gvproxy to serve a guest TCP port as a host unix socket, retrying until the listener /// lands (dockerd readiness is the app's probe, not ours). private static func publishForward(local socketPath: String, guestPort: Int, apiSocket: String, label: String) { diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/FileServiceResourcePublisher.swift b/Packages/ContainerizationEngine/Sources/dory-hv/FileServiceResourcePublisher.swift new file mode 100644 index 00000000..0738b4f8 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/FileServiceResourcePublisher.swift @@ -0,0 +1,198 @@ +import Darwin +import DoryFSWorkerContracts +import DoryHV +import Foundation + +struct DoryFileServiceResourceSnapshot: Codable, Sendable, Equatable { + let schema: String + let version: Int + let generatedAt: Date + let running: Bool + let cacheMode: String + let maximumCacheValiditySeconds: Double + let configuredShareCount: Int + let invalidationOnlyShareCount: Int + let watcherNudgeShareCount: Int + let frontendCount: Int + let requestQueueCount: Int + let observationRequired: Bool + let observationActive: Bool + let requiredObservationShareCount: Int + let observedRequiredShareCount: Int + let observationStreamCount: Int + let pendingEventCount: Int + let pendingEventLimit: Int + let receivedEventCount: UInt64 + let deliveredBatchCount: UInt64 + let failedBatchCount: UInt64 + let eventLossCount: UInt64 + let invalidationCount: UInt64 + let invalidationFailureCount: UInt64 + let invalidationFailureLatched: Bool + let rejectedRequestCount: UInt64 + let executedRequestCount: UInt64 + let terminalQueueFaultCount: UInt64 + let completedRequestCount: UInt64 + let failedRequestCount: UInt64 + let inFlightRequestCount: UInt64 + let peakInFlightRequestCount: UInt64 + let requestPayloadBytes: UInt64 + let workerResponsePayloadBytes: UInt64 + let guestPublishedResponseBytes: UInt64 + let totalRequestLatencyNanoseconds: UInt64 + let maximumRequestLatencyNanoseconds: UInt64 + let coherenceReceivedBatchCount: UInt64 + let coherenceReplayedBatchCount: UInt64 + let coherenceInFlightBatchCount: Int + let coherenceFailedBatchCount: UInt64 + let coherenceTotalLatencyNanoseconds: UInt64 + let coherenceMaximumLatencyNanoseconds: UInt64 + let coherenceRequestBytes: UInt64 + let coherenceAcknowledgementBytes: UInt64 + let coherenceTerminalFailureLatched: Bool +} + +final class FileServiceResourcePublisher: @unchecked Sendable { + private let outputPath: String + private let worker: DoryFilesystemWorkerLaunch + private let frontends: [VirtioFS] + private let queue = DispatchQueue(label: "dev.dory.file-service.resources") + private let queueKey = DispatchSpecificKey() + private let lock = NSLock() + private var timer: DispatchSourceTimer? + + init( + stateDirectory: String, + worker: DoryFilesystemWorkerLaunch, + frontends: [VirtioFS] + ) { + outputPath = stateDirectory + "/file-service-resources.json" + self.worker = worker + self.frontends = frontends + queue.setSpecific(key: queueKey, value: ()) + } + + func start() { + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now(), repeating: .seconds(5), leeway: .milliseconds(250)) + timer.setEventHandler { [weak self] in self?.publish() } + lock.withLock { self.timer = timer } + timer.resume() + } + + func stop() { + let timer = lock.withLock { () -> DispatchSourceTimer? in + defer { self.timer = nil } + return self.timer + } + timer?.cancel() + if DispatchQueue.getSpecific(key: queueKey) == nil { queue.sync {} } + try? FileManager.default.removeItem(atPath: outputPath) + } + + private func publish() { + let status = try? worker.client.coherenceStatus(timeout: 1) + let sink = worker.client.coherenceStatistics + let invalidation = frontends.map(\.statistics) + let frontend = frontends.map(\.frontendStatistics) + let performance = frontends.map(\.performanceStatistics) + let configured = Int(status?.configuredShareCount ?? 0) + let streamCount = Int(status?.observationStreamCount ?? 0) + let required = Int(status?.requiredObservationShareCount ?? 0) + let observed = Int(status?.observedRequiredShareCount ?? 0) + let observationActive = configured == 0 + || (streamCount > 0 && required == observed && status?.running == true) + let invalidationLatched = invalidation.contains(where: \.invalidationFailureLatched) + let snapshot = DoryFileServiceResourceSnapshot( + schema: "dev.dory.file-service.resources", + version: 1, + generatedAt: Date(), + running: status?.running == true + && !sink.terminalFailureLatched + && !invalidationLatched + && observationActive, + cacheMode: "zero-validity", + maximumCacheValiditySeconds: Double(VirtioFS.maximumCoherentCacheValiditySeconds), + configuredShareCount: configured, + invalidationOnlyShareCount: Int(status?.invalidationOnlyShareCount ?? 0), + watcherNudgeShareCount: Int(status?.watcherNudgeShareCount ?? 0), + frontendCount: frontends.count, + requestQueueCount: frontends.reduce(0) { $0 + $1.requestQueueCount }, + observationRequired: configured > 0, + observationActive: observationActive, + requiredObservationShareCount: required, + observedRequiredShareCount: observed, + observationStreamCount: streamCount, + pendingEventCount: Int(status?.pendingEventCount ?? 0), + pendingEventLimit: Int(status?.pendingEventLimit ?? 0), + receivedEventCount: status?.receivedEventCount ?? 0, + deliveredBatchCount: status?.deliveredBatchCount ?? 0, + failedBatchCount: status?.failedBatchCount ?? 0, + eventLossCount: status?.eventLossCount ?? 0, + invalidationCount: sum(invalidation.map(\.invalidations)), + invalidationFailureCount: sum(invalidation.map(\.invalidationFailures)), + invalidationFailureLatched: invalidationLatched, + rejectedRequestCount: sum(frontend.map(\.rejectedRequests)), + executedRequestCount: sum(frontend.map(\.executedRequests)), + terminalQueueFaultCount: sum(frontend.map(\.terminalQueueFaults)), + completedRequestCount: sum(performance.map(\.completedRequests)), + failedRequestCount: sum(performance.map(\.failedRequests)), + inFlightRequestCount: sum(performance.map(\.inFlightRequests)), + peakInFlightRequestCount: sum(performance.map(\.peakInFlightRequests)), + requestPayloadBytes: sum(performance.map(\.requestPayloadBytes)), + workerResponsePayloadBytes: sum(performance.map(\.workerResponsePayloadBytes)), + guestPublishedResponseBytes: sum(performance.map(\.guestPublishedResponseBytes)), + totalRequestLatencyNanoseconds: sum(performance.map(\.totalRequestLatencyNanoseconds)), + maximumRequestLatencyNanoseconds: performance.map(\.maximumRequestLatencyNanoseconds).max() ?? 0, + coherenceReceivedBatchCount: sink.receivedBatchCount, + coherenceReplayedBatchCount: sink.replayedBatchCount, + coherenceInFlightBatchCount: sink.inFlightBatchCount, + coherenceFailedBatchCount: sink.failedBatchCount, + coherenceTotalLatencyNanoseconds: sink.totalLatencyNanoseconds, + coherenceMaximumLatencyNanoseconds: sink.maximumLatencyNanoseconds, + coherenceRequestBytes: sink.receivedBytes, + coherenceAcknowledgementBytes: sink.acknowledgementBytes, + coherenceTerminalFailureLatched: sink.terminalFailureLatched + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(snapshot) else { return } + try? Self.writeAtomically(data, to: outputPath) + } + + private func sum(_ values: [UInt64]) -> UInt64 { + values.reduce(0) { left, right in + let (value, overflow) = left.addingReportingOverflow(right) + return overflow ? UInt64.max : value + } + } + + private static func writeAtomically(_ data: Data, to path: String) throws { + let temporary = path + ".tmp.\(getpid())" + _ = unlink(temporary) + let descriptor = open(temporary, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0o600) + guard descriptor >= 0 else { throw CocoaError(.fileWriteUnknown) } + var succeeded = false + defer { + close(descriptor) + if !succeeded { unlink(temporary) } + } + try data.withUnsafeBytes { raw in + var offset = 0 + while offset < raw.count { + let count = Darwin.write( + descriptor, + raw.baseAddress?.advanced(by: offset), + raw.count - offset + ) + guard count > 0 else { throw CocoaError(.fileWriteUnknown) } + offset += count + } + } + guard fsync(descriptor) == 0, rename(temporary, path) == 0 else { + throw CocoaError(.fileWriteUnknown) + } + succeeded = true + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/RawHVSerialConsoleInput.swift b/Packages/ContainerizationEngine/Sources/dory-hv/RawHVSerialConsoleInput.swift new file mode 100644 index 00000000..f60180cb --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/RawHVSerialConsoleInput.swift @@ -0,0 +1,908 @@ +import Darwin +import DoryHV +import Foundation + +enum RawHVSerialConsoleInputError: Error, Equatable, CustomStringConvertible, Sendable { + case invalidConfiguration(String) + case invalidSocketPath(String) + case untrustedSocketPath(String) + case systemCall(operation: String, path: String, code: Int32) + + var description: String { + switch self { + case .invalidConfiguration(let detail): + return "invalid raw-HV serial console configuration: \(detail)" + case .invalidSocketPath(let detail): + return "invalid raw-HV serial console socket path: \(detail)" + case .untrustedSocketPath(let detail): + return "untrusted raw-HV serial console socket path: \(detail)" + case let .systemCall(operation, path, code): + return "could not \(operation) raw-HV serial console socket \(path): " + + "errno \(code) (\(String(cString: strerror(code))))" + } + } +} + +/// Private Unix-socket input side of raw-HV's durable serial console. +/// +/// Output continues to flow directly into `serial.log`. Each admitted client writes one frame and +/// half-closes its write side. The complete frame is bounded and deadline-governed before any byte +/// reaches the UART, so a slow, oversized, or abandoned peer cannot partially inject a command or +/// retain an admission slot indefinitely. +final class RawHVSerialConsoleInput: @unchecked Sendable { + struct Metrics: Equatable, Sendable { + var activeClientCount = 0 + var acceptedFrameCount: UInt64 = 0 + var acceptedByteCount: UInt64 = 0 + var rejectedPeerCount: UInt64 = 0 + var rejectedCapacityCount: UInt64 = 0 + var rejectedEmptyFrameCount: UInt64 = 0 + var rejectedOversizedFrameCount: UInt64 = 0 + var timedOutFrameCount: UInt64 = 0 + var uartBackpressureCount: UInt64 = 0 + var clientIOFailureCount: UInt64 = 0 + var listenerFailureCount: UInt64 = 0 + } + + /// Deterministic lifecycle observation points for race regression tests. Production uses the + /// no-op defaults; callbacks never make ownership decisions or replace the lifetime lock. + struct LifecycleHooks: Sendable { + var beforeListenerShutdown: @Sendable () -> Void + var beforeListenerRetire: @Sendable () -> Void + + init( + beforeListenerShutdown: @escaping @Sendable () -> Void = {}, + beforeListenerRetire: @escaping @Sendable () -> Void = {} + ) { + self.beforeListenerShutdown = beforeListenerShutdown + self.beforeListenerRetire = beforeListenerRetire + } + } + + static let productionMaximumFrameBytes = 4 * 1_024 + static let productionMaximumConcurrentClients = 8 + static let productionFrameTimeout: TimeInterval = 1 + + private static let maximumConfiguredFrameBytes = 64 * 1_024 + private static let maximumConfiguredClients = 64 + private static let maximumConfiguredTimeout: TimeInterval = 60 + private static let maximumStopWait: TimeInterval = 5 + private static let listenerPollMilliseconds: Int32 = 100 + private static let socketPathMutationLock = NSLock() + + private let lifetime: Lifetime + + init( + socketPath: String, + uart: PL011, + maximumConcurrentClients: Int = productionMaximumConcurrentClients, + maximumFrameBytes: Int = productionMaximumFrameBytes, + frameTimeout: TimeInterval = productionFrameTimeout, + expectedPeerUID: uid_t = geteuid(), + lifecycleHooks: LifecycleHooks = LifecycleHooks(), + log: @escaping @Sendable (String) -> Void = { message in + FileHandle.standardError.write( + Data("dory-hv desktop serial console: \(message)\n".utf8) + ) + } + ) throws { + guard (1...Self.maximumConfiguredClients).contains(maximumConcurrentClients) else { + throw RawHVSerialConsoleInputError.invalidConfiguration( + "maximumConcurrentClients must be in 1...\(Self.maximumConfiguredClients)" + ) + } + guard (1...Self.maximumConfiguredFrameBytes).contains(maximumFrameBytes) else { + throw RawHVSerialConsoleInputError.invalidConfiguration( + "maximumFrameBytes must be in 1...\(Self.maximumConfiguredFrameBytes)" + ) + } + guard frameTimeout.isFinite, + frameTimeout > 0, + frameTimeout <= Self.maximumConfiguredTimeout else { + throw RawHVSerialConsoleInputError.invalidConfiguration( + "frameTimeout must be finite and in (0, \(Self.maximumConfiguredTimeout)]" + ) + } + + let listener = try Self.makeOwnedListener( + socketPath: socketPath, + backlog: maximumConcurrentClients + ) + let lifetime = Lifetime( + listener: listener, + socketPath: socketPath, + uart: uart, + maximumConcurrentClients: maximumConcurrentClients, + maximumFrameBytes: maximumFrameBytes, + frameTimeout: frameTimeout, + expectedPeerUID: expectedPeerUID, + lifecycleHooks: lifecycleHooks, + log: log + ) + self.lifetime = lifetime + + let listenerQueue = DispatchQueue( + label: "dev.dory.dory-hv.serial-console-input.listener.\(listener.descriptor)" + ) + let clientQueue = DispatchQueue( + label: "dev.dory.dory-hv.serial-console-input.clients.\(listener.descriptor)", + attributes: .concurrent + ) + listenerQueue.async { + Self.runListener( + listener, + lifetime: lifetime, + clientQueue: clientQueue + ) + } + } + + var metrics: Metrics { lifetime.metrics } + + /// Wakes the listener and every admitted client, then waits against one bounded teardown + /// deadline. Listener/client worker threads remain the sole owners that close their descriptor. + func stop(timeout: TimeInterval = 1) { + let boundedTimeout = timeout.isFinite + ? min(max(0, timeout), Self.maximumStopWait) + : 1 + guard lifetime.stop(timeout: boundedTimeout) else { + lifetime.log( + "raw-HV serial console teardown did not drain within " + + "\(boundedTimeout) seconds on \(lifetime.socketPath)" + ) + return + } + } + + deinit { + stop() + } + + private static func runListener( + _ listener: OwnedListener, + lifetime: Lifetime, + clientQueue: DispatchQueue + ) { + defer { lifetime.finishListener(listener) } + while !lifetime.isStopping { + var readiness = pollfd( + fd: listener.descriptor, + events: Int16(POLLIN), + revents: 0 + ) + let result = poll(&readiness, 1, listenerPollMilliseconds) + if result == 0 { continue } + if result < 0 { + if errno == EINTR { continue } + if !lifetime.isStopping { + lifetime.recordListenerFailure( + "listener poll failed with errno \(errno)" + ) + } + return + } + if lifetime.isStopping { return } + if readiness.revents & Int16(POLLERR | POLLHUP | POLLNVAL) != 0 { + lifetime.recordListenerFailure("listener became unavailable") + return + } + guard readiness.revents & Int16(POLLIN) != 0 else { continue } + + while !lifetime.isStopping { + let client = accept(listener.descriptor, nil, nil) + if client < 0 { + if errno == EINTR { continue } + if errno == EAGAIN || errno == EWOULDBLOCK { break } + if !lifetime.isStopping { + lifetime.recordListenerFailure( + "accept failed with errno \(errno)" + ) + } + return + } + if let failure = configureAcceptedClient(client) { + close(client) + lifetime.recordClientIOFailure( + "could not \(failure.operation) for accepted serial console client: " + + "errno \(failure.code)" + ) + continue + } + var peerUID: uid_t = 0 + var peerGID: gid_t = 0 + guard getpeereid(client, &peerUID, &peerGID) == 0 else { + let code = errno + close(client) + lifetime.recordClientIOFailure( + "could not authenticate serial console peer: errno \(code)" + ) + continue + } + guard peerUID == lifetime.expectedPeerUID else { + close(client) + lifetime.recordRejectedPeer(peerUID: peerUID) + continue + } + switch lifetime.admit(client) { + case .stopping: + close(client) + case .atCapacity: + close(client) + lifetime.recordRejectedCapacity() + case .admitted(let admission): + clientQueue.async { + let outcome = admission.session.run() + lifetime.finishClient(token: admission.token, outcome: outcome) + } + } + } + } + } + + private struct ClientConfigurationFailure { + var operation: String + var code: Int32 + } + + private static func configureAcceptedClient( + _ descriptor: Int32 + ) -> ClientConfigurationFailure? { + guard fcntl(descriptor, F_SETFD, FD_CLOEXEC) == 0 else { + return ClientConfigurationFailure(operation: "set close-on-exec", code: errno) + } + let flags = fcntl(descriptor, F_GETFL) + guard flags >= 0 else { + return ClientConfigurationFailure(operation: "read descriptor flags", code: errno) + } + guard fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0 else { + return ClientConfigurationFailure(operation: "make descriptor nonblocking", code: errno) + } + // Darwin inherits SO_NOSIGPIPE from the listener. Re-setting it after a peer has already + // closed returns EINVAL, so verify the inherited protection instead of rejecting a valid + // one-frame client that disconnected immediately after SHUT_WR. + var noSigpipe: Int32 = 0 + var noSigpipeLength = socklen_t(MemoryLayout.size) + let noSigpipeResult = getsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + &noSigpipeLength + ) + guard noSigpipeResult == 0 else { + return ClientConfigurationFailure( + operation: "verify inherited SIGPIPE protection", + code: errno + ) + } + guard noSigpipe == 1 else { + return ClientConfigurationFailure( + operation: "verify inherited SIGPIPE protection", + code: ENOTSUP + ) + } + return nil + } + + private struct SocketPathIdentity: Equatable, Sendable { + var device: dev_t + var inode: ino_t + var generation: UInt32 + var birthSeconds: Int64 + var birthNanoseconds: Int64 + + init(_ info: stat) { + device = info.st_dev + inode = info.st_ino + generation = info.st_gen + birthSeconds = Int64(info.st_birthtimespec.tv_sec) + birthNanoseconds = Int64(info.st_birthtimespec.tv_nsec) + } + } + + private struct OwnedListener: Sendable { + var descriptor: Int32 + var identity: SocketPathIdentity + } + + private enum ExistingSocketProbe { + case live + case refused + case missing + case indeterminate(Int32) + } + + private static func makeOwnedListener( + socketPath: String, + backlog: Int + ) throws -> OwnedListener { + socketPathMutationLock.lock() + defer { socketPathMutationLock.unlock() } + try validate(socketPath: socketPath) + + let parent = (socketPath as NSString).deletingLastPathComponent + var parentInfo = stat() + guard lstat(parent, &parentInfo) == 0, + parentInfo.st_mode & S_IFMT == S_IFDIR, + parentInfo.st_uid == geteuid(), + parentInfo.st_mode & 0o022 == 0 else { + throw RawHVSerialConsoleInputError.untrustedSocketPath( + "parent directory must be owned by the engine effective uid and not writable " + + "by group/other: \(parent)" + ) + } + + var staleIdentity: SocketPathIdentity? + var stale = stat() + if lstat(socketPath, &stale) == 0 { + guard stale.st_mode & S_IFMT == S_IFSOCK, + stale.st_uid == geteuid(), + stale.st_nlink == 1 else { + throw RawHVSerialConsoleInputError.untrustedSocketPath( + "refusing to replace a non-socket, multiply-linked, or differently-owned " + + "node: \(socketPath)" + ) + } + staleIdentity = SocketPathIdentity(stale) + } else if errno != ENOENT { + throw systemCall("inspect", path: socketPath) + } + + if staleIdentity != nil { + switch probeExistingSocket(socketPath) { + case .refused, .missing: + break + case .live: + throw RawHVSerialConsoleInputError.untrustedSocketPath( + "refusing to replace a live serial console listener: \(socketPath)" + ) + case .indeterminate(let code): + throw RawHVSerialConsoleInputError.untrustedSocketPath( + "could not prove serial console socket stale (errno \(code)): \(socketPath)" + ) + } + } + + // Allocate and secure the descriptor before removing a trusted stale node. Resource or + // descriptor-configuration failure must not unnecessarily destroy the last endpoint. + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw systemCall("create", path: socketPath) } + var identity: SocketPathIdentity? + do { + guard fcntl(descriptor, F_SETFD, FD_CLOEXEC) == 0 else { + throw systemCall("set close-on-exec for", path: socketPath) + } + let flags = fcntl(descriptor, F_GETFL) + guard flags >= 0, + fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0 else { + throw systemCall("make nonblocking", path: socketPath) + } + var noSigpipe: Int32 = 1 + guard setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size) + ) == 0 else { + throw systemCall("disable SIGPIPE for", path: socketPath) + } + if let staleIdentity { + try removeStaleSocketIfUnchanged( + socketPath, + expectedIdentity: staleIdentity + ) + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(socketPath.utf8) + withUnsafeMutableBytes(of: &address.sun_path) { destination in + bytes.withUnsafeBytes { source in + destination.baseAddress!.copyMemory( + from: source.baseAddress!, + byteCount: bytes.count + ) + } + } + let bound = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + guard bound == 0 else { throw systemCall("bind", path: socketPath) } + guard let boundIdentity = socketIdentity(at: socketPath), + boundIdentity.owner == geteuid(), + boundIdentity.linkCount == 1 else { + throw RawHVSerialConsoleInputError.untrustedSocketPath( + "bound node did not retain a single owner socket identity: \(socketPath)" + ) + } + identity = boundIdentity.identity + guard chmod(socketPath, 0o600) == 0 else { + throw systemCall("set owner-only mode on", path: socketPath) + } + guard let captured = socketIdentity(at: socketPath), + captured.identity == boundIdentity.identity, + captured.owner == geteuid(), + captured.linkCount == 1, + captured.mode & 0o777 == 0o600 else { + throw RawHVSerialConsoleInputError.untrustedSocketPath( + "bound node did not retain owner-only socket identity: \(socketPath)" + ) + } + guard listen(descriptor, Int32(backlog)) == 0 else { + throw systemCall("listen on", path: socketPath) + } + return OwnedListener(descriptor: descriptor, identity: captured.identity) + } catch { + if let identity { + unlinkIfOwned(socketPath, identity: identity) + } + close(descriptor) + throw error + } + } + + /// A same-uid socket is not necessarily stale. Probe without blocking and fail closed for every + /// outcome except the kernel's explicit "no listener" results. In particular, EINPROGRESS and + /// EAGAIN can mean a live listener or full backlog and must never authorize unlink. + private static func probeExistingSocket(_ socketPath: String) -> ExistingSocketProbe { + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { return .indeterminate(errno) } + defer { close(descriptor) } + guard fcntl(descriptor, F_SETFD, FD_CLOEXEC) == 0 else { + return .indeterminate(errno) + } + let flags = fcntl(descriptor, F_GETFL) + guard flags >= 0, + fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0 else { + return .indeterminate(errno) + } + var noSigpipe: Int32 = 1 + guard setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigpipe, + socklen_t(MemoryLayout.size) + ) == 0 else { + return .indeterminate(errno) + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(socketPath.utf8) + withUnsafeMutableBytes(of: &address.sun_path) { destination in + bytes.withUnsafeBytes { source in + destination.baseAddress!.copyMemory( + from: source.baseAddress!, + byteCount: bytes.count + ) + } + } + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect( + descriptor, + $0, + socklen_t(MemoryLayout.size) + ) + } + } + if result == 0 { return .live } + switch errno { + case ECONNREFUSED: return .refused + case ENOENT: return .missing + default: return .indeterminate(errno) + } + } + + private static func removeStaleSocketIfUnchanged( + _ socketPath: String, + expectedIdentity: SocketPathIdentity + ) throws { + var current = stat() + if lstat(socketPath, ¤t) != 0 { + guard errno == ENOENT else { throw systemCall("reinspect", path: socketPath) } + return + } + guard current.st_mode & S_IFMT == S_IFSOCK, + current.st_uid == geteuid(), + current.st_nlink == 1, + SocketPathIdentity(current) == expectedIdentity else { + throw RawHVSerialConsoleInputError.untrustedSocketPath( + "stale socket identity changed before replacement: \(socketPath)" + ) + } + guard unlink(socketPath) == 0 else { + throw systemCall("remove stale", path: socketPath) + } + } + + private static func validate(socketPath: String) throws { + let bytes = Array(socketPath.utf8) + let address = sockaddr_un() + let maximum = MemoryLayout.size(ofValue: address.sun_path) - 1 + guard socketPath.first == "/" else { + throw RawHVSerialConsoleInputError.invalidSocketPath("path must be absolute") + } + guard !bytes.contains(0) else { + throw RawHVSerialConsoleInputError.invalidSocketPath("path contains a NUL byte") + } + guard !bytes.isEmpty, bytes.count <= maximum else { + throw RawHVSerialConsoleInputError.invalidSocketPath( + "path is \(bytes.count) UTF-8 bytes; maximum is \(maximum)" + ) + } + guard (socketPath as NSString).standardizingPath == socketPath else { + throw RawHVSerialConsoleInputError.invalidSocketPath( + "path must not contain redundant or parent components" + ) + } + } + + private static func retire(_ listener: OwnedListener, socketPath: String) { + socketPathMutationLock.lock() + unlinkIfOwned(socketPath, identity: listener.identity) + socketPathMutationLock.unlock() + close(listener.descriptor) + } + + @discardableResult + private static func unlinkIfOwned( + _ socketPath: String, + identity: SocketPathIdentity + ) -> Bool { + guard socketIdentity(at: socketPath)?.identity == identity else { return false } + return unlink(socketPath) == 0 || errno == ENOENT + } + + private static func socketIdentity( + at socketPath: String + ) -> (identity: SocketPathIdentity, owner: uid_t, mode: mode_t, linkCount: nlink_t)? { + var info = stat() + guard lstat(socketPath, &info) == 0, + info.st_mode & S_IFMT == S_IFSOCK else { + return nil + } + return ( + SocketPathIdentity(info), + info.st_uid, + info.st_mode, + info.st_nlink + ) + } + + private static func systemCall( + _ operation: String, + path: String + ) -> RawHVSerialConsoleInputError { + .systemCall(operation: operation, path: path, code: errno) + } + + private final class ClientSession: @unchecked Sendable { + enum Outcome: Sendable { + case frame([UInt8]) + case empty + case oversized + case timedOut + case ioFailure(Int32) + case stopped + } + + private let lock = NSLock() + private let maximumFrameBytes: Int + private let deadline: TimeInterval + private var descriptor: Int32? + private var started = false + private var stopping = false + + init( + descriptor: Int32, + maximumFrameBytes: Int, + frameTimeout: TimeInterval + ) { + self.descriptor = descriptor + self.maximumFrameBytes = maximumFrameBytes + self.deadline = ProcessInfo.processInfo.systemUptime + frameTimeout + } + + func run() -> Outcome { + let descriptor: Int32 + lock.lock() + guard !started, let owned = self.descriptor else { + lock.unlock() + return .stopped + } + started = true + descriptor = owned + let shouldRead = !stopping + lock.unlock() + + let outcome = shouldRead ? readFrame(from: descriptor) : .stopped + let finalOutcome = isStopping ? .stopped : outcome + finish() + return finalOutcome + } + + func requestStop() { + lock.lock() + stopping = true + if let descriptor { _ = shutdown(descriptor, SHUT_RDWR) } + lock.unlock() + } + + private var isStopping: Bool { + lock.lock() + defer { lock.unlock() } + return stopping + } + + private func readFrame(from descriptor: Int32) -> Outcome { + var frame = [UInt8]() + frame.reserveCapacity(min(maximumFrameBytes, 4 * 1_024)) + var buffer = [UInt8](repeating: 0, count: min(maximumFrameBytes + 1, 4 * 1_024)) + while true { + if isStopping { return .stopped } + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { return .timedOut } + let milliseconds = Int32(min( + Double(Int32.max), + max(1, ceil(remaining * 1_000)) + )) + var readiness = pollfd( + fd: descriptor, + events: Int16(POLLIN), + revents: 0 + ) + let ready = poll(&readiness, 1, milliseconds) + if ready == 0 { return .timedOut } + if ready < 0 { + if errno == EINTR { continue } + return .ioFailure(errno) + } + if readiness.revents & Int16(POLLERR | POLLNVAL) != 0 { + return isStopping ? .stopped : .ioFailure(ECONNRESET) + } + + let remainingCapacity = maximumFrameBytes - frame.count + let requested = min(buffer.count, remainingCapacity + 1) + let count = buffer.withUnsafeMutableBytes { + Darwin.read(descriptor, $0.baseAddress, requested) + } + if count > 0 { + frame.append(contentsOf: buffer.prefix(count)) + if frame.count > maximumFrameBytes { return .oversized } + continue + } + if count == 0 { + return frame.isEmpty ? .empty : .frame(frame) + } + if errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK { + continue + } + return isStopping ? .stopped : .ioFailure(errno) + } + } + + private func finish() { + lock.lock() + let descriptor = self.descriptor + self.descriptor = nil + if let descriptor { close(descriptor) } + lock.unlock() + } + } + + private final class Lifetime: @unchecked Sendable { + struct Admission: Sendable { + var token: UUID + var session: ClientSession + } + + enum AdmissionDecision: Sendable { + case admitted(Admission) + case atCapacity + case stopping + } + + let socketPath: String + let expectedPeerUID: uid_t + let log: @Sendable (String) -> Void + + private let lock = NSLock() + private let listenerCompletion = DispatchGroup() + private let clientCompletion = DispatchGroup() + private let uart: PL011 + private let maximumConcurrentClients: Int + private let maximumFrameBytes: Int + private let frameTimeout: TimeInterval + private let lifecycleHooks: LifecycleHooks + private var listener: OwnedListener? + private var stopping = false + private var clients = [UUID: ClientSession]() + private var storedMetrics = Metrics() + + init( + listener: OwnedListener, + socketPath: String, + uart: PL011, + maximumConcurrentClients: Int, + maximumFrameBytes: Int, + frameTimeout: TimeInterval, + expectedPeerUID: uid_t, + lifecycleHooks: LifecycleHooks, + log: @escaping @Sendable (String) -> Void + ) { + self.listener = listener + self.socketPath = socketPath + self.uart = uart + self.maximumConcurrentClients = maximumConcurrentClients + self.maximumFrameBytes = maximumFrameBytes + self.frameTimeout = frameTimeout + self.expectedPeerUID = expectedPeerUID + self.lifecycleHooks = lifecycleHooks + self.log = log + listenerCompletion.enter() + } + + var isStopping: Bool { + lock.lock() + defer { lock.unlock() } + return stopping + } + + var metrics: Metrics { + lock.lock() + defer { lock.unlock() } + var result = storedMetrics + result.activeClientCount = clients.count + return result + } + + func admit(_ descriptor: Int32) -> AdmissionDecision { + lock.lock() + defer { lock.unlock() } + guard !stopping else { return .stopping } + guard clients.count < maximumConcurrentClients else { return .atCapacity } + let token = UUID() + let session = ClientSession( + descriptor: descriptor, + maximumFrameBytes: maximumFrameBytes, + frameTimeout: frameTimeout + ) + clients[token] = session + clientCompletion.enter() + return .admitted(Admission(token: token, session: session)) + } + + func finishClient(token: UUID, outcome: ClientSession.Outcome) { + var diagnostic: String? + lock.lock() + guard clients.removeValue(forKey: token) != nil else { + lock.unlock() + return + } + switch outcome { + case .frame(let bytes) where !stopping: + if uart.receive(bytes) { + increment(&storedMetrics.acceptedFrameCount) + add(UInt64(bytes.count), to: &storedMetrics.acceptedByteCount) + } else { + increment(&storedMetrics.uartBackpressureCount) + diagnostic = "dropped a serial console frame because the UART input queue is full" + } + case .frame: + break + case .empty: + increment(&storedMetrics.rejectedEmptyFrameCount) + diagnostic = "dropped an empty serial console frame" + case .oversized: + increment(&storedMetrics.rejectedOversizedFrameCount) + diagnostic = "dropped an oversized serial console frame" + case .timedOut: + increment(&storedMetrics.timedOutFrameCount) + diagnostic = "dropped a serial console frame after its whole-frame deadline" + case .ioFailure(let code): + increment(&storedMetrics.clientIOFailureCount) + diagnostic = "serial console client read failed with errno \(code)" + case .stopped: + break + } + lock.unlock() + clientCompletion.leave() + if let diagnostic { log(diagnostic) } + } + + func recordRejectedPeer(peerUID: uid_t) { + lock.lock() + increment(&storedMetrics.rejectedPeerCount) + lock.unlock() + log( + "rejected serial console peer uid \(peerUID); " + + "expected \(expectedPeerUID)" + ) + } + + func recordRejectedCapacity() { + lock.lock() + increment(&storedMetrics.rejectedCapacityCount) + lock.unlock() + log("rejected serial console client because admission capacity is full") + } + + func recordClientIOFailure(_ diagnostic: String) { + lock.lock() + increment(&storedMetrics.clientIOFailureCount) + lock.unlock() + log(diagnostic) + } + + func recordListenerFailure(_ diagnostic: String) { + let shouldLog: Bool + lock.lock() + if !stopping { + increment(&storedMetrics.listenerFailureCount) + shouldLog = true + } else { + shouldLog = false + } + lock.unlock() + if shouldLog { log(diagnostic) } + } + + func finishListener(_ owned: OwnedListener) { + let sessions: [ClientSession] + lock.lock() + guard listener?.descriptor == owned.descriptor, + listener?.identity == owned.identity else { + lock.unlock() + return + } + listener = nil + stopping = true + sessions = Array(clients.values) + lock.unlock() + + lifecycleHooks.beforeListenerRetire() + RawHVSerialConsoleInput.retire(owned, socketPath: socketPath) + for session in sessions { session.requestStop() } + listenerCompletion.leave() + } + + func stop(timeout: TimeInterval) -> Bool { + let sessions: [ClientSession] + lock.lock() + stopping = true + if let listener { + // This is a descriptor borrow, not an integer snapshot. finishListener needs the + // same lock before it can remove and retire the listener, so close/reuse cannot win + // between selecting this descriptor and shutdown returning. + lifecycleHooks.beforeListenerShutdown() + _ = shutdown(listener.descriptor, SHUT_RDWR) + } + sessions = Array(clients.values) + lock.unlock() + for session in sessions { session.requestStop() } + + let deadline = DispatchTime.now() + timeout + let listenerFinished = listenerCompletion.wait(timeout: deadline) == .success + let clientsFinished = clientCompletion.wait(timeout: deadline) == .success + return listenerFinished && clientsFinished + } + + private func increment(_ value: inout UInt64) { + if value < UInt64.max { value += 1 } + } + + private func add(_ amount: UInt64, to value: inout UInt64) { + let (result, overflow) = value.addingReportingOverflow(amount) + value = overflow ? UInt64.max : result + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/RawHVVirtualHardwareAttachmentPlan.swift b/Packages/ContainerizationEngine/Sources/dory-hv/RawHVVirtualHardwareAttachmentPlan.swift new file mode 100644 index 00000000..c6547d74 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/RawHVVirtualHardwareAttachmentPlan.swift @@ -0,0 +1,175 @@ +import DoryOperations +import DoryVMContracts +import Foundation + +enum RawHVVirtualHardwareAttachmentPlanError: Error, Equatable, Sendable { + case duplicateMaterializedDevice(DoryVirtualDeviceID) + case materializedDeviceSetMismatch + case incompleteLaunchAuthority + case resolvedDeviceContractMismatch +} + +enum RawHVVirtualHardwareDiskAuthorityKind: Equatable, Sendable { + case legacyPath + case resolvedDescriptor +} + +enum RawHVVirtualHardwareBootAuthorityKind: Equatable, Sendable { + case legacyPaths + case resolvedImmutableBytes +} + +enum RawHVVirtualHardwareAttachmentMode: Equatable, Sendable { + case legacy + case resolved([RawHVVirtualHardwareAttachmentAssignment]) +} + +struct RawHVVirtualHardwareAttachmentAssignment: Equatable, Sendable { + let request: DoryRawHVVirtualDeviceRequest + let mmioSlot: Int +} + +/// Joins the daemon-authorized sparse topology to the exact device functions the helper actually +/// constructed. Neither construction order nor array order is permitted to choose an MMIO slot. +enum RawHVVirtualHardwareAttachmentPlan { + /// Validates the entire launch-authority tuple and derives the helper's expected device set + /// without consulting the durable topology. This preflight must run before constructing any + /// backend, opening a share, or starting an external network process. + static func launchMode( + diskAuthority: RawHVVirtualHardwareDiskAuthorityKind, + bootAuthority: RawHVVirtualHardwareBootAuthorityKind, + topology: DoryRawHVVirtualHardwareTopology?, + resolvedGraphics: DoryGraphicsAccelerationLevel?, + resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest?, + resolvedPortForwards: [DoryVMPortForward]?, + resolvedSystemDiskLogicalID: DoryVirtualDeviceID?, + directoryShareStableIDs: [String] + ) throws -> RawHVVirtualHardwareAttachmentMode { + if diskAuthority == .legacyPath, + bootAuthority == .legacyPaths, + topology == nil, + resolvedGraphics == nil, + resolvedDevices == nil, + resolvedPortForwards == nil, + resolvedSystemDiskLogicalID == nil { + return .legacy + } + + guard diskAuthority == .resolvedDescriptor, + bootAuthority == .resolvedImmutableBytes, + let topology, + let resolvedGraphics, + let resolvedDevices, + resolvedPortForwards != nil, + let resolvedSystemDiskLogicalID else { + throw RawHVVirtualHardwareAttachmentPlanError.incompleteLaunchAuthority + } + guard resolvedGraphics != .none, + resolvedDevices.directorySharing == !directoryShareStableIDs.isEmpty, + let networkInterface = resolvedDevices.networkInterface, + networkInterface.isValid else { + throw RawHVVirtualHardwareAttachmentPlanError.resolvedDeviceContractMismatch + } + + let expectedDevices = try expectedResolvedDevices( + systemDiskLogicalID: resolvedSystemDiskLogicalID, + resolvedDevices: resolvedDevices, + networkStableID: networkInterface.id, + directoryShareStableIDs: directoryShareStableIDs + ) + return .resolved(try assignments( + topology: topology, + materializedDevices: expectedDevices + )) + } + + /// Builds canonical device-function identities from launch inputs owned independently of the + /// topology. Fixed singleton IDs intentionally match the daemon planner's ABI-v1 namespace. + static func expectedResolvedDevices( + systemDiskLogicalID: DoryVirtualDeviceID, + resolvedDevices: DoryVirtualMachineDeviceCapabilityRequest, + networkStableID: String, + directoryShareStableIDs: [String] + ) throws -> [DoryRawHVVirtualDeviceRequest] { + var requests = [ + DoryRawHVVirtualDeviceRequest( + logicalID: systemDiskLogicalID, + role: .systemDisk + ), + try canonicalFixedRequest(.graphics), + try canonicalFixedRequest(.entropy), + try canonicalFixedRequest(.balloon), + try canonicalFixedRequest(.vsock), + ] + if resolvedDevices.keyboard { + requests.append(try canonicalFixedRequest(.keyboard)) + } + if resolvedDevices.pointer { + requests.append(try canonicalFixedRequest(.pointer)) + } + if resolvedDevices.audioInput || resolvedDevices.audioOutput { + requests.append(try canonicalFixedRequest(.audio)) + } + requests.append(DoryRawHVVirtualDeviceRequest( + logicalID: try DoryVirtualDeviceID.derived( + namespace: .network, + stableID: networkStableID + ), + role: .network + )) + for stableID in directoryShareStableIDs { + requests.append(DoryRawHVVirtualDeviceRequest( + logicalID: try DoryVirtualDeviceID.derived( + namespace: .directoryShare, + stableID: stableID + ), + role: .directoryShare + )) + } + return requests + } + + static func canonicalFixedRequest( + _ role: DoryVirtualDeviceRole + ) throws -> DoryRawHVVirtualDeviceRequest { + switch role { + case .graphics, .entropy, .balloon, .vsock, .keyboard, .pointer, .audio: + return try DoryRawHVVirtualDeviceRequest( + logicalID: "rawhv-\(role.rawValue)", + role: role + ) + case .systemDisk, .network, .auxiliaryBlock, .removableStorage, + .directoryShare, .usbController: + throw RawHVVirtualHardwareAttachmentPlanError.resolvedDeviceContractMismatch + } + } + + static func assignments( + topology: DoryRawHVVirtualHardwareTopology, + materializedDevices: [DoryRawHVVirtualDeviceRequest] + ) throws -> [RawHVVirtualHardwareAttachmentAssignment] { + var materializedByID = [DoryVirtualDeviceID: DoryRawHVVirtualDeviceRequest]() + for request in materializedDevices { + guard materializedByID.updateValue(request, forKey: request.logicalID) == nil else { + throw RawHVVirtualHardwareAttachmentPlanError.duplicateMaterializedDevice( + request.logicalID + ) + } + } + let authorized = topology.occupiedSlots.map { + DoryRawHVVirtualDeviceRequest(logicalID: $0.logicalID, role: $0.role) + } + guard Set(materializedDevices) == Set(authorized) else { + throw RawHVVirtualHardwareAttachmentPlanError.materializedDeviceSetMismatch + } + return topology.occupiedSlots.map { + RawHVVirtualHardwareAttachmentAssignment( + request: DoryRawHVVirtualDeviceRequest( + logicalID: $0.logicalID, + role: $0.role + ), + mmioSlot: $0.mmioSlot + ) + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/RendererBootstrapQualificationCommand.swift b/Packages/ContainerizationEngine/Sources/dory-hv/RendererBootstrapQualificationCommand.swift new file mode 100644 index 00000000..2ef9dd59 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/RendererBootstrapQualificationCommand.swift @@ -0,0 +1,375 @@ +import CryptoKit +import Darwin +import DoryHV +import DoryRendererWorkerWireContracts +import DorydKit +import Foundation +import Security + +enum RendererBootstrapQualificationCommandError: Error, Equatable { + case usage(String) + case invalidRunnerBundle + case invalidRunnerSignature + case invalidInventoryPath + case invalidInventory + case artifactMismatch(String) + case invalidKernelDigest + case invalidTimestamp(String) + case invalidValidityWindow + case invalidOutputPath + case outputExists + case outputWriteFailed +} + +/// Build-time admission harness for the exact already-signed nested renderer XPC. +/// +/// This command intentionally lives in `dory-hv`: the worker's audit-token policy admits only the +/// expected-team runner, so an unsigned helper executable cannot manufacture bootstrap evidence. +/// The receipt is written outside the intermediate-signed app. Packaging later copies it into +/// Resources and applies the final outer signature. +enum RendererBootstrapQualificationCommand { + private struct Options { + let inventoryPath: String + let managedKernelSHA256: String + let issuedAt: Date + let expiresAt: Date + let outputPath: String + } + + private final class Outcome: @unchecked Sendable { + private let lock = NSLock() + private var value: Result? + + func publish(_ result: Result) { + lock.lock() + value = result + lock.unlock() + } + + func read() -> Result? { + lock.lock() + defer { lock.unlock() } + return value + } + } + + static func run(_ arguments: ArraySlice) throws { + let options = try parse(arguments) + let semaphore = DispatchSemaphore(value: 0) + let outcome = Outcome() + Task.detached { + do { + try await qualify(options) + outcome.publish(.success(())) + } catch { + outcome.publish(.failure(error)) + } + semaphore.signal() + } + semaphore.wait() + guard let result = outcome.read() else { + throw RendererBootstrapQualificationCommandError.outputWriteFailed + } + try result.get() + } + + private static func qualify(_ options: Options) async throws { + let bundle = Bundle.main + guard bundle.bundleURL.pathExtension == "app", + bundle.bundleIdentifier == DoryRendererWorkerIdentity.runnerBundleIdentifier else { + throw RendererBootstrapQualificationCommandError.invalidRunnerBundle + } + _ = try verifiedCodeDirectoryHash( + at: bundle.bundleURL, + requirement: DoryRendererWorkerIdentity.runnerCodeSigningRequirement, + checkNestedCode: true + ) + + let contents = bundle.bundleURL.appendingPathComponent("Contents", isDirectory: true) + let requiredInventoryURL = contents.appendingPathComponent( + DoryRendererProductionInventory.relativePath + ).standardizedFileURL + let suppliedInventoryURL = URL( + fileURLWithPath: options.inventoryPath + ).standardizedFileURL + guard suppliedInventoryURL == requiredInventoryURL else { + throw RendererBootstrapQualificationCommandError.invalidInventoryPath + } + let inventoryData = try stableRegularFile( + suppliedInventoryURL, + maximumBytes: UInt64(DoryRendererProductionInventory.maximumEncodedBytes) + ) + let inventory: DoryRendererProductionInventory + do { + inventory = try DoryRendererProductionInventory.decodeCanonical(inventoryData) + } catch { + throw RendererBootstrapQualificationCommandError.invalidInventory + } + + for component in inventory.components.values { + for record in component.files { + let artifactURL = contents.appendingPathComponent(record.path) + let actual = try stableRegularFile( + artifactURL, + maximumBytes: DoryRendererProductionInventory.maximumArtifactBytes + ) + guard UInt64(actual.count) == record.byteCount, + Data(SHA256.hash(data: actual)) == record.sha256.bytes else { + throw RendererBootstrapQualificationCommandError.artifactMismatch( + record.path + ) + } + } + } + + guard let worker = inventory.components["rendererWorker"]?.files.first else { + throw RendererBootstrapQualificationCommandError.invalidInventory + } + let workerBundle = contents.appendingPathComponent( + "XPCServices/DoryRendererWorker.xpc", + isDirectory: true + ) + let workerCodeDirectoryHash = try verifiedCodeDirectoryHash( + at: workerBundle, + requirement: DoryRendererWorkerIdentity.workerCodeSigningRequirement, + checkNestedCode: false + ) + let managedKernel: DoryRendererArtifactDigest + do { + managedKernel = try DoryRendererArtifactDigest( + lowercaseSHA256: options.managedKernelSHA256, + field: "managedGuestKernel" + ) + } catch { + throw RendererBootstrapQualificationCommandError.invalidKernelDigest + } + let guestMesa = try DoryRendererArtifactDigest( + lowercaseSHA256: DoryRendererSourceTuple.guestMesaRuntimeSHA256, + field: "guestMesa" + ) + let bootstrap = try DoryRendererWorkerBootstrap( + workspaceID: DoryRendererWorkspaceID( + rawValue: UUID(uuidString: "d0470000-0000-4000-8000-000000000001")! + ), + generation: DoryRendererWorkerGeneration(rawValue: 1), + sourceTuple: .productionCandidate, + producerFenceContract: .managedLinux612106PrepareFBV1, + requestedCapabilities: .productionAcceleration, + artifacts: DoryRendererArtifactManifest( + candidateInventory: inventory.candidateInventory, + managedGuestKernel: managedKernel, + guestMesa: guestMesa, + rendererWorkerExecutable: worker.sha256, + rendererWorkerCodeDirectoryHash: workerCodeDirectoryHash + ) + ) + let exactBootstrapBytes = DoryRendererWorkerBootstrapCodec.encode(bootstrap) + let broker = try await DoryRendererWorkerBroker.connect( + exactBootstrapBytes: exactBootstrapBytes + ) + do { + let receipt = try DoryVerifiedRendererBootstrapQualification + .makeCandidateReceipt( + bootstrap: bootstrap, + liveReceipt: broker.capabilityReceipt, + issuedAt: options.issuedAt, + expiresAt: options.expiresAt + ) + try writeExclusive(receipt, to: options.outputPath) + await broker.invalidate() + } catch { + await broker.invalidate() + throw error + } + } + + private static func parse(_ arguments: ArraySlice) throws -> Options { + var values = [String: String]() + let allowed: Set = [ + "--inventory", "--managed-kernel-sha256", "--issued-at", "--expires-at", "--output", + ] + var iterator = arguments.makeIterator() + while let option = iterator.next() { + guard allowed.contains(option), values[option] == nil, + let value = iterator.next(), !value.isEmpty else { + throw RendererBootstrapQualificationCommandError.usage(option) + } + values[option] = value + } + guard Set(values.keys) == allowed, + let inventory = values["--inventory"], + let kernel = values["--managed-kernel-sha256"], + let issuedString = values["--issued-at"], + let expiresString = values["--expires-at"], + let output = values["--output"] else { + throw RendererBootstrapQualificationCommandError.usage( + "renderer-qualify requires inventory, kernel digest, issuance, expiry, and output" + ) + } + let issued = try timestamp(issuedString) + let expires = try timestamp(expiresString) + guard expires > issued, + expires.timeIntervalSince(issued) + <= DoryVerifiedRendererBootstrapQualification.maximumValidity else { + throw RendererBootstrapQualificationCommandError.invalidValidityWindow + } + let outputURL = URL(fileURLWithPath: output) + guard outputURL.path == output, + outputURL.lastPathComponent + == DoryVerifiedRendererBootstrapQualification.receiptFilename else { + throw RendererBootstrapQualificationCommandError.invalidOutputPath + } + return Options( + inventoryPath: inventory, + managedKernelSHA256: kernel, + issuedAt: issued, + expiresAt: expires, + outputPath: output + ) + } + + private static func timestamp(_ value: String) throws -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + guard let date = formatter.date(from: value), + formatter.string(from: date) == value else { + throw RendererBootstrapQualificationCommandError.invalidTimestamp(value) + } + return date + } + + private static func stableRegularFile( + _ url: URL, + maximumBytes: UInt64 + ) throws -> Data { + let descriptor = open(url.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + guard descriptor >= 0 else { + throw RendererBootstrapQualificationCommandError.artifactMismatch(url.path) + } + defer { close(descriptor) } + var before = stat() + guard fstat(descriptor, &before) == 0, + before.st_mode & S_IFMT == S_IFREG, + before.st_nlink == 1, + before.st_size > 0, + UInt64(before.st_size) <= maximumBytes, + before.st_mode & (S_IWGRP | S_IWOTH) == 0 else { + throw RendererBootstrapQualificationCommandError.artifactMismatch(url.path) + } + var bytes = Data(count: Int(before.st_size)) + try bytes.withUnsafeMutableBytes { raw in + guard let base = raw.baseAddress else { + throw RendererBootstrapQualificationCommandError.artifactMismatch(url.path) + } + var offset = 0 + while offset < raw.count { + let count = pread( + descriptor, + base.advanced(by: offset), + raw.count - offset, + off_t(offset) + ) + if count > 0 { + offset += count + } else if count < 0, errno == EINTR { + continue + } else { + throw RendererBootstrapQualificationCommandError.artifactMismatch(url.path) + } + } + } + var after = stat() + guard fstat(descriptor, &after) == 0, + before.st_dev == after.st_dev, + before.st_ino == after.st_ino, + before.st_size == after.st_size, + before.st_mtimespec.tv_sec == after.st_mtimespec.tv_sec, + before.st_mtimespec.tv_nsec == after.st_mtimespec.tv_nsec, + before.st_ctimespec.tv_sec == after.st_ctimespec.tv_sec, + before.st_ctimespec.tv_nsec == after.st_ctimespec.tv_nsec else { + throw RendererBootstrapQualificationCommandError.artifactMismatch(url.path) + } + return bytes + } + + private static func verifiedCodeDirectoryHash( + at url: URL, + requirement requirementString: String, + checkNestedCode: Bool + ) throws -> DoryCodeDirectoryHash { + var code: SecStaticCode? + var requirement: SecRequirement? + guard SecStaticCodeCreateWithPath(url as CFURL, SecCSFlags(), &code) == errSecSuccess, + let code, + SecRequirementCreateWithString( + requirementString as CFString, + SecCSFlags(), + &requirement + ) == errSecSuccess, + let requirement else { + throw RendererBootstrapQualificationCommandError.invalidRunnerSignature + } + var rawFlags = kSecCSCheckAllArchitectures + if checkNestedCode { rawFlags |= kSecCSCheckNestedCode } + guard SecStaticCodeCheckValidity( + code, + SecCSFlags(rawValue: rawFlags), + requirement + ) == errSecSuccess else { + throw RendererBootstrapQualificationCommandError.invalidRunnerSignature + } + var information: CFDictionary? + guard SecCodeCopySigningInformation( + code, + SecCSFlags(rawValue: kSecCSSigningInformation), + &information + ) == errSecSuccess, + let values = information as? [CFString: Any], + let unique = values[kSecCodeInfoUnique] as? Data else { + throw RendererBootstrapQualificationCommandError.invalidRunnerSignature + } + do { + return try DoryCodeDirectoryHash(bytes: unique) + } catch { + throw RendererBootstrapQualificationCommandError.invalidRunnerSignature + } + } + + private static func writeExclusive(_ data: Data, to path: String) throws { + let descriptor = open( + path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(0o644) + ) + guard descriptor >= 0 else { + if errno == EEXIST { + throw RendererBootstrapQualificationCommandError.outputExists + } + throw RendererBootstrapQualificationCommandError.outputWriteFailed + } + defer { close(descriptor) } + var offset = 0 + try data.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { + throw RendererBootstrapQualificationCommandError.outputWriteFailed + } + while offset < raw.count { + let count = Darwin.write( + descriptor, + base.advanced(by: offset), + raw.count - offset + ) + if count > 0 { + offset += count + } else if count < 0, errno == EINTR { + continue + } else { + throw RendererBootstrapQualificationCommandError.outputWriteFailed + } + } + } + guard fsync(descriptor) == 0 else { + throw RendererBootstrapQualificationCommandError.outputWriteFailed + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSShareConfiguration.swift b/Packages/ContainerizationEngine/Sources/dory-hv/VirtioFSShareConfiguration.swift similarity index 55% rename from Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSShareConfiguration.swift rename to Packages/ContainerizationEngine/Sources/dory-hv/VirtioFSShareConfiguration.swift index 0a5fbcc7..5f71ad22 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSShareConfiguration.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/VirtioFSShareConfiguration.swift @@ -1,3 +1,6 @@ +import Darwin +import DoryFSWorkerContracts +import DoryHV import Foundation public struct VirtioFSShareConfiguration: Equatable, Sendable { @@ -167,18 +170,179 @@ public struct VirtioFSShareConfiguration: Equatable, Sendable { && !name.contains("/") && !name.utf8.contains(0) } - public func makeBackend(daxGuestBase _: UInt64? = nil, requestQueueCount: Int? = nil) throws -> VirtioFS { + public func makeBackend( + broker: DoryFSWorkerBroker, + requestQueueCount: Int? = nil, + onWorkerLifecycle: @escaping @Sendable (VirtioFSWorkerLifecycleEvent) -> Void = { _ in } + ) throws -> VirtioFS { // `dax` is mutable for source compatibility. Recheck at the production construction boundary // so a caller cannot parse a safe share, flip the bit, and bypass initializer validation. guard !dax else { throw VMError.invalidConfiguration(Self.daxUnsupportedReason) } - let hostFS = try HostFS( - rootPath: path, - readOnly: readOnly, - hiddenNames: hiddenNames, - rootHiddenNames: rootHiddenNames + return try VirtioFS( + tag: tag, + broker: broker, + requestQueueCount: requestQueueCount, + onWorkerLifecycle: onWorkerLifecycle + ) + } +} + +struct DoryFilesystemWorkerLaunch: @unchecked Sendable { + let client: DoryFSWorkerWorkspaceClient + let capabilityByTag: [String: DoryFSShareCapabilityID] + + func broker(for share: VirtioFSShareConfiguration) throws -> DoryFSWorkerBroker { + let capability = try capability(for: share) + return try client.broker(for: capability) + } + + func capability( + for share: VirtioFSShareConfiguration + ) throws -> DoryFSShareCapabilityID { + guard let capability = capabilityByTag[share.tag] else { + throw VMError.invalidConfiguration( + "filesystem worker has no capability for virtio-fs tag \(share.tag)" + ) + } + return capability + } + + @discardableResult + func installCoherenceHandler( + _ handler: @escaping @Sendable (DoryFSWorkerCoherenceBatch) async throws -> Void + ) -> Bool { + client.installCoherenceHandler(handler) + } + + func installLifecycleHandler( + _ handler: @escaping @Sendable (DoryFSWorkerChannelEvent) -> Void + ) { + client.installLifecycleHandler(handler) + } +} + +enum DoryFilesystemWorkerLauncher { + static func start( + shares: [VirtioFSShareConfiguration], + coherencePolicyByTag: [String: DoryFSShareCoherencePolicy] = [:] + ) async throws -> DoryFilesystemWorkerLaunch { + let prepared = try prepare( + shares: shares, + coherencePolicyByTag: coherencePolicyByTag + ) + let client = try await DoryFSWorkerWorkspaceClient.connect( + exactBootstrapBytes: prepared.bytes, + rootDescriptors: prepared.rootDescriptors + ) + return DoryFilesystemWorkerLaunch( + client: client, + capabilityByTag: prepared.capabilities + ) + } + + static func startBlocking( + shares: [VirtioFSShareConfiguration], + coherencePolicyByTag: [String: DoryFSShareCoherencePolicy] = [:] + ) throws -> DoryFilesystemWorkerLaunch { + let prepared = try prepare( + shares: shares, + coherencePolicyByTag: coherencePolicyByTag + ) + let client = try DoryFSWorkerWorkspaceClient.connectBlocking( + exactBootstrapBytes: prepared.bytes, + rootDescriptors: prepared.rootDescriptors + ) + return DoryFilesystemWorkerLaunch( + client: client, + capabilityByTag: prepared.capabilities + ) + } + + static func prepare( + shares: [VirtioFSShareConfiguration], + coherencePolicyByTag: [String: DoryFSShareCoherencePolicy] = [:] + ) throws -> ( + bytes: Data, + rootDescriptors: [FileHandle], + capabilities: [String: DoryFSShareCapabilityID] + ) { + guard !shares.isEmpty, shares.count <= DoryFSWorkerBootstrapCodec.maximumShares else { + throw VMError.invalidConfiguration("invalid filesystem worker share count") + } + var seenTags = Set() + var capabilities = [String: DoryFSShareCapabilityID]() + var authorities = [DoryFSShareBootstrapAuthority]() + var rootDescriptors = [FileHandle]() + authorities.reserveCapacity(shares.count) + rootDescriptors.reserveCapacity(shares.count) + for share in shares { + guard seenTags.insert(share.tag).inserted else { + throw VMError.invalidConfiguration( + "duplicate virtio-fs share tag: \(share.tag)" + ) + } + let descriptor = Darwin.open( + share.path, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard descriptor >= 0 else { + throw VMError.invalidConfiguration( + "cannot pin virtio-fs share \(share.tag): errno \(errno)" + ) + } + var status = stat() + guard fstat(descriptor, &status) == 0, + status.st_mode & S_IFMT == S_IFDIR else { + let savedErrno = errno + Darwin.close(descriptor) + throw VMError.invalidConfiguration( + "cannot inspect virtio-fs share \(share.tag): errno \(savedErrno)" + ) + } + // Keep the exact no-follow directory descriptor open and transfer it through signed + // XPC. A bookmark created by this unsandboxed runner is only a locator, not a Powerbox + // grant for another sandbox identity, while Darwin App Sandbox does not extend an + // inherited directory descriptor to descendant `openat` operations. The dedicated + // worker therefore shares this runner's host filesystem namespace but receives no host + // paths: it duplicates only these descriptors and verifies the sealed identity below + // before admitting any FUSE request. + let rootDescriptorIndex = UInt16(rootDescriptors.count) + rootDescriptors.append(FileHandle( + fileDescriptor: descriptor, + closeOnDealloc: true + )) + let capability = DoryFSShareCapabilityID.random() + capabilities[share.tag] = capability + authorities.append(try DoryFSShareBootstrapAuthority( + capabilityID: capability, + expectedRootIdentity: try DoryFSPinnedRootIdentity( + device: UInt64(truncatingIfNeeded: status.st_dev), + inode: UInt64(truncatingIfNeeded: status.st_ino), + generation: UInt64(truncatingIfNeeded: status.st_gen) + ), + readOnly: share.readOnly, + coherencePolicy: coherencePolicyByTag[share.tag] ?? .disabled, + guestIdentity: DoryFSGuestIdentityPolicy(uid: getuid(), gid: getgid()), + resourceLimits: .production, + rootDescriptorIndex: rootDescriptorIndex, + hiddenComponents: Array(share.hiddenNames), + rootHiddenComponents: Array(share.rootHiddenNames) + )) + } + let bootstrap = try DoryFSWorkerBootstrap( + workspaceID: .random(), + generation: try DoryFSWorkerGeneration( + rawValue: UInt64.random(in: 1...UInt64.max) + ), + workerLimits: .production, + shares: authorities + ) + return ( + bytes: try DoryFSWorkerBootstrapCodec.encode(bootstrap), + rootDescriptors: rootDescriptors, + capabilities: capabilities ) - return try VirtioFS(tag: tag, hostFS: hostFS, requestQueueCount: requestQueueCount) } } diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift index ab36e25d..2deb95c7 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift @@ -1,5 +1,8 @@ import DoryHV import DoryCore +import DorydKit +import DoryOperations +import DoryVMContracts import Foundation signal(SIGPIPE, SIG_IGN) @@ -12,9 +15,12 @@ let defaultBootCommandLine = "console=ttyS0 earlyprintk=serial,ttyS0,115200 pani let defaultAgentPingCommandLine = "root=/dev/vda rw panic=0" #endif -func fail(_ message: String) -> Never { +func fail( + _ message: String, + status: DoryDesktopHelperExitStatus = .generalFailure +) -> Never { FileHandle.standardError.write(Data("dory-hv: \(message)\n".utf8)) - exit(1) + exit(status.rawValue) } do { @@ -29,10 +35,7 @@ struct Options { var memoryMB: UInt64 = 2048 var cpus: Int = 1 var commandLine = defaultBootCommandLine - var disks: [String] = [] - var gvproxy: String? var timeoutSeconds: UInt64 = 30 - var shares: [VirtioFSShareConfiguration] = [] } func parseOptions(_ arguments: ArraySlice) -> Options { @@ -45,16 +48,7 @@ func parseOptions(_ arguments: ArraySlice) -> Options { case "--mem-mb": options.memoryMB = iterator.next().flatMap(UInt64.init) ?? options.memoryMB case "--cpus": options.cpus = iterator.next().flatMap(Int.init) ?? options.cpus case "--cmdline": options.commandLine = iterator.next() ?? options.commandLine - case "--disk": if let disk = iterator.next() { options.disks.append(disk) } - case "--gvproxy": options.gvproxy = iterator.next() case "--timeout-sec": options.timeoutSeconds = iterator.next().flatMap(UInt64.init) ?? options.timeoutSeconds - case "--share": - guard let value = iterator.next() else { fail("--share requires tag=/host/path[:ro|:rw][:safe][:at=/guest/path]; DAX host shares are disabled") } - do { - options.shares.append(try VirtioFSShareConfiguration(argument: value)) - } catch { - fail("\(error)") - } default: fail("unknown option \(argument)") } } @@ -65,10 +59,13 @@ private final class AgentPingResultBox: @unchecked Sendable { private let lock = NSLock() private var stored: Result? - func set(_ result: Result) { + @discardableResult + func setIfEmpty(_ result: Result) -> Bool { lock.lock() + defer { lock.unlock() } + guard stored == nil else { return false } stored = result - lock.unlock() + return true } func get() -> Result? { @@ -78,7 +75,7 @@ private final class AgentPingResultBox: @unchecked Sendable { } } -func attachBackend(_ backend: VirtioDeviceBackend, to machine: Machine, slot: Int) { +func attachBackend(_ backend: VirtioDeviceBackend, to machine: Machine, slot: Int) throws { let spi = GuestLayout.virtioFirstIRQ + UInt32(slot) let transport = VirtioMMIOTransport( baseAddress: GuestLayout.virtioBase + UInt64(slot) * GuestLayout.virtioSlotSize, @@ -87,7 +84,7 @@ func attachBackend(_ backend: VirtioDeviceBackend, to machine: Machine, slot: In ) { [weak machine] in machine?.raiseGSI(spi) } - machine.attachVirtioSlot(transport) + try machine.attachVirtioSlot(transport, at: slot) } func attachPlatformDevices(to machine: Machine, console: FileHandle) { @@ -135,60 +132,98 @@ func runAgentPing(_ options: Options) { vsock, ] for (slot, backend) in backends.enumerated() { - attachBackend(backend, to: machine, slot: slot) + try attachBackend(backend, to: machine, slot: slot) } try machine.loadBootPayload() - let runThread = Thread { - do { - let stop = try machine.run() - FileHandle.standardError.write(Data("dory-hv: guest stopped before agent answered: \(stop)\n".utf8)) - } catch { - FileHandle.standardError.write(Data("dory-hv: guest failed before agent answered: \(error)\n".utf8)) - } - } - runThread.name = "dory-hv.agent-ping.vm" - runThread.start() - let deadline = DispatchTime.now().uptimeNanoseconds + options.timeoutSeconds * 1_000_000_000 let semaphore = DispatchSemaphore(value: 0) + let probeFinished = DispatchSemaphore(value: 0) let result = AgentPingResultBox() - Task.detached { - while DispatchTime.now().uptimeNanoseconds < deadline { - let connection = vsock.connect(port: VsockPorts.agent) - let channel = AgentChannel(connection: connection) + let machineRunner = RawHVMachineRunner( + machine: machine, + threadName: "dory-hv.agent-ping.vcpu0" + ) + try machineRunner.start { machineResult in + let failure: any Error + switch machineResult { + case .success(let reason): + failure = VMError.bootFailure( + "guest stopped before agent answered: \(reason)" + ) + case .failure(let error): + failure = error + } + if result.setIfEmpty(.failure(failure)) { + semaphore.signal() + } + } + + let probeTask = Task.detached { + defer { probeFinished.signal() } + while !Task.isCancelled, + DispatchTime.now().uptimeNanoseconds < deadline { + var connection: VsockConnection? do { + let admitted = try vsock.connectForServiceIfCapacity( + port: VsockPorts.agent, + service: .agentRPC + ) + connection = admitted + let channel = AgentChannel(connection: admitted) let info = try await channel.info() - result.set(.success(info)) - semaphore.signal() + if result.setIfEmpty(.success(info)) { + semaphore.signal() + } return } catch { - connection.close() - try? await Task.sleep(nanoseconds: 500_000_000) + connection?.close() + do { + try await Task.sleep(nanoseconds: 500_000_000) + } catch { + return + } } } - result.set(.failure(VMError.bootFailure("guest agent did not answer on vsock port 1024 within \(options.timeoutSeconds)s"))) - semaphore.signal() + guard !Task.isCancelled else { return } + if result.setIfEmpty(.failure(VMError.bootFailure( + "guest agent did not answer on vsock port 1024 within \(options.timeoutSeconds)s" + ))) { + semaphore.signal() + } } semaphore.wait() + probeTask.cancel() + probeFinished.wait() switch result.get() { case .success(let info): + _ = try machineRunner.stopAndWait(.powerOff) let data = try JSONEncoder().encode(info) print(String(decoding: data, as: UTF8.self)) - exit(0) case .failure(let error): - fail("\(error)") + _ = try? machineRunner.stopAndWait(.crash("agent-ping failed: \(error)")) + throw error case nil: - fail("agent-ping ended without a result") + _ = try? machineRunner.stopAndWait( + .crash("agent-ping ended without a result") + ) + throw VMError.bootFailure("agent-ping ended without a result") } } catch { fail("\(error)") } } -let arguments = Array(CommandLine.arguments.dropFirst()) +let arguments: [String] +do { + arguments = try DoryApplicationLaunchHandoffClient.receiveIfRequested( + arguments: Array(CommandLine.arguments.dropFirst()) + ) +} catch { + fail("application launch authority handoff failed: \(error)") +} guard let command = arguments.first else { - fail("usage: dory-hv [options]") + fail("usage: dory-hv [options]") } switch command { @@ -304,19 +339,6 @@ case "data-drive": } catch { fail("data-drive \(operation) failed: \(error)") } -case "lzfse": - let sub = arguments.dropFirst().first - let paths = Array(arguments.dropFirst(2)) - guard let sub, paths.count == 2 else { fail("usage: dory-hv lzfse ") } - do { - switch sub { - case "compress": try LZFSE.compress(source: paths[0], destination: paths[1]) - case "decompress": try LZFSE.decompress(source: paths[0], destination: paths[1]) - default: fail("usage: dory-hv lzfse ") - } - } catch { - fail("lzfse \(sub) failed: \(error)") - } case "smoke": do { let result = try HVSmoke.run() @@ -330,93 +352,286 @@ case "madvtest": } catch { fail("\(error)") } -case "daxprobe": +case "renderer-qualify": do { - let arg = arguments.dropFirst(1).first - let base = arg.flatMap { UInt64($0.hasPrefix("0x") ? String($0.dropFirst(2)) : $0, radix: 16) } - let result = try DaxCoherenceProbe.run(daxGuestBase: base ?? GuestLayout.daxWindowBase) - print("dory-hv: \(result)") + try RendererBootstrapQualificationCommand.run(arguments.dropFirst()) } catch { - fail("\(error)") + fail("renderer qualification failed: \(error)") } -case "boot": - let options = parseOptions(arguments.dropFirst()) - guard let kernel = options.kernel else { fail("boot requires --kernel") } - do { - let configuration = MachineConfiguration( - kernelPath: kernel, - commandLine: options.commandLine, - memoryBytes: options.memoryMB << 20, - cpuCount: options.cpus - ) - let machine = try Machine(configuration: configuration) - let console = FileHandle.standardOutput - attachPlatformDevices(to: machine, console: console) - var backends: [VirtioDeviceBackend] = [] - for (slot, diskPath) in options.disks.enumerated() { - backends.append(try VirtioBlk(path: diskPath, identity: "dory-blk\(slot)")) +case "desktop": + var machineID: String? + var operationID: UUID? + var stateDirectory: String? + var kernel: String? + var initrd: String? + var rootfs: String? + var runtimeLaunchEnvelope: RuntimeLaunchEnvelope? + var legacyGraphicsBackend: DoryDesktopGraphicsBackend? + var rootDevice = "/dev/vda" + var rootDeviceWasSpecified = false + var genericGuest = false + var bootMode: String? + var gvproxy: String? + var handoffSocket: String? + var agentSocket: String? + var shellSocket: String? + var consoleSocket: String? + var controlSocket: String? + var usbControlSocket: String? + var sshAgentSocket: String? + var memoryMB: UInt64 = 6_144 + var memoryWasSpecified = false + var cpus = 6 + var cpusWereSpecified = false + var shares = [DoryMachineShareConfiguration]() + var environment = [String: String]() + var displayPresentation: DoryMachineDisplayPresentation = .windowed + var iterator = arguments.dropFirst().makeIterator() + while let argument = iterator.next() { + switch argument { + case "--machine-id": machineID = iterator.next() + case "--operation-id": + guard let value = iterator.next(), + let parsed = DoryOperationIdentity.parseCanonical(value) else { + fail("desktop --operation-id requires a canonical lowercase UUID") + } + operationID = parsed + case "--state-dir": stateDirectory = iterator.next() + case "--kernel": kernel = iterator.next() + case "--initrd": initrd = iterator.next() + case "--rootfs": rootfs = iterator.next() + case "--runtime-launch-envelope": + guard let value = iterator.next() else { + fail("desktop --runtime-launch-envelope requires a value") + } + do { + runtimeLaunchEnvelope = try RuntimeLaunchEnvelope.decodeResolvedRawHVArgument(value) + } catch { + fail("invalid desktop runtime launch envelope: \(error)") + } + case "--legacy-graphics": + guard let value = iterator.next(), + let backend = DoryDesktopGraphicsBackend(rawValue: value) else { + fail("desktop --legacy-graphics requires software, virgl, or virgl-venus") + } + legacyGraphicsBackend = backend + case "--root-device": + rootDeviceWasSpecified = true + rootDevice = iterator.next() ?? rootDevice + case "--generic-guest": genericGuest = true + case "--gvproxy": gvproxy = iterator.next() + case "--handoff-sock": handoffSocket = iterator.next() + case "--agent-sock": agentSocket = iterator.next() + case "--shell-sock": shellSocket = iterator.next() + case "--console-sock": consoleSocket = iterator.next() + case "--ssh-agent-socket": sshAgentSocket = iterator.next() + case "--memory-mb", "--mem-mb": + guard let value = iterator.next(), let parsed = UInt64(value), parsed > 0 else { + fail("desktop --memory-mb requires a positive integer") + } + memoryMB = parsed + memoryWasSpecified = true + case "--cpus": + guard let value = iterator.next(), let parsed = Int(value), parsed > 0 else { + fail("desktop --cpus requires a positive integer") + } + cpus = parsed + cpusWereSpecified = true + case "--share": + guard let value = iterator.next() else { fail("desktop --share requires a value") } + do { shares.append(try DoryMachineShareConfiguration(argument: value)) } + catch { fail("invalid desktop share: \(error)") } + case "--env": + guard let value = iterator.next(), let equals = value.firstIndex(of: "=") else { + fail("desktop --env requires KEY=VALUE") + } + environment[String(value[.. [userAuthorized|seize|capture]") } - let mode: HostUsbOpenMode - switch args.dropFirst().first { - case "seize": mode = .seize - case "capture", nil: mode = .capture - case "userAuthorized", "user": mode = .userAuthorized - case let other?: fail("unknown mode \(other)") - } - do { - FileHandle.standardError.write(Data("dory-hv: claiming \(busID) mode=\(mode)…\n".utf8)) - let device = try HostUsbDeviceFactory.open(busID: busID, mode: mode) - let command = UsbipSubmitCommand( - header: UsbipHeaderBasic(command: .cmdSubmit, sequenceNumber: 1, deviceID: 0, direction: .in, endpoint: 0), - transferFlags: 0, - transferBufferLength: 18, - startFrame: 0, - numberOfPackets: 0, - interval: 0, - setup: [0x80, 0x06, 0x00, 0x01, 0x00, 0x00, 0x12, 0x00], // GET_DESCRIPTOR(device, 18) - transferBuffer: [] - ) - let reply = try device.submit(command) - let bytes = reply.transferBuffer - print("CLAIM OK. GET_DESCRIPTOR status=\(reply.status) actualLength=\(reply.actualLength) bytes=\(bytes.count)") - if bytes.count >= 12 { - let vid = UInt16(bytes[8]) | (UInt16(bytes[9]) << 8) - let pid = UInt16(bytes[10]) | (UInt16(bytes[11]) << 8) - print(String(format: "device descriptor: bLength=%d bDescriptorType=%d idVendor=0x%04x idProduct=0x%04x", bytes[0], bytes[1], vid, pid)) - } - } catch { - fail("usb probe failed: \(error)") - } - case "attach": - let attachArgs = Array(arguments.dropFirst(2)) - guard let busID = attachArgs.first else { fail("usage: dory-hv usb attach [userAuthorized|seize|capture]") } - let controlSocket = "\(NSHomeDirectory())/.dory/hv/usb-control.sock" - do { - let response = try UsbControlClient.send(UsbControlRequest(cmd: "attach", busid: busID, mode: attachArgs.dropFirst().first), socketPath: controlSocket) - guard response.ok else { fail("usb attach failed: \(response.error ?? "unknown")") } - print("attached \(busID) on vhci port \(response.port ?? -1)") - } catch { - fail("usb attach failed: \(error)") - } - case "detach": - let detachArgs = Array(arguments.dropFirst(2)) - guard let busID = detachArgs.first else { fail("usage: dory-hv usb detach ") } - let controlSocket = "\(NSHomeDirectory())/.dory/hv/usb-control.sock" - do { - let response = try UsbControlClient.send(UsbControlRequest(cmd: "detach", busid: busID), socketPath: controlSocket) - guard response.ok else { fail("usb detach failed: \(response.error ?? "unknown")") } - print("detached \(busID)") - } catch { - fail("usb detach failed: \(error)") - } default: - fail("usage: dory-hv usb ") + fail("usage: dory-hv usb list") } case "engine": var engineSocket = "\(NSHomeDirectory())/.dory/engine.sock" @@ -501,8 +658,7 @@ case "engine": var cpus = 4 var rootfs: String? var stateDirectory: String? - var dockerDataDisk: String? - var dataDriveRoot: String? + var dockerDataDiskArguments = EngineMode.DockerDataDiskArguments() var shares: [VirtioFSShareConfiguration] = [] var directIPRequested = false var directIPSubnet: String? @@ -512,6 +668,8 @@ case "engine": var directIPv6VirtualNetwork = "fd7d:6f72:7900::/64" var directIPv6HostGateway = "fd7d:6f72:7900::1" var gpuMode = EngineMode.GPUAccelerationMode.off + var reclaimPolicy = EngineMode.ReclaimPolicy.dropCaches + var fuseRequestQueuePolicy = EngineMode.FuseRequestQueuePolicy.automatic var amd64Emulation = false var publishHost = "127.0.0.1" var agentVsockForward: String? @@ -535,20 +693,37 @@ case "engine": guard let value = iterator.next(), !value.isEmpty else { fail("engine --data-disk requires a non-empty absolute path") } - guard value.hasPrefix("/") else { fail("engine --data-disk requires an absolute path") } - dockerDataDisk = value + do { + try dockerDataDiskArguments.setLegacyPath(value) + } catch { + fail("engine \(error)") + } case "--data-drive": guard let value = iterator.next(), !value.isEmpty else { fail("engine --data-drive requires a non-empty absolute .dorydrive path") } do { - let environmentHome = DoryDataDrive.processHome() - let drive = try DoryDataDrive(home: environmentHome, overrideRoot: value) - try drive.prepare() - dockerDataDisk = drive.engineDataDiskPath - dataDriveRoot = drive.root + try dockerDataDiskArguments.setDataDrive(value) } catch { - fail("invalid Dory data drive: \(error)") + fail("engine \(error)") + } + case DockerDataDiskLaunchContract.fileDescriptorArgument: + guard let value = iterator.next() else { + fail("engine --docker-data-disk-fd requires a file descriptor") + } + do { + try dockerDataDiskArguments.setInheritedFileDescriptor(value) + } catch { + fail("engine \(error)") + } + case DockerDataDiskLaunchContract.filesystemUUIDArgument: + guard let value = iterator.next() else { + fail("engine --docker-data-disk-uuid requires a canonical lowercase UUID") + } + do { + try dockerDataDiskArguments.setExpectedFilesystemUUID(value) + } catch { + fail("engine \(error)") } case "--mem-mb": memoryMB = iterator.next().flatMap(UInt64.init) ?? memoryMB case "--cpus": cpus = iterator.next().flatMap(Int.init) ?? cpus @@ -568,6 +743,23 @@ case "engine": gpuMode = parseGPUMode(iterator.next() ?? "") case let value where value.hasPrefix("--gpu="): gpuMode = parseGPUMode(String(value.dropFirst("--gpu=".count))) + case "--memory-reclaim": + guard let value = iterator.next(), + let parsed = EngineMode.ReclaimPolicy(rawValue: value) else { + fail("engine --memory-reclaim requires drop-caches or senpai") + } + reclaimPolicy = parsed + case "--fuse-request-queues": + guard let value = iterator.next(), let count = Int(value) else { + fail("engine --fuse-request-queues requires an integer from 1 through 8") + } + do { + fuseRequestQueuePolicy = try EngineMode.FuseRequestQueuePolicy( + fixedCount: count + ) + } catch { + fail("\(error)") + } case "--amd64": amd64Emulation = true case "--publish-host": @@ -591,6 +783,42 @@ case "engine": guard let stateDirectory else { fail("engine requires explicit --state-dir; refusing to select persistent Docker state implicitly") } + let dockerDataDiskAuthority: EngineMode.DockerDataDiskAuthority + let dataDriveRoot: String? + let dataDriveDiskPath: String? + do { + switch try dockerDataDiskArguments.resolvedSelection() { + case let .inherited(fileDescriptor, expectedFilesystemUUID, dataDriveArgument): + let drive = try DoryDataDrive( + home: DoryDataDrive.processHome(), + overrideRoot: dataDriveArgument + ) + // The daemon owns disk creation and drive.lock in production. Only validate and retain + // the managed namespace metadata here; the disk pathname is never attachment authority. + try drive.validateManifest() + dockerDataDiskAuthority = .inherited( + fileDescriptor: fileDescriptor, + expectedFilesystemUUID: expectedFilesystemUUID + ) + dataDriveRoot = drive.root + dataDriveDiskPath = drive.engineDataDiskPath + case .standaloneDataDrive(let dataDriveArgument): + let drive = try DoryDataDrive( + home: DoryDataDrive.processHome(), + overrideRoot: dataDriveArgument + ) + try drive.prepare() + dockerDataDiskAuthority = .standalonePath(drive.engineDataDiskPath) + dataDriveRoot = drive.root + dataDriveDiskPath = drive.engineDataDiskPath + case .standalonePath(let path): + dockerDataDiskAuthority = .standalonePath(path) + dataDriveRoot = nil + dataDriveDiskPath = nil + } + } catch { + fail("invalid engine Docker data-disk authority: \(error)") + } let configuration = EngineMode.Configuration( engineSocket: engineSocket, kernelPath: kernel, @@ -598,8 +826,9 @@ case "engine": memoryMB: memoryMB, cpus: cpus, stateDirectory: stateDirectory, - dockerDataDiskPath: dockerDataDisk, + dockerDataDiskAuthority: dockerDataDiskAuthority, dataDriveRoot: dataDriveRoot, + dataDriveDiskPath: dataDriveDiskPath, bundledRootfs: rootfs, shares: shares, directIP: directIPSubnet.map { subnet in @@ -623,25 +852,19 @@ case "engine": ) }, gpuMode: gpuMode, + reclaimPolicy: reclaimPolicy, + fuseRequestQueuePolicy: fuseRequestQueuePolicy, amd64Emulation: amd64Emulation, publishHost: publishHost, agentVsockForward: agentVsockForward, sshAgentSocket: sshAgentSocket, guestAgentPath: guestAgent ) - // Top-level code is implicitly MainActor; a plain Task would inherit it and deadlock behind - // the semaphore below. Detach so the engine runs on the concurrent pool. - let semaphore = DispatchSemaphore(value: 0) - Task.detached { - do { - try await EngineMode.run(configuration) - } catch { - FileHandle.standardError.write(Data("dory-hv: engine failed: \(error)\n".utf8)) - exit(1) - } - semaphore.signal() + do { + try EngineMode.run(configuration) + } catch { + fail("engine failed: \(error)") } - semaphore.wait() default: fail("unknown command \(command)") } diff --git a/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerHostCoherenceTests.swift b/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerHostCoherenceTests.swift new file mode 100644 index 00000000..0d8bc8c1 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerHostCoherenceTests.swift @@ -0,0 +1,575 @@ +import DoryFSWorkerContracts +@testable import DoryFSWorkerServiceCore +import Foundation +import Testing + +@Suite(.serialized) +struct DoryFSWorkerHostCoherenceTests { + @Test func initiallyEmptyRootPublishesExactHostCreateForGuestWatcher() async throws { + let share = try CoherenceTemporaryShare() + let hostFS = try HostFS(rootPath: share.root.path) + let capability = try hostCoherenceCapability(1) + let exchange = HostCoherenceExchangeRecorder() + let failures = HostCoherenceFailureRecorder() + let relay = try DoryFSWorkerHostCoherence( + generation: DoryFSWorkerGeneration(rawValue: 101), + shares: [(capability, hostFS, .invalidationAndWatcherNudge)], + exchange: exchange.exchange, + onFailure: failures.record + ) + defer { relay.stop() } + + #expect(!relay.statistics.running) + try relay.activate() + let active = relay.statistics + #expect(active.running) + #expect(active.requiredObservationShareCount == 1) + #expect(active.observedRequiredShareCount == 1) + #expect(active.observationStreamCount == 1) + + try runExternal("/usr/bin/touch", [ + share.root.appendingPathComponent("created-by-host.txt").path, + ]) + relay.flushObservationStreams() + + #expect(try await waitForNudge("created-by-host.txt", in: exchange)) + #expect(failures.error == nil) + #expect(relay.statistics.deliveredBatchCount >= 1) + } + + @Test func preactivationAtomicMoveIsReplayedAfterSinkHandshake() async throws { + let share = try CoherenceTemporaryShare(stagingContents: Data("ready".utf8)) + let hostFS = try HostFS(rootPath: share.root.path) + let capability = try hostCoherenceCapability(2) + let exchange = HostCoherenceExchangeRecorder() + let failures = HostCoherenceFailureRecorder() + let relay = try DoryFSWorkerHostCoherence( + generation: DoryFSWorkerGeneration(rawValue: 102), + shares: [(capability, hostFS, .invalidationAndWatcherNudge)], + exchange: exchange.exchange, + onFailure: failures.record + ) + defer { relay.stop() } + + // This mutation occurs after the worker captured its FSEvents checkpoint but before any + // stream or runner callback exists. Activation must replay it only after the sink is ready. + try runExternal("/bin/mv", [ + share.staging.path, + share.root.appendingPathComponent("atomic-install.txt").path, + ]) + #expect(try await waitForPathToExist( + share.root.appendingPathComponent("atomic-install.txt").path + )) + try relay.prepare() + + #expect(!relay.statistics.running) + #expect(try await waitForPendingEvent(in: relay)) + #expect(exchange.exactFrames.isEmpty) + + try relay.activateDelivery() + + #expect(try await waitForNudge("atomic-install.txt", in: exchange)) + #expect(failures.error == nil) + let active = relay.statistics + #expect(active.running) + #expect(active.requiredObservationShareCount == active.observedRequiredShareCount) + } + + @Test func deliveryActivationRequiresPreparedObservation() throws { + let share = try CoherenceTemporaryShare() + let hostFS = try HostFS(rootPath: share.root.path) + let capability = try hostCoherenceCapability(9) + let exchange = HostCoherenceExchangeRecorder() + let failures = HostCoherenceFailureRecorder() + let relay = try DoryFSWorkerHostCoherence( + generation: DoryFSWorkerGeneration(rawValue: 109), + shares: [(capability, hostFS, .invalidationAndWatcherNudge)], + exchange: exchange.exchange, + onFailure: failures.record + ) + defer { relay.stop() } + + #expect(throws: DoryFSWorkerHostCoherenceError.observationUnavailable) { + try relay.activateDelivery() + } + #expect(exchange.exactFrames.isEmpty) + #expect(failures.error == nil) + + try relay.prepare() + try relay.activateDelivery() + + #expect(relay.statistics.running) + #expect(failures.error == nil) + } + + @Test func transientDeliveryRetriesTheExactRetainedBatch() async throws { + let share = try CoherenceTemporaryShare() + let file = share.root.appendingPathComponent("known.txt") + try Data("before".utf8).write(to: file) + let hostFS = try HostFS(rootPath: share.root.path) + _ = try hostFS.lookup(parent: HostFS.rootNodeID, name: "known.txt") + let capability = try hostCoherenceCapability(3) + let exchange = HostCoherenceExchangeRecorder(failFirstAttempt: true) + let failures = HostCoherenceFailureRecorder() + let relay = try DoryFSWorkerHostCoherence( + generation: DoryFSWorkerGeneration(rawValue: 103), + shares: [(capability, hostFS, .invalidationAndWatcherNudge)], + exchange: exchange.exchange, + onFailure: failures.record + ) + defer { relay.stop() } + try relay.activate() + + try runExternal("/usr/bin/touch", [file.path]) + relay.flushObservationStreams() + + #expect(try await waitForAttempts(2, in: exchange)) + let frames = exchange.exactFrames + #expect(frames.count >= 2) + if frames.count >= 2 { #expect(frames[0] == frames[1]) } + #expect(failures.error == nil) + #expect(relay.statistics.deliveredBatchCount >= 1) + } + + @Test func deliveryActivationReconcilesKnownInodeWithoutWaitingForFSEvents() throws { + let share = try CoherenceTemporaryShare() + let file = share.root.appendingPathComponent("edited-during-boot.txt") + try Data("before".utf8).write(to: file) + let hostFS = try HostFS(rootPath: share.root.path) + let entry = try hostFS.lookup(parent: HostFS.rootNodeID, name: file.lastPathComponent) + let capability = try hostCoherenceCapability(8) + let exchange = HostCoherenceExchangeRecorder() + let failures = HostCoherenceFailureRecorder() + let relay = try DoryFSWorkerHostCoherence( + generation: DoryFSWorkerGeneration(rawValue: 108), + shares: [(capability, hostFS, .invalidationAndWatcherNudge)], + exchange: exchange.exchange, + onFailure: failures.record + ) + defer { relay.stop() } + + try relay.prepare() + let prepared = relay.statistics + #expect(!prepared.running) + #expect(prepared.requiredObservationShareCount == 1) + #expect(prepared.observedRequiredShareCount == 1) + #expect(prepared.observationStreamCount == 1) + + try runExternal("/usr/bin/touch", [file.path]) + // Do not wait for the FSEvents callback. The activation-time known-inode sweep must make + // cache safety independent of when fseventsd journals this edit. + try relay.activateDelivery() + + #expect(relay.statistics.running) + #expect(exchange.containsInodeInvalidation(entry.nodeID)) + #expect(relay.statistics.deliveredBatchCount >= 1) + #expect(failures.error == nil) + } + + @Test func activationIsNotPublishedUntilCatchupAcknowledgementCompletes() async throws { + let share = try CoherenceTemporaryShare() + let file = share.root.appendingPathComponent("blocked-activation.txt") + let hostFS = try HostFS(rootPath: share.root.path) + let capability = try hostCoherenceCapability(10) + let exchange = BlockingHostCoherenceExchange() + let failures = HostCoherenceFailureRecorder() + let relay = try DoryFSWorkerHostCoherence( + generation: DoryFSWorkerGeneration(rawValue: 110), + shares: [(capability, hostFS, .invalidationAndWatcherNudge)], + exchange: exchange.exchange, + onFailure: failures.record + ) + defer { + exchange.release() + relay.stop() + } + + try relay.prepare() + try runExternal("/usr/bin/touch", [file.path]) + #expect(try await waitForPendingEvent(in: relay)) + + let first = Task.detached { try relay.activateDelivery() } + #expect(try await exchange.waitUntilBlocked()) + #expect(!relay.statistics.running) + + let secondCompletion = HostCoherenceCompletionRecorder() + let second = Task.detached { + try relay.activateDelivery() + secondCompletion.record() + } + try await Task.sleep(nanoseconds: 50_000_000) + #expect(!secondCompletion.completed) + #expect(!relay.statistics.running) + + exchange.release() + try await first.value + try await second.value + + #expect(secondCompletion.completed) + #expect(relay.statistics.running) + #expect(exchange.containsNudge(file.lastPathComponent)) + #expect(failures.error == nil) + } + + @Test func activationReplayRetainsCallbackContextUntilQueueDrain() async throws { + let share = try CoherenceTemporaryShare() + let hostFS = try HostFS(rootPath: share.root.path) + let capability = try hostCoherenceCapability(11) + let exchange = HostCoherenceExchangeRecorder() + let failures = HostCoherenceFailureRecorder() + let cleanup = HostCoherenceBlockingHook() + let completion = HostCoherenceCompletionRecorder() + let relay = try DoryFSWorkerHostCoherence( + generation: DoryFSWorkerGeneration(rawValue: 111), + shares: [(capability, hostFS, .invalidationAndWatcherNudge)], + exchange: exchange.exchange, + onFailure: failures.record + ) + relay.activationReplayCleanupQueueTestHook = cleanup.block + defer { + cleanup.release() + relay.stop() + } + + try relay.prepare() + let activation = Task.detached { + try relay.activateDelivery() + completion.record() + } + #expect(try await cleanup.waitUntilBlocked()) + #expect(!completion.completed) + #expect(!relay.statistics.running) + + cleanup.release() + try await activation.value + + #expect(completion.completed) + #expect(relay.statistics.running) + #expect(failures.error == nil) + } + + @Test func ignoreSelfSuppressesWorkerMutationButAcceptsDifferentPID() async throws { + let share = try CoherenceTemporaryShare() + let hostFS = try HostFS(rootPath: share.root.path) + let capability = try hostCoherenceCapability(4) + let exchange = HostCoherenceExchangeRecorder() + let failures = HostCoherenceFailureRecorder() + let relay = try DoryFSWorkerHostCoherence( + generation: DoryFSWorkerGeneration(rawValue: 104), + shares: [(capability, hostFS, .invalidationAndWatcherNudge)], + exchange: exchange.exchange, + onFailure: failures.record + ) + defer { relay.stop() } + try relay.activate() + let activationFrameCount = exchange.exactFrames.count + + try Data("guest write".utf8).write( + to: share.root.appendingPathComponent("same-worker-pid.txt") + ) + relay.flushObservationStreams() + try await Task.sleep(nanoseconds: 50_000_000) + relay.flushObservationStreams() + #expect(exchange.exactFrames.count == activationFrameCount) + + try runExternal("/usr/bin/touch", [ + share.root.appendingPathComponent("different-pid.txt").path, + ]) + relay.flushObservationStreams() + #expect(try await waitForNudge("different-pid.txt", in: exchange)) + #expect(failures.error == nil) + } + + @Test func readOnlyIdenticalAndNestedRootsRouteToEveryCapability() async throws { + let share = try CoherenceTemporaryShare() + let nested = share.root.appendingPathComponent("nested", isDirectory: true) + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: false) + let file = nested.appendingPathComponent("shared.txt") + try Data("before".utf8).write(to: file) + let outer = try HostFS(rootPath: share.root.path, readOnly: true) + let innerFirst = try HostFS(rootPath: nested.path, readOnly: true) + let innerSecond = try HostFS(rootPath: nested.path, readOnly: true) + let nestedEntry = try outer.lookup(parent: HostFS.rootNodeID, name: "nested") + _ = try outer.lookup(parent: nestedEntry.nodeID, name: "shared.txt") + _ = try innerFirst.lookup(parent: HostFS.rootNodeID, name: "shared.txt") + _ = try innerSecond.lookup(parent: HostFS.rootNodeID, name: "shared.txt") + let outerCapability = try hostCoherenceCapability(5) + let innerFirstCapability = try hostCoherenceCapability(6) + let innerSecondCapability = try hostCoherenceCapability(7) + let exchange = HostCoherenceExchangeRecorder() + let failures = HostCoherenceFailureRecorder() + let relay = try DoryFSWorkerHostCoherence( + generation: DoryFSWorkerGeneration(rawValue: 105), + shares: [ + (outerCapability, outer, .invalidationOnly), + (innerFirstCapability, innerFirst, .invalidationOnly), + (innerSecondCapability, innerSecond, .invalidationOnly), + ], + exchange: exchange.exchange, + onFailure: failures.record + ) + defer { relay.stop() } + try relay.activate() + let active = relay.statistics + #expect(active.configuredShareCount == 3) + #expect(active.invalidationOnlyShareCount == 3) + #expect(active.observationStreamCount == 3) + #expect(active.observedRequiredShareCount == 3) + + try runExternal("/usr/bin/touch", [file.path]) + relay.flushObservationStreams() + + let capabilities = Set([outerCapability, innerFirstCapability, innerSecondCapability]) + #expect(try await waitForCapabilities(capabilities, in: exchange)) + let batches = exchange.recordedBatches.filter { + capabilities.contains($0.shareCapabilityID) + } + #expect(Set(batches.map(\.shareCapabilityID)) == capabilities) + #expect(batches.allSatisfy { !$0.invalidations.isEmpty }) + #expect(batches.allSatisfy { $0.nudgeRelativePaths.isEmpty }) + #expect(failures.error == nil) + } +} + +private enum HostCoherenceRecorderError: Error { + case transientFailure + case processFailed(Int32) + case timedOut +} + +private final class HostCoherenceExchangeRecorder: @unchecked Sendable { + private let lock = NSLock() + private let failFirstAttempt: Bool + private var frames = [Data]() + private var batches = [DoryFSWorkerCoherenceBatch]() + + init(failFirstAttempt: Bool = false) { + self.failFirstAttempt = failFirstAttempt + } + + var exactFrames: [Data] { lock.withLock { frames } } + var recordedBatches: [DoryFSWorkerCoherenceBatch] { lock.withLock { batches } } + + func containsNudge(_ path: String) -> Bool { + lock.withLock { batches.contains { $0.nudgeRelativePaths.contains(path) } } + } + + func containsInodeInvalidation(_ nodeID: UInt64) -> Bool { + lock.withLock { + batches.contains { batch in + batch.invalidations.contains { invalidation in + if case .inode(let candidate, _, _) = invalidation { + return candidate == nodeID + } + return false + } + } + } + } + + func exchange(_ frame: Data) throws -> Data { + let batch = try DoryFSWorkerCoherenceCodec.decodeBatch(frame) + let attempt = lock.withLock { () -> Int in + frames.append(frame) + batches.append(batch) + return frames.count + } + if failFirstAttempt, attempt == 1 { + throw HostCoherenceRecorderError.transientFailure + } + return DoryFSWorkerCoherenceCodec.encode( + try DoryFSWorkerCoherenceAcknowledgement(accepting: batch) + ) + } +} + +private final class BlockingHostCoherenceExchange: @unchecked Sendable { + private let condition = NSCondition() + private var blocked = false + private var released = false + private var batches = [DoryFSWorkerCoherenceBatch]() + + func exchange(_ frame: Data) throws -> Data { + let batch = try DoryFSWorkerCoherenceCodec.decodeBatch(frame) + condition.lock() + batches.append(batch) + blocked = true + condition.broadcast() + let deadline = Date(timeIntervalSinceNow: 5) + while !released { + guard condition.wait(until: deadline) else { + condition.unlock() + throw HostCoherenceRecorderError.timedOut + } + } + condition.unlock() + return DoryFSWorkerCoherenceCodec.encode( + try DoryFSWorkerCoherenceAcknowledgement(accepting: batch) + ) + } + + func waitUntilBlocked() async throws -> Bool { + for _ in 0..<200 { + if condition.withLock({ blocked }) { return true } + try await Task.sleep(nanoseconds: 25_000_000) + } + return false + } + + func release() { + condition.withLock { + released = true + condition.broadcast() + } + } + + func containsNudge(_ path: String) -> Bool { + condition.withLock { + batches.contains { $0.nudgeRelativePaths.contains(path) } + } + } +} + +private final class HostCoherenceCompletionRecorder: @unchecked Sendable { + private let lock = NSLock() + private var didComplete = false + + var completed: Bool { lock.withLock { didComplete } } + + func record() { + lock.withLock { didComplete = true } + } +} + +private final class HostCoherenceBlockingHook: @unchecked Sendable { + private let condition = NSCondition() + private var blocked = false + private var released = false + + func block() { + condition.lock() + blocked = true + condition.broadcast() + let deadline = Date(timeIntervalSinceNow: 5) + while !released, condition.wait(until: deadline) {} + condition.unlock() + } + + func waitUntilBlocked() async throws -> Bool { + for _ in 0..<200 { + if condition.withLock({ blocked }) { return true } + try await Task.sleep(nanoseconds: 25_000_000) + } + return false + } + + func release() { + condition.withLock { + released = true + condition.broadcast() + } + } +} + +private final class HostCoherenceFailureRecorder: @unchecked Sendable { + private let lock = NSLock() + private var stored: DoryFSWorkerHostCoherenceError? + + var error: DoryFSWorkerHostCoherenceError? { lock.withLock { stored } } + + func record(_ error: DoryFSWorkerHostCoherenceError) { + lock.withLock { + if stored == nil { stored = error } + } + } +} + +private final class CoherenceTemporaryShare { + let base: URL + let root: URL + let staging: URL + + init(stagingContents: Data? = nil) throws { + base = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent( + "dory-host-coherence-\(UUID().uuidString)", + isDirectory: true + ) + root = base.appendingPathComponent("share", isDirectory: true) + staging = base.appendingPathComponent("staging", isDirectory: false) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + if let stagingContents { try stagingContents.write(to: staging) } + } + + deinit { try? FileManager.default.removeItem(at: base) } +} + +private func hostCoherenceCapability(_ byte: UInt8) throws -> DoryFSShareCapabilityID { + try DoryFSShareCapabilityID(rawValue: UUID(uuid: ( + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, byte + ))) +} + +private func runExternal(_ executable: String, _ arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + try process.run() + process.waitUntilExit() + guard process.terminationReason == .exit, process.terminationStatus == 0 else { + throw HostCoherenceRecorderError.processFailed(process.terminationStatus) + } +} + +private func waitForPathToExist(_ path: String) async throws -> Bool { + for _ in 0..<200 { + if FileManager.default.fileExists(atPath: path) { return true } + try await Task.sleep(nanoseconds: 25_000_000) + } + return false +} + +private func waitForPendingEvent( + in relay: DoryFSWorkerHostCoherence +) async throws -> Bool { + for _ in 0..<200 { + relay.flushObservationStreams() + if relay.statistics.pendingEventCount > 0 { return true } + try await Task.sleep(nanoseconds: 25_000_000) + } + return false +} + +private func waitForNudge( + _ path: String, + in recorder: HostCoherenceExchangeRecorder +) async throws -> Bool { + for _ in 0..<200 { + if recorder.containsNudge(path) { return true } + try await Task.sleep(nanoseconds: 25_000_000) + } + return false +} + +private func waitForAttempts( + _ count: Int, + in recorder: HostCoherenceExchangeRecorder +) async throws -> Bool { + for _ in 0..<200 { + if recorder.exactFrames.count >= count { return true } + try await Task.sleep(nanoseconds: 25_000_000) + } + return false +} + +private func waitForCapabilities( + _ capabilities: Set, + in recorder: HostCoherenceExchangeRecorder +) async throws -> Bool { + for _ in 0..<200 { + let observed = Set(recorder.recordedBatches.map(\.shareCapabilityID)) + if capabilities.isSubset(of: observed) { return true } + try await Task.sleep(nanoseconds: 25_000_000) + } + return false +} diff --git a/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerProcessResourcesTests.swift b/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerProcessResourcesTests.swift new file mode 100644 index 00000000..3c578ccc --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerProcessResourcesTests.swift @@ -0,0 +1,26 @@ +import Darwin +import Testing +@testable import DoryFSWorkerServiceCore + +struct DoryFSWorkerProcessResourcesTests { + @Test func descriptorSoftLimitIsBoundedByHardLimitAndWorkerCeiling() { + #expect(DoryFSWorkerProcessResources.desiredFileDescriptorSoftLimit( + current: 256, + hard: 1_048_576, + ceiling: 262_144 + ) == 262_144) + #expect(DoryFSWorkerProcessResources.desiredFileDescriptorSoftLimit( + current: 256, + hard: 32_768, + ceiling: 262_144 + ) == 32_768) + } + + @Test func descriptorSoftLimitNeverLowersAnExistingHigherLimit() { + #expect(DoryFSWorkerProcessResources.desiredFileDescriptorSoftLimit( + current: 1_048_576, + hard: rlim_t.max, + ceiling: 262_144 + ) == 1_048_576) + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerRootAuthorityTests.swift b/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerRootAuthorityTests.swift new file mode 100644 index 00000000..aab5b19e --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/DoryFSWorkerRootAuthorityTests.swift @@ -0,0 +1,552 @@ +import Darwin +import DoryFSWorkerContracts +@testable import DoryFSWorkerServiceCore +import Foundation +import Testing + +@Suite(.serialized) +struct DoryFSWorkerRootAuthorityTests { + @Test func rootAuthorityErrorsMapToBoundedNonSensitiveBootstrapReasons() throws { + let identifier = try capability(index: 1) + let cases: [(DoryFSWorkerRootAuthorityError, DoryFSWorkerRPCFailureCode)] = [ + (.descriptorCountMismatch(expected: 1, actual: 0), .bootstrapDescriptorTransferFailed), + (.rootDescriptorUnavailable(identifier, errno: EBADF), .bootstrapDescriptorTransferFailed), + (.rootInspectionFailed(identifier, errno: EIO), .bootstrapRootOpenFailed), + (.rootIsNotDirectory(identifier), .bootstrapRootOpenFailed), + (.rootIdentityMismatch(identifier), .bootstrapRootIdentityMismatch), + ] + + for (error, code) in cases { + #expect(error.bootstrapFailureCode == code) + } + } + + @Test func acceptsAllDescriptorsBySealedOrdinalAndBoundsBorrows() throws { + let tree = try TemporaryDirectoryTree() + let firstURL = try tree.makeDirectory("first") + let secondURL = try tree.makeDirectory("second") + let firstHandle = try openHandle(firstURL, directoryOnly: true) + let secondHandle = try openHandle(secondURL, directoryOnly: true) + let firstIdentity = try pinnedIdentity(of: firstHandle) + let secondIdentity = try pinnedIdentity(of: secondHandle) + let first = try share(index: 200, descriptorIndex: 0, identity: firstIdentity) + let second = try share(index: 1, descriptorIndex: 1, identity: secondIdentity) + let bootstrap = try makeBootstrap(shares: [first, second]) + var authority: DoryFSWorkerRootAuthority? = makeAuthority() + + let receiptBytes = try authority!.bootstrap( + exactBytes: DoryFSWorkerBootstrapCodec.encode(bootstrap), + rootDescriptors: [firstHandle, secondHandle] + ) + + #expect( + try DoryFSWorkerBootstrapCodec.decodeReceipt(receiptBytes) + == DoryFSWorkerBootstrapReceipt(accepting: bootstrap) + ) + #expect(matchingDescriptors(firstIdentity).count == 2) + #expect(matchingDescriptors(secondIdentity).count == 2) + + var escapedDescriptor: Int32 = -1 + try authority!.withBorrowedRootFileDescriptor(for: first.capabilityID) { descriptor in + escapedDescriptor = descriptor + #expect(descriptorNames(descriptor, identity: firstIdentity)) + #expect(fcntl(descriptor, F_GETFD) & FD_CLOEXEC != 0) + } + #expect(!descriptorNames(escapedDescriptor, identity: firstIdentity)) + + authority = nil + #expect(matchingDescriptors(firstIdentity).count == 1) + #expect(matchingDescriptors(secondIdentity).count == 1) + } + + @Test func descriptorCountMismatchConsumesAttemptWithoutOpeningRoots() throws { + let tree = try TemporaryDirectoryTree() + let root = try tree.makeDirectory("root") + let handle = try openHandle(root, directoryOnly: true) + let identity = try pinnedIdentity(of: handle) + let authorityShare = try share(index: 2, descriptorIndex: 0, identity: identity) + let bytes = try encodedBootstrap([authorityShare]) + let baseline = matchingDescriptors(identity) + let authority = makeAuthority() + + #expect(throws: DoryFSWorkerRootAuthorityError.descriptorCountMismatch( + expected: 1, + actual: 0 + )) { + _ = try authority.bootstrap(exactBytes: bytes, rootDescriptors: []) + } + #expect(matchingDescriptors(identity) == baseline) + #expect(throws: DoryFSWorkerRootAuthorityError.bootstrapAlreadyAttempted) { + _ = try authority.bootstrap(exactBytes: bytes, rootDescriptors: [handle]) + } + } + + @Test func closedTransferredDescriptorIsRejectedWithoutPathFallback() throws { + let tree = try TemporaryDirectoryTree() + let root = try tree.makeDirectory("root") + let descriptor = Darwin.open(root.path, O_RDONLY | O_DIRECTORY | O_CLOEXEC) + #expect(descriptor >= 0) + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: false) + let identity = try pinnedIdentity(of: handle) + let authorityShare = try share(index: 3, descriptorIndex: 0, identity: identity) + _ = Darwin.close(descriptor) + let authority = makeAuthority() + + do { + _ = try authority.bootstrap( + exactBytes: encodedBootstrap([authorityShare]), + rootDescriptors: [handle] + ) + Issue.record("closed descriptor unexpectedly accepted") + } catch let error as DoryFSWorkerRootAuthorityError { + guard case .rootDescriptorUnavailable(let capability, let observedErrno) = error else { + Issue.record("unexpected error: \(error)") + return + } + #expect(capability == authorityShare.capabilityID) + #expect(observedErrno == EBADF) + } + } + + @Test func nonDirectoryDescriptorIsRejected() throws { + let tree = try TemporaryDirectoryTree() + let file = try tree.makeFile("ordinary-file") + let handle = try openHandle(file, directoryOnly: false) + let authorityShare = try share( + index: 4, + descriptorIndex: 0, + identity: pinnedIdentity(of: handle) + ) + let authority = makeAuthority() + + #expect(throws: DoryFSWorkerRootAuthorityError.rootIsNotDirectory( + authorityShare.capabilityID + )) { + _ = try authority.bootstrap( + exactBytes: encodedBootstrap([authorityShare]), + rootDescriptors: [handle] + ) + } + } + + @Test func descriptorIdentityMismatchIsRejected() throws { + let tree = try TemporaryDirectoryTree() + let sealed = try openHandle(tree.makeDirectory("sealed"), directoryOnly: true) + let replacement = try openHandle(tree.makeDirectory("replacement"), directoryOnly: true) + let authorityShare = try share( + index: 5, + descriptorIndex: 0, + identity: pinnedIdentity(of: sealed) + ) + let authority = makeAuthority() + + #expect(throws: DoryFSWorkerRootAuthorityError.rootIdentityMismatch( + authorityShare.capabilityID + )) { + _ = try authority.bootstrap( + exactBytes: encodedBootstrap([authorityShare]), + rootDescriptors: [replacement] + ) + } + } + + @Test func partialFailureRollsBackEveryEarlierDuplicate() throws { + let tree = try TemporaryDirectoryTree() + let valid = try openHandle(tree.makeDirectory("valid"), directoryOnly: true) + let sealedInvalid = try openHandle(tree.makeDirectory("sealed-invalid"), directoryOnly: true) + let replacement = try openHandle(tree.makeDirectory("replacement"), directoryOnly: true) + let validIdentity = try pinnedIdentity(of: valid) + let validShare = try share(index: 6, descriptorIndex: 0, identity: validIdentity) + let invalidShare = try share( + index: 7, + descriptorIndex: 1, + identity: pinnedIdentity(of: sealedInvalid) + ) + let baseline = matchingDescriptors(validIdentity) + let authority = makeAuthority() + + #expect(throws: DoryFSWorkerRootAuthorityError.rootIdentityMismatch( + invalidShare.capabilityID + )) { + _ = try authority.bootstrap( + exactBytes: encodedBootstrap([validShare, invalidShare]), + rootDescriptors: [valid, replacement] + ) + } + #expect(matchingDescriptors(validIdentity) == baseline) + #expect(throws: DoryFSWorkerRootAuthorityError.bootstrapNotAccepted) { + try authority.withBorrowedRootFileDescriptor(for: validShare.capabilityID) { _ in () } + } + } + + @Test func sharedAdmissionRejectsSecondAuthorityObject() throws { + let tree = try TemporaryDirectoryTree() + let handle = try openHandle(tree.makeDirectory("root"), directoryOnly: true) + let authorityShare = try share( + index: 8, + descriptorIndex: 0, + identity: pinnedIdentity(of: handle) + ) + let bytes = try encodedBootstrap([authorityShare]) + let gate = DoryFSWorkerBootstrapAdmission() + let first = DoryFSWorkerRootAuthority(bootstrapAdmission: gate) + let second = DoryFSWorkerRootAuthority(bootstrapAdmission: gate) + + _ = try first.bootstrap(exactBytes: bytes, rootDescriptors: [handle]) + #expect(throws: DoryFSWorkerRootAuthorityError.bootstrapAlreadyAttempted) { + _ = try second.bootstrap(exactBytes: bytes, rootDescriptors: [handle]) + } + } + + @Test func malformedEnvelopeConsumesTheOnlyBootstrapAttempt() throws { + let tree = try TemporaryDirectoryTree() + let handle = try openHandle(tree.makeDirectory("root"), directoryOnly: true) + let authorityShare = try share( + index: 9, + descriptorIndex: 0, + identity: pinnedIdentity(of: handle) + ) + let valid = try encodedBootstrap([authorityShare]) + var malformed = valid + malformed.append(0) + let authority = makeAuthority() + + #expect(throws: DoryFSWorkerBootstrapError.bootstrapLengthMismatch( + declared: UInt32(valid.count), + actual: malformed.count + )) { + _ = try authority.bootstrap(exactBytes: malformed, rootDescriptors: [handle]) + } + #expect(throws: DoryFSWorkerRootAuthorityError.bootstrapAlreadyAttempted) { + _ = try authority.bootstrap(exactBytes: valid, rootDescriptors: [handle]) + } + } + + @Test func unknownCapabilityAndThrownBorrowDoNotLeakDescriptors() throws { + enum BorrowFailure: Error { case expected } + + let tree = try TemporaryDirectoryTree() + let handle = try openHandle(tree.makeDirectory("root"), directoryOnly: true) + let identity = try pinnedIdentity(of: handle) + let authorityShare = try share(index: 10, descriptorIndex: 0, identity: identity) + let authority = makeAuthority() + _ = try authority.bootstrap( + exactBytes: encodedBootstrap([authorityShare]), + rootDescriptors: [handle] + ) + let beforeBorrow = matchingDescriptors(identity) + var escaped: Int32 = -1 + + #expect(throws: BorrowFailure.expected) { + try authority.withBorrowedRootFileDescriptor(for: authorityShare.capabilityID) { + escaped = $0 + throw BorrowFailure.expected + } + } + #expect(!descriptorNames(escaped, identity: identity)) + #expect(matchingDescriptors(identity) == beforeBorrow) + let unknown = try capability(index: 11) + #expect(throws: DoryFSWorkerRootAuthorityError.unknownCapability(unknown)) { + try authority.withBorrowedRootFileDescriptor(for: unknown) { _ in () } + } + } + + @Test func serviceBootstrapUsesTransferredRootAndRetainsHostFSAuthority() throws { + let tree = try TemporaryDirectoryTree() + let root = try tree.makeDirectory("root") + let handle = try openHandle(root, directoryOnly: true) + let authorityShare = try share( + index: 12, + descriptorIndex: 0, + identity: pinnedIdentity(of: handle) + ) + let bootstrap = try makeBootstrap(shares: [authorityShare]) + let service = DoryFSWorkerService(rootAuthority: makeAuthority()) + + let receiptBytes = try unwrapRPC(service.bootstrap( + exactBytes: DoryFSWorkerBootstrapCodec.encode(bootstrap), + rootDescriptors: [handle] + )) + #expect( + try DoryFSWorkerBootstrapCodec.decodeReceipt(receiptBytes) + == DoryFSWorkerBootstrapReceipt(accepting: bootstrap) + ) + + let request = try DoryFSWorkerRequest( + generation: bootstrap.generation, + shareCapabilityID: authorityShare.capabilityID, + requestID: 1, + correlationID: 101, + opcodeClass: .metadata, + responseCapacity: UInt32(FuseOutHeader.byteCount + 104), + deadlineUptimeNanoseconds: DispatchTime.now().uptimeNanoseconds + 5_000_000_000, + payload: Data( + FuseProtocol.encodeInHeader(FuseInHeader( + length: UInt32(FuseInHeader.byteCount + 16), + opcode: FuseOpcode.getattr.rawValue, + unique: 101, + nodeID: HostFS.rootNodeID, + uid: 1_000, + gid: 1_000, + pid: 42 + )) + FuseProtocol.encodeGetattrIn(FuseGetattrIn()) + ) + ) + let frame = try DoryFSWorkerFrameCodec.encode( + .execute(request), + maximumFrameBytes: DoryFSWorkerLimits.production.maximumFrameBytes + ) + let response = try unwrapRPC(service.exchange(exactFrame: frame)) + guard case .reply(let reply) = try DoryFSWorkerFrameCodec.decodeServiceFrame( + response, + maximumFrameBytes: DoryFSWorkerLimits.production.maximumFrameBytes + ), case .completed(let payload) = reply.outcome else { + Issue.record("service did not complete root getattr") + return + } + #expect(try FuseProtocol.decodeOutHeader([UInt8](payload)).error == 0) + } + + @Test func serviceReturnsBoundedDescriptorTransferFailure() throws { + let tree = try TemporaryDirectoryTree() + let handle = try openHandle(tree.makeDirectory("root"), directoryOnly: true) + let authorityShare = try share( + index: 13, + descriptorIndex: 0, + identity: pinnedIdentity(of: handle) + ) + let service = DoryFSWorkerService(rootAuthority: makeAuthority()) + let result = try DoryFSWorkerRPCResultCodec.decode(service.bootstrap( + exactBytes: encodedBootstrap([authorityShare]), + rootDescriptors: [] + )) + #expect(result == .failure(.bootstrapDescriptorTransferFailed)) + } + + @Test func serviceRejectsAdvertisedCoherenceWithoutAnExchange() throws { + let tree = try TemporaryDirectoryTree() + let handle = try openHandle(tree.makeDirectory("root"), directoryOnly: true) + let authorityShare = try share( + index: 14, + descriptorIndex: 0, + identity: pinnedIdentity(of: handle), + coherencePolicy: .invalidationOnly + ) + let service = DoryFSWorkerService(rootAuthority: makeAuthority()) + + let result = try DoryFSWorkerRPCResultCodec.decode(service.bootstrap( + exactBytes: encodedBootstrap([authorityShare]), + rootDescriptors: [handle] + )) + + #expect(result == .failure(.bootstrapRejected)) + #expect(try DoryFSWorkerRPCResultCodec.decode(service.bootstrap( + exactBytes: encodedBootstrap([authorityShare]), + rootDescriptors: [handle] + )) == .failure(.bootstrapAlreadyAttempted)) + } + + @Test func serviceAllowsDisabledOnlySharesWithoutAnExchange() throws { + let tree = try TemporaryDirectoryTree() + let handle = try openHandle(tree.makeDirectory("root"), directoryOnly: true) + let authorityShare = try share( + index: 15, + descriptorIndex: 0, + identity: pinnedIdentity(of: handle), + coherencePolicy: .disabled + ) + let bootstrap = try makeBootstrap(shares: [authorityShare]) + let service = DoryFSWorkerService(rootAuthority: makeAuthority()) + + let receipt = try unwrapRPC(service.bootstrap( + exactBytes: DoryFSWorkerBootstrapCodec.encode(bootstrap), + rootDescriptors: [handle] + )) + + #expect( + try DoryFSWorkerBootstrapCodec.decodeReceipt(receipt) + == DoryFSWorkerBootstrapReceipt(accepting: bootstrap) + ) + } + + @Test func serviceSeparatesObservationPreparationFromDeliveryActivation() throws { + let tree = try TemporaryDirectoryTree() + let handle = try openHandle(tree.makeDirectory("root"), directoryOnly: true) + let authorityShare = try share( + index: 16, + descriptorIndex: 0, + identity: pinnedIdentity(of: handle), + coherencePolicy: .invalidationOnly + ) + let bootstrap = try makeBootstrap(shares: [authorityShare]) + let service = DoryFSWorkerService( + coherenceExchange: { exactFrame in + let batch = try DoryFSWorkerCoherenceCodec.decodeBatch(exactFrame) + return DoryFSWorkerCoherenceCodec.encode( + try DoryFSWorkerCoherenceAcknowledgement(accepting: batch) + ) + }, + onCoherenceFailure: { _ in } + ) + + _ = try unwrapRPC(service.bootstrap( + exactBytes: DoryFSWorkerBootstrapCodec.encode(bootstrap), + rootDescriptors: [handle] + )) + + let prepared = try DoryFSWorkerCoherenceStatusCodec.decode( + service.prepareCoherenceExactBytes() + ) + #expect(prepared.generation == bootstrap.generation) + #expect(!prepared.running) + #expect(prepared.configuredShareCount == 1) + #expect(prepared.observationStreamCount == 1) + #expect( + prepared.requiredObservationShareCount == prepared.observedRequiredShareCount + ) + + let active = try DoryFSWorkerCoherenceStatusCodec.decode( + service.activateCoherenceExactBytes() + ) + #expect(active.generation == bootstrap.generation) + #expect(active.running) + #expect(active.observationStreamCount == 1) + } +} + +private enum RootAuthorityTestError: Error { + case unexpectedRPCFailure(DoryFSWorkerRPCFailureCode) +} + +private func unwrapRPC(_ data: Data) throws -> Data { + switch try DoryFSWorkerRPCResultCodec.decode(data) { + case .success(let payload): + return payload + case .failure(let code): + throw RootAuthorityTestError.unexpectedRPCFailure(code) + } +} + +private final class TemporaryDirectoryTree { + let root: URL + + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent( + "dory-fs-root-authority-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + } + + func makeDirectory(_ name: String) throws -> URL { + let url = root.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false) + return url + } + + func makeFile(_ name: String) throws -> URL { + let url = root.appendingPathComponent(name, isDirectory: false) + guard FileManager.default.createFile(atPath: url.path, contents: Data([0x44])) else { + throw CocoaError(.fileWriteUnknown) + } + return url + } + + deinit { + try? FileManager.default.removeItem(at: root) + } +} + +private func makeAuthority() -> DoryFSWorkerRootAuthority { + DoryFSWorkerRootAuthority(bootstrapAdmission: DoryFSWorkerBootstrapAdmission()) +} + +private func encodedBootstrap( + _ shares: [DoryFSShareBootstrapAuthority] +) throws -> Data { + try DoryFSWorkerBootstrapCodec.encode(makeBootstrap(shares: shares)) +} + +private func makeBootstrap( + shares: [DoryFSShareBootstrapAuthority] +) throws -> DoryFSWorkerBootstrap { + try DoryFSWorkerBootstrap( + workspaceID: DoryFSWorkerWorkspaceID( + rawValue: #require(UUID(uuidString: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")) + ), + generation: DoryFSWorkerGeneration(rawValue: 17), + workerLimits: .production, + shares: shares + ) +} + +private func share( + index: Int, + descriptorIndex: UInt16, + identity: DoryFSPinnedRootIdentity, + coherencePolicy: DoryFSShareCoherencePolicy = .disabled +) throws -> DoryFSShareBootstrapAuthority { + try DoryFSShareBootstrapAuthority( + capabilityID: capability(index: index), + expectedRootIdentity: identity, + readOnly: index.isMultiple(of: 2), + coherencePolicy: coherencePolicy, + guestIdentity: DoryFSGuestIdentityPolicy(uid: 1_000, gid: 1_000), + resourceLimits: .production, + rootDescriptorIndex: descriptorIndex, + hiddenComponents: [".git"], + rootHiddenComponents: ["library"] + ) +} + +private func capability(index: Int) throws -> DoryFSShareCapabilityID { + precondition((1...255).contains(index)) + return try DoryFSShareCapabilityID(rawValue: UUID(uuid: ( + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, UInt8(index) + ))) +} + +private func openHandle(_ url: URL, directoryOnly: Bool) throws -> FileHandle { + let flags = O_RDONLY | O_CLOEXEC | (directoryOnly ? O_DIRECTORY : 0) + let descriptor = Darwin.open(url.path, flags) + guard descriptor >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + return FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) +} + +private func pinnedIdentity(of handle: FileHandle) throws -> DoryFSPinnedRootIdentity { + var status = stat() + guard fstat(handle.fileDescriptor, &status) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + return try identity(status) +} + +private func identity(_ status: stat) throws -> DoryFSPinnedRootIdentity { + try DoryFSPinnedRootIdentity( + device: UInt64(truncatingIfNeeded: status.st_dev), + inode: UInt64(truncatingIfNeeded: status.st_ino), + generation: UInt64(truncatingIfNeeded: status.st_gen) + ) +} + +private func descriptorNames( + _ descriptor: Int32, + identity expected: DoryFSPinnedRootIdentity +) -> Bool { + var status = stat() + guard descriptor >= 0, fstat(descriptor, &status) == 0 else { return false } + return (try? identity(status)) == expected +} + +private func matchingDescriptors(_ expected: DoryFSPinnedRootIdentity) -> Set { + var result = Set() + for descriptor in Int32(0).. FuseResourceLimits { + FuseResourceLimits( + maximumLiveNonRootNodes: nodes, + maximumFileHandles: files, + maximumDirectoryHandles: directories, + maximumDirectoryCursorEntries: cursorEntries, + maximumDirectoryCursorNameBytes: cursorNameBytes, + maximumAdvisoryLockOwners: owners, + maximumPendingBlockingLocks: pending + ) +} + +private func quotaReadDirPayload( + handle: UInt64, + offset: UInt64, + maximumBytes: UInt32 +) -> [UInt8] { + quotaBytes(handle) + quotaBytes(offset) + quotaBytes(maximumBytes) + quotaBytes(UInt32(0)) + + quotaBytes(UInt64(0)) + quotaBytes(UInt32(0)) + quotaBytes(UInt32(0)) +} + +private func quotaDirentPlusLength(nameByteCount: Int) -> Int { + (128 + 24 + nameByteCount + 7) & ~7 +} + +private func quotaRequest( + unique: UInt64, + opcode: FuseOpcode, + nodeID: UInt64, + payload: [UInt8] = [] +) -> [UInt8] { + FuseProtocol.encodeInHeader(FuseInHeader( + length: UInt32(FuseInHeader.byteCount + payload.count), + opcode: opcode.rawValue, + unique: unique, + nodeID: nodeID, + uid: 1000, + gid: 1000, + pid: 42 + )) + payload +} + +private func quotaPayload(from response: [UInt8]) -> [UInt8] { + Array(response.dropFirst(FuseOutHeader.byteCount)) +} + +private func quotaBytes(_ value: UInt32) -> [UInt8] { + var value = value.littleEndian + return withUnsafeBytes(of: &value) { Array($0) } +} + +private func quotaBytes(_ value: UInt64) -> [UInt8] { + var value = value.littleEndian + return withUnsafeBytes(of: &value) { Array($0) } +} + +private func quotaLockPayload( + handle: UInt64, + owner: UInt64, + type: UInt32 +) -> [UInt8] { + quotaBytes(handle) + quotaBytes(owner) + quotaBytes(UInt64(0)) + quotaBytes(UInt64(7)) + + quotaBytes(type) + quotaBytes(UInt32(42)) + quotaBytes(UInt32(0)) + quotaBytes(UInt32(0)) +} + +private func quotaFlushPayload(handle: UInt64, owner: UInt64) -> [UInt8] { + quotaBytes(handle) + quotaBytes(UInt32(0)) + quotaBytes(UInt32(0)) + quotaBytes(owner) +} + +private func quotaReleasePayload(handle: UInt64, owner: UInt64) -> [UInt8] { + quotaFlushPayload(handle: handle, owner: owner) +} + +private func quotaWaitUntil( + timeout: TimeInterval = 2, + condition: () -> Bool +) -> Bool { + let deadline = ProcessInfo.processInfo.systemUptime + timeout + while ProcessInfo.processInfo.systemUptime < deadline { + if condition() { return true } + Thread.sleep(forTimeInterval: 0.005) + } + return condition() +} + +private func exerciseQuotaShutdown(hostFS: HostFS) throws { + let server = FuseServer(hostFS: hostFS) + let lookup = server.handle(request: quotaRequest( + unique: 200, + opcode: .lookup, + nodeID: HostFS.rootNodeID, + payload: Array("shutdown.txt\0".utf8) + )) + guard try FuseProtocol.decodeOutHeader(lookup).error == 0 else { + throw QuotaTestFixtureError.lookupFailed + } + let nodeID = quotaPayload(from: lookup).leUInt64(at: 0) + let open = server.handle(request: quotaRequest( + unique: 201, + opcode: .open, + nodeID: nodeID, + payload: quotaBytes(UInt32(O_RDWR)) + quotaBytes(UInt32(0)) + )) + guard try FuseProtocol.decodeOutHeader(open).error == 0 else { + throw QuotaTestFixtureError.openFailed + } + let fileHandle = quotaPayload(from: open).leUInt64(at: 0) + let openDirectory = server.handle(request: quotaRequest( + unique: 202, + opcode: .opendir, + nodeID: HostFS.rootNodeID + )) + guard try FuseProtocol.decodeOutHeader(openDirectory).error == 0 else { + throw QuotaTestFixtureError.openDirectoryFailed + } + let lock = server.handle(request: quotaRequest( + unique: 203, + opcode: .setlk, + nodeID: nodeID, + payload: quotaLockPayload(handle: fileHandle, owner: 901, type: 1) + )) + guard try FuseProtocol.decodeOutHeader(lock).error == 0 else { + throw QuotaTestFixtureError.lockFailed + } + let snapshot = server.resourceSnapshot + guard snapshot.liveNonRootNodes == 1, + snapshot.fileHandles == 1, + snapshot.directoryHandles == 1, + snapshot.advisoryLockOwners == 1 else { + throw QuotaTestFixtureError.unexpectedSnapshot + } +} + +private extension Array where Element == UInt8 { + func leUInt32(at offset: Int) -> UInt32 { + UInt32(self[offset]) + | UInt32(self[offset + 1]) << 8 + | UInt32(self[offset + 2]) << 16 + | UInt32(self[offset + 3]) << 24 + } + + func leUInt64(at offset: Int) -> UInt64 { + UInt64(leUInt32(at: offset)) | UInt64(leUInt32(at: offset + 4)) << 32 + } +} + +private final class QuotaTestRoot { + let url: URL + + init() throws { + url = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("dory-fuse-quota-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + + deinit { + try? FileManager.default.removeItem(at: url) + } + + func write(_ text: String, to relativePath: String) throws { + try text.write(to: url.appendingPathComponent(relativePath), atomically: true, encoding: .utf8) + } +} + +private final class QuotaAdmissionResults: @unchecked Sendable { + private let lock = NSLock() + private var tokens: [FuseResourceToken] = [] + private var recordedErrors: [FuseResourceQuotaError] = [] + private var recordedUnexpectedErrors: [String] = [] + + var successCount: Int { lock.withLock { tokens.count } } + var errors: [FuseResourceQuotaError] { lock.withLock { recordedErrors } } + var unexpectedErrors: [String] { lock.withLock { recordedUnexpectedErrors } } + + func record(token: FuseResourceToken) { + lock.withLock { tokens.append(token) } + } + + func record(error: FuseResourceQuotaError) { + lock.withLock { recordedErrors.append(error) } + } + + func recordUnexpected(_ error: Error) { + lock.withLock { recordedUnexpectedErrors.append(String(describing: error)) } + } + + func releaseAll() { + let admitted = lock.withLock { () -> [FuseResourceToken] in + let admitted = tokens + tokens.removeAll(keepingCapacity: false) + return admitted + } + admitted.forEach { $0.release() } + } +} + +private final class ConcurrentLookupResults: @unchecked Sendable { + private let lock = NSLock() + private var successes = 0 + private var recordedErrors: [FuseResourceQuotaError] = [] + private var recordedUnexpectedErrors: [String] = [] + + var successCount: Int { lock.withLock { successes } } + var errors: [FuseResourceQuotaError] { lock.withLock { recordedErrors } } + var unexpectedErrors: [String] { lock.withLock { recordedUnexpectedErrors } } + + func recordSuccess() { + lock.withLock { successes += 1 } + } + + func record(error: FuseResourceQuotaError) { + lock.withLock { recordedErrors.append(error) } + } + + func recordUnexpected(_ error: Error) { + lock.withLock { recordedUnexpectedErrors.append(String(describing: error)) } + } +} + +private final class QuotaLockedResponse: @unchecked Sendable { + private let lock = NSLock() + private var response: [UInt8] = [] + + func store(_ response: [UInt8]) { + lock.withLock { self.response = response } + } + + func load() -> [UInt8] { + lock.withLock { response } + } +} + +private final class QuotaLockFixture: @unchecked Sendable { + let root: QuotaTestRoot + let server: FuseServer + let nodeID: UInt64 + let fileHandle: UInt64 + + init(limits: FuseResourceLimits) throws { + root = try QuotaTestRoot() + try root.write("lock", to: "lock.txt") + server = FuseServer(hostFS: try HostFS(rootPath: root.url.path, resourceLimits: limits)) + let lookup = server.handle(request: quotaRequest( + unique: 100, + opcode: .lookup, + nodeID: HostFS.rootNodeID, + payload: Array("lock.txt\0".utf8) + )) + guard try FuseProtocol.decodeOutHeader(lookup).error == 0 else { + throw QuotaTestFixtureError.lookupFailed + } + nodeID = quotaPayload(from: lookup).leUInt64(at: 0) + let open = server.handle(request: quotaRequest( + unique: 101, + opcode: .open, + nodeID: nodeID, + payload: quotaBytes(UInt32(O_RDWR)) + quotaBytes(UInt32(0)) + )) + guard try FuseProtocol.decodeOutHeader(open).error == 0 else { + throw QuotaTestFixtureError.openFailed + } + fileHandle = quotaPayload(from: open).leUInt64(at: 0) + } + + func lockRequest(unique: UInt64, owner: UInt64, type: UInt32, blocking: Bool) -> [UInt8] { + quotaRequest( + unique: unique, + opcode: blocking ? .setlkw : .setlk, + nodeID: nodeID, + payload: quotaLockPayload(handle: fileHandle, owner: owner, type: type) + ) + } +} + +private enum QuotaTestFixtureError: Error { + case lookupFailed + case openFailed + case openDirectoryFailed + case lockFailed + case unexpectedSnapshot +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/FuseServerTests.swift b/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/FuseServerTests.swift similarity index 84% rename from Packages/ContainerizationEngine/Tests/DoryHVTests/FuseServerTests.swift rename to Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/FuseServerTests.swift index b2b14930..36c80380 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/FuseServerTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryFSWorkerServiceCoreTests/FuseServerTests.swift @@ -1,9 +1,38 @@ import Darwin +import DoryFSWorkerContracts import Foundation import Testing -@testable import DoryHV +@testable import DoryFSWorkerServiceCore struct FuseServerTests { + @Test func syncfsFallsBackWithoutFailingConnectionAndDestroyAcknowledgesTeardown() throws { + let root = try TestFuseServerRoot() + let server = try FuseServer(hostFS: HostFS(rootPath: root.url.path)) + + let sync = server.handle(request: request( + unique: 8, + opcode: .syncfs, + nodeID: HostFS.rootNodeID, + payload: [UInt8](repeating: 0, count: 8) + )) + let destroy = server.handle(request: request( + unique: 9, + opcode: .destroy, + nodeID: 0 + )) + + #expect( + try FuseProtocol.decodeOutHeader(sync).error + == -FuseProtocol.linuxErrno(ENOSYS) + ) + #expect(try FuseProtocol.decodeOutHeader(sync).unique == 8) + #expect(try FuseProtocol.decodeOutHeader(destroy) == FuseOutHeader( + length: UInt32(FuseOutHeader.byteCount), + error: 0, + unique: 9 + )) + } + @Test func lookupGetattrOpenReadAndReleaseFlow() throws { let root = try TestFuseServerRoot() try root.write("hello dory", to: "hello.txt") @@ -103,11 +132,8 @@ struct FuseServerTests { ) // The old node ID remains a valid inode while LOOKUP/FH refs exist. Atomic replacement // detaches its pathname, but GETATTR returns the pinned old inode instead of leaking ESTALE - // to an ordinary path open. VirtioFS uses this direct response path in production. - let firstPathGetattr = try directGetattrResponse( - server: server, - request: pathGetattrRequest - ) + // to an ordinary path open. + let firstPathGetattr = server.handle(request: pathGetattrRequest) #expect(try FuseProtocol.decodeOutHeader(firstPathGetattr).error == 0) #expect(payload(from: firstPathGetattr).leUInt64(at: 24) == 3) #expect(payload(from: firstPathGetattr).leUInt32(at: 80) == 0) @@ -172,14 +198,9 @@ struct FuseServerTests { )) ) let handleGetattr = server.handle(request: handleGetattrRequest) - let directHandleGetattr = try directGetattrResponse( - server: server, - request: handleGetattrRequest - ) let handleAttributes = payload(from: handleGetattr) #expect(try FuseProtocol.decodeOutHeader(handleGetattr).error == 0) - #expect(directHandleGetattr == handleGetattr) #expect(handleAttributes.leUInt64(at: 16) == oldNodeID) #expect(handleAttributes.leUInt64(at: 24) == 3) #expect(handleAttributes.leUInt32(at: 80) == 0) @@ -251,7 +272,7 @@ struct FuseServerTests { )) } - @Test func getattrFileHandleRejectsMismatchedAndUnknownHandlesOnBothPaths() throws { + @Test func getattrFileHandleRejectsMismatchedAndUnknownHandles() throws { let root = try TestFuseServerRoot() try root.write("first", to: "first.txt") try root.write("second", to: "second.txt") @@ -299,10 +320,8 @@ struct FuseServerTests { ] for invalidRequest in invalidRequests { - let arrayResponse = server.handle(request: invalidRequest) - let directResponse = try directGetattrResponse(server: server, request: invalidRequest) - #expect(try FuseProtocol.decodeOutHeader(arrayResponse).error == -EBADF) - #expect(directResponse == arrayResponse) + let response = server.handle(request: invalidRequest) + #expect(try FuseProtocol.decodeOutHeader(response).error == -EBADF) } } @@ -338,20 +357,13 @@ struct FuseServerTests { let writePayload = bytes(readHandle) + bytes(UInt64(0)) + bytes(UInt32(1)) + bytes(UInt32(0)) + bytes(UInt64(0)) + bytes(UInt32(0)) + bytes(UInt32(0)) + Array("X".utf8) - let arrayWrite = server.handle(request: request( + let rejectedWrite = server.handle(request: request( unique: 333, opcode: .write, nodeID: oldNodeID, payload: writePayload )) - #expect(try FuseProtocol.decodeOutHeader(arrayWrite).error == -EBADF) - let directWrite = try directWriteResponse(server: server, request: request( - unique: 334, - opcode: .write, - nodeID: oldNodeID, - payload: writePayload - )) - #expect(try FuseProtocol.decodeOutHeader(directWrite).error == -EBADF) + #expect(try FuseProtocol.decodeOutHeader(rejectedWrite).error == -EBADF) let truncate = server.handle(request: request( unique: 335, @@ -375,20 +387,13 @@ struct FuseServerTests { let writeHandle = payload(from: writeOpen).leUInt64(at: 0) let readPayload = bytes(writeHandle) + bytes(UInt64(0)) + bytes(UInt32(32)) + bytes(UInt32(0)) + bytes(UInt64(0)) + bytes(UInt32(0)) + bytes(UInt32(0)) - let arrayRead = server.handle(request: request( + let rejectedRead = server.handle(request: request( unique: 337, opcode: .read, nodeID: oldNodeID, payload: readPayload )) - #expect(try FuseProtocol.decodeOutHeader(arrayRead).error == -EBADF) - let directRead = try directReadResponse(server: server, request: request( - unique: 338, - opcode: .read, - nodeID: oldNodeID, - payload: readPayload - )) - #expect(try FuseProtocol.decodeOutHeader(directRead).error == -EBADF) + #expect(try FuseProtocol.decodeOutHeader(rejectedRead).error == -EBADF) let wrongNodeRead = server.handle(request: request( unique: 339, @@ -492,22 +497,14 @@ struct FuseServerTests { let readPayload = bytes(handle) + bytes(UInt64(0)) + bytes(UInt32(32)) + bytes(UInt32(0)) + bytes(UInt64(0)) + bytes(UInt32(0)) + bytes(UInt32(0)) - let arrayRead = server.handle(request: request( + let read = server.handle(request: request( unique: 353, opcode: .read, nodeID: nodeID, payload: readPayload )) - #expect(try FuseProtocol.decodeOutHeader(arrayRead).error == 0) - #expect(String(decoding: payload(from: arrayRead), as: UTF8.self) == "writeback") - let directRead = try directReadResponse(server: server, request: request( - unique: 354, - opcode: .read, - nodeID: nodeID, - payload: readPayload - )) - #expect(try FuseProtocol.decodeOutHeader(directRead).error == 0) - #expect(String(decoding: payload(from: directRead), as: UTF8.self) == "writeback") + #expect(try FuseProtocol.decodeOutHeader(read).error == 0) + #expect(String(decoding: payload(from: read), as: UTF8.self) == "writeback") _ = server.handle(request: request( unique: 355, opcode: .release, @@ -702,18 +699,7 @@ struct FuseServerTests { let hostFS = try HostFS(rootPath: root.url.path) let server = FuseServer(hostFS: hostFS) func rollback(_ response: [UInt8], opcode: FuseOpcode) { - var storage = response - storage.withUnsafeMutableBytes { raw in - server.rollbackUnpublishedResponse( - opcode: opcode, - writable: [VirtqueueSegment( - pointer: raw.baseAddress!, - length: raw.count, - isDeviceWritable: true - )], - written: raw.count - ) - } + server.rollbackUnpublishedResponse(opcode: opcode, response: response) } let droppedLookup = server.handle(request: request( @@ -1024,19 +1010,8 @@ struct FuseServerTests { nodeID: HostFS.rootNodeID, payload: releaseDirectoryIn ) - let releaseDirectoryHeader = try FuseProtocol.decodeInHeader(releaseDirectoryRequest) - let releaseDirectoryPayload = releaseDirectoryRequest[FuseInHeader.byteCount.. Int in - let segment = VirtqueueSegment(pointer: buffer.baseAddress!, length: buffer.count, isDeviceWritable: true) - return server.writeReleaseResponse( - header: releaseDirectoryHeader, - payload: releaseDirectoryPayload, - writable: [segment] - ) - } - #expect(releaseCount == FuseOutHeader.byteCount) - #expect(try FuseProtocol.decodeOutHeader(releaseDestination).error == 0) + let releasedDirectory = server.handle(request: releaseDirectoryRequest) + #expect(try FuseProtocol.decodeOutHeader(releasedDirectory).error == 0) let readIn = bytes(fileHandle) + bytes(UInt64(0)) + bytes(UInt32(7)) + bytes(UInt32(0)) + bytes(UInt64(0)) + bytes(UInt32(0)) + bytes(UInt32(0)) @@ -1067,7 +1042,7 @@ struct FuseServerTests { )).isEmpty) } - @Test func zeroCopyReadMatchesArrayPath() throws { + @Test func readReturnsRequestedSlice() throws { let root = try TestFuseServerRoot() try root.write("hello dory world", to: "z.txt") let server = try FuseServer(hostFS: HostFS(rootPath: root.url.path)) @@ -1077,57 +1052,35 @@ struct FuseServerTests { let readIn = bytes(handle) + bytes(UInt64(6)) + bytes(UInt32(4)) + bytes(UInt32(0)) + bytes(UInt64(0)) + bytes(UInt32(0)) + bytes(UInt32(0)) let req = request(unique: 3, opcode: .read, nodeID: nodeID, payload: readIn) - let arrayPath = server.handle(request: req) + let response = server.handle(request: req) - let header = try FuseProtocol.decodeInHeader(req) - let readPayload = Array(req[FuseInHeader.byteCount.. Int in - let segment = VirtqueueSegment(pointer: buffer.baseAddress!, length: buffer.count, isDeviceWritable: true) - return server.writeReadResponse(header: header, payload: readPayload, writable: [segment]) - } - - #expect(Array(dest[0.. Int in - let segment = VirtqueueSegment(pointer: buffer.baseAddress!, length: buffer.count, isDeviceWritable: true) - return server.writeWriteResponse(header: writeHeader, payload: writePayload, writable: [segment]) - } + let written = server.handle(request: writeRequest) - #expect(writeCount == FuseOutHeader.byteCount + 8) - #expect(try FuseProtocol.decodeOutHeader(Array(writeDest)).error == 0) - #expect(payload(from: Array(writeDest)).leUInt32(at: 0) == UInt32(writeData.count)) + #expect(try FuseProtocol.decodeOutHeader(written).error == 0) + #expect(payload(from: written).leUInt32(at: 0) == UInt32(writeData.count)) let releaseIn = bytes(handle) + bytes(UInt32(0)) + bytes(UInt32(0)) + bytes(UInt64(0)) let releaseRequest = request(unique: 103, opcode: .release, nodeID: nodeID, payload: releaseIn) - let releaseHeader = try FuseProtocol.decodeInHeader(releaseRequest) - let releasePayload = releaseRequest[FuseInHeader.byteCount.. Int in - let segment = VirtqueueSegment(pointer: buffer.baseAddress!, length: buffer.count, isDeviceWritable: true) - return server.writeReleaseResponse(header: releaseHeader, payload: releasePayload, writable: [segment]) - } + let released = server.handle(request: releaseRequest) - #expect(releaseCount == FuseOutHeader.byteCount) - #expect(try FuseProtocol.decodeOutHeader(Array(releaseDest)).error == 0) - #expect(try String(contentsOf: root.url.appendingPathComponent("direct.txt"), encoding: .utf8) == "hello direct") + #expect(try FuseProtocol.decodeOutHeader(released).error == 0) + #expect(try String(contentsOf: root.url.appendingPathComponent("written.txt"), encoding: .utf8) == "hello dory") } @Test func releaseCannotCloseDescriptorBorrowedByConcurrentWrite() async throws { @@ -1188,89 +1141,60 @@ struct FuseServerTests { #expect(try FuseProtocol.decodeOutHeader(stale).error == -EBADF) } - @Test func directMetadataMissResponsesMatchArrayPath() throws { + @Test func lookupMissHitAndGetxattrResponsesAreCanonical() throws { let root = try TestFuseServerRoot() try root.write("exists", to: "exists.txt") - let arrayServer = try FuseServer(hostFS: HostFS(rootPath: root.url.path)) - let directHostFS = try HostFS(rootPath: root.url.path) - let directServer = FuseServer(hostFS: directHostFS) + let hostFS = try HostFS(rootPath: root.url.path) + let server = FuseServer(hostFS: hostFS) let missingRequest = request(unique: 201, opcode: .lookup, nodeID: HostFS.rootNodeID, payload: Array("missing.txt\0".utf8)) - let missingHeader = try FuseProtocol.decodeInHeader(missingRequest) - let missingPayload = missingRequest[FuseInHeader.byteCount.. Int in - let segment = VirtqueueSegment(pointer: buffer.baseAddress!, length: buffer.count, isDeviceWritable: true) - return directServer.writeLookupResponse(header: missingHeader, payload: missingPayload, writable: [segment]) - } + let missing = server.handle(request: missingRequest) // Misses are always plain ENOENT. A negative dentry could hide a concurrently created path. - #expect(missingCount == FuseOutHeader.byteCount) - #expect(Array(missingDest[0.. Int in - let segment = VirtqueueSegment(pointer: buffer.baseAddress!, length: buffer.count, isDeviceWritable: true) - return directServer.writeLookupResponse(header: hitHeader, payload: hitPayload, writable: [segment]) - } + let hit = server.handle(request: hitRequest) - #expect(hitCount == FuseOutHeader.byteCount + 128) - #expect(Array(hitDest[0..