Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/desktop.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,14 @@ jobs:
run: python -c "import torch; assert torch.__version__ == '2.6.0+cu124'"
- name: Run protocol and runtime-boundary tests
working-directory: .
run: |
python -m unittest discover -s desktop/tests -v
communityai-desktop --self-test
env:
# The desktop tests share synthetic publication documents with the root suite.
PYTHONPATH: ${{ github.workspace }}/tests
PYTEST_DISABLE_PLUGIN_AUTOLOAD: "1"
run: python -m pytest -q desktop/tests
- name: Run installed desktop self-test
working-directory: .
run: communityai-desktop --self-test
- name: Verify normalized archive consumers
working-directory: .
run: python -m unittest discover -s tests -p test_runtime_archive_hardlinks.py -v
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,19 @@ jobs:
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Install Linux Qt runtime libraries
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends --yes \
libdbus-1-3 libegl1 libgl1 libglib2.0-0 libfontconfig1 libfreetype6 \
libxkbcommon0 libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 \
libxcb-image0 libxcb-keysyms1 libxcb-render-util0 libxcb-shape0 libwayland-cursor0 xvfb xauth
- name: Install dependencies
run: |
uv sync --extra dev --python ${{ matrix.python-version }}
# Desktop tests exercise the real Qt runtime, including subprocess diagnostics.
uv pip install --python .venv/bin/python -e ./desktop
- name: Test Factory source contract
env:
PYTHONPATH: src:desktop/src
Expand Down
1 change: 1 addition & 0 deletions desktop/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies = [
[project.optional-dependencies]
dev = [
"pyinstaller>=6.11,<7",
"pytest==6.2.5",
]

[project.scripts]
Expand Down
10 changes: 8 additions & 2 deletions desktop/runtime_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,18 @@ def normalize_runtime(node_root: Path, *, target_platform: str, torch_version: s
source, source_identity = canonical[digest]
if identity[:2] == source_identity[:2]:
continue
if _identity(source) != source_identity or _identity(path) != identity:
# A same-length rewrite can preserve size and even mtime on a
# coarse filesystem. Recheck content before replacing a path.
if _identity(source) != source_identity or _identity(path) != identity or _sha256(source) != digest:
raise RuntimeError("native library changed during normalization")
temporary = path.with_name(f".{path.name}.hardlink-{uuid.uuid4().hex}")
try:
os.link(source, temporary, follow_symlinks=False)
if _identity(source) != source_identity or _identity(path) != identity:
if (
_identity(source) != source_identity
or _identity(path) != identity
or _sha256(temporary) != digest
):
raise RuntimeError("native library changed during normalization")
os.replace(temporary, path)
finally:
Expand Down
3 changes: 1 addition & 2 deletions desktop/src/communityai_desktop/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,8 @@ def connect() -> DesktopController:

updater = None
if qualification_automation is None and profile is None:
from PySide6.QtCore import QStandardPaths

from communityai_desktop.updater import UpdateManager, installed_root
from PySide6.QtCore import QStandardPaths

root = installed_root()
if root is not None:
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/communityai_desktop/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ def __init__(
self.node_url = normalize_loopback_url(node_url)
self.credential_store = credential_store
self.config_path = _absolute_path(config_path)
self.data_dir = Path(data_dir).expanduser().resolve()
# Keep the caller's path spelling. On Windows, resolve() expands an
# 8.3 alias and makes the fixed-profile sidecar reject the same directory.
self.data_dir = _absolute_path(data_dir)
default_node_command = node_command is None
self.node_command = tuple(node_command or _default_node_command())
if not self.node_command or any(not isinstance(part, str) or not part for part in self.node_command):
Expand Down
3 changes: 1 addition & 2 deletions desktop/src/communityai_desktop/model_health.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Live block coverage, observed peers, and this computer's artifact transfers."""

from communityai_desktop.presentation import model_name
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QFrame,
Expand All @@ -15,8 +16,6 @@
QWidget,
)

from communityai_desktop.presentation import model_name

COLORS = {
"covered": "#237851",
"replicated": "#35b779",
Expand Down
5 changes: 4 additions & 1 deletion desktop/tests/test_build_desktop.py
Original file line number Diff line number Diff line change
Expand Up @@ -1083,7 +1083,10 @@ class VolunteerBuildIsolationTests(unittest.TestCase):
def setUp(self) -> None:
self._temporary_directory = TemporaryDirectory()
self.addCleanup(self._temporary_directory.cleanup)
self.tmp_path = Path(self._temporary_directory.name)
# macOS exposes its temporary directory through /var -> /private/var.
# Keep these safe fixtures on the canonical path; link-rejection tests
# below still pass their explicit linked paths to the production guard.
self.tmp_path = Path(self._temporary_directory.name).resolve()
self.project = self.tmp_path / "repository" / "desktop"
icon = self.project / "src" / "communityai_desktop" / "assets" / "communityai.ico"
icon.parent.mkdir(parents=True)
Expand Down
3 changes: 1 addition & 2 deletions desktop/tests/test_cgroup_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
from tempfile import TemporaryDirectory
from unittest.mock import patch

from desktop import cgroup_extension as extension
from desktop import launch_node
from desktop import cgroup_extension as extension, launch_node


class CgroupExtensionTests(unittest.TestCase):
Expand Down
3 changes: 1 addition & 2 deletions desktop/tests/test_gpu_sharing_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,11 @@

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

from communityai_desktop.resource_controls import GpuResourceControls
from PySide6.QtCore import Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QAbstractSlider, QApplication

from communityai_desktop.resource_controls import GpuResourceControls


def state():
capacities = (8, 12, 16, 24, 32, 40, 48, 80)
Expand Down
5 changes: 2 additions & 3 deletions desktop/tests/test_host_memory_policy_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

import test_gpu_sharing_integration as gpu_fixture
from communityai_desktop.client import NodeClientError, _normalize_policy
from communityai_desktop.presentation import sharing_reason
from PySide6.QtCore import QPoint, QRect, QTimer
from PySide6.QtGui import QFontDatabase, QPalette
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QLabel, QLineEdit, QPlainTextEdit, QScrollArea

from communityai_desktop.client import NodeClientError, _normalize_policy
from communityai_desktop.presentation import sharing_reason


def contrast(foreground, background):
def luminance(color):
Expand Down
6 changes: 3 additions & 3 deletions desktop/tests/test_model_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@

os.environ["QT_QPA_PLATFORM"] = "offscreen"

from communityai_desktop.model_health import DownloadCard, DownloadsPanel, ModelHealthCard
from communityai_desktop.telemetry import download_view, route_view
from PySide6.QtCore import Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication

from communityai_desktop.model_health import DownloadCard, DownloadsPanel, ModelHealthCard
from communityai_desktop.telemetry import download_view, route_view


class ModelHealthTests(unittest.TestCase):
@classmethod
Expand Down Expand Up @@ -265,6 +264,7 @@ def test_typed_memory_rejection_keeps_managed_guidance_after_loading_state_is_cl
from communityai_desktop.acceptance import _FakeNodeState
from communityai_desktop.client import _normalize_contribution_status
from communityai_desktop.controller import DesktopController

from drift.node.server import _contribution_status

fixture = _FakeNodeState()
Expand Down
3 changes: 1 addition & 2 deletions desktop/tests/test_resource_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

from PySide6.QtWidgets import QApplication

from communityai_desktop.acceptance import fake_node
from communityai_desktop.client import NodeClient, NodeClientError
from communityai_desktop.controller import DesktopController
from communityai_desktop.resource_controls import ResourceControls
from PySide6.QtWidgets import QApplication


class ResourceControlsTests(unittest.TestCase):
Expand Down
5 changes: 2 additions & 3 deletions desktop/tests/test_resource_playthrough.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QPushButton, QWidget

from communityai_desktop.app import main
from communityai_desktop.resource_controls import ResourceControls
from communityai_desktop.resource_playthrough import ResourcePlaythrough
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QPushButton, QWidget


class ResourcePlaythroughTests(unittest.TestCase):
Expand Down
3 changes: 1 addition & 2 deletions desktop/tests/test_resource_recovery_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,11 @@

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

from PySide6.QtCore import QTimer

from communityai_desktop.acceptance import fake_node
from communityai_desktop.client import NodeClient
from communityai_desktop.controller import DesktopController
from communityai_desktop.presentation import recovery_reason
from PySide6.QtCore import QTimer


def test_recovery_keeps_off_and_pause_truth_while_gating_only_start():
Expand Down
10 changes: 9 additions & 1 deletion desktop/tests/test_runtime_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,25 @@ def test_changed_source_fails_before_link_replacement(self):
first = self.write("_internal/a.so")
second = self.write("_internal/b.so")
real_hash = packaging._sha256
original_stat = first.stat()
original_identity = packaging._identity(first)
# This rewrite keeps the original byte length and mtime, so metadata
# alone cannot detect a stale source digest before hardlink replacement.
self.assertEqual(len(b"poison-library"), len(b"native-library"))

def hash_and_change(path):
digest = real_hash(path)
if path == first:
first.write_bytes(b"changed-longer")
first.write_bytes(b"poison-library")
os.utime(first, ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns))
self.assertEqual(packaging._identity(first), original_identity)
return digest

with patch.object(packaging, "_sha256", side_effect=hash_and_change):
with self.assertRaisesRegex(RuntimeError, "changed during"):
self.normalize()
self.assertFalse(os.path.samefile(first, second))
self.assertEqual(second.read_bytes(), b"native-library")

def test_deb_staging_preserves_hardlinks_even_across_device_copy_fallback(self):
first = self.write("a.so", b"a" * 1025)
Expand Down
3 changes: 1 addition & 2 deletions desktop/tests/test_simple_desktop.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,10 @@ def test_stale_poll_error_does_not_cancel_a_successful_start(self):
self._exercise_poll_race(True)

def _exercise_poll_race(self, fail_poll):
from communityai_desktop.pyside_shell import run
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QLabel

from communityai_desktop.pyside_shell import run

with fake_node(all_workers_paused=True) as (url, token):
state = DesktopController(NodeClient(url, token)).snapshot()
state["hardware"] = {
Expand Down
4 changes: 2 additions & 2 deletions desktop/tests/test_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ def test_existing_instance_protocol_distinguishes_manual_and_login_launch(self):
from communityai_desktop.pyside_shell import run

application = QApplication.instance() or QApplication([])
name = f"communityai-test-{uuid.uuid4().hex}"
name = f"cai-{uuid.uuid4().hex}"
QLocalServer.removeServer(name)
server = QLocalServer(application)
server.setSocketOptions(QLocalServer.UserAccessOption)
Expand Down Expand Up @@ -440,7 +440,7 @@ def test_primary_instance_releases_owned_endpoint_and_lock(self):

from communityai_desktop.pyside_shell import run

name = f"communityai-owner-test-{uuid.uuid4().hex}"
name = f"cai-{uuid.uuid4().hex}"

def unavailable():
raise RuntimeError("offline for UI smoke")
Expand Down
44 changes: 44 additions & 0 deletions desktop/tests/test_volunteer_node_launcher.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import builtins
import ctypes
import importlib.util
import io
import json
Expand Down Expand Up @@ -539,6 +540,49 @@ def bootstrap_runner(command, **kwargs):
self.assertEqual(bootstrap_calls[0][1]["cwd"], str(self.profile.data_dir))
self.assertEqual(len(self.dispatches), 3)

@unittest.skipUnless(os.name == "nt", "Windows 8.3 path aliases")
def test_windows_short_profile_path_keeps_lifecycle_arguments_private(self):
length = ctypes.windll.kernel32.GetShortPathNameW(str(self.home), None, 0)
if not length:
self.skipTest("Windows did not provide a short path")
buffer = ctypes.create_unicode_buffer(length)
if not ctypes.windll.kernel32.GetShortPathNameW(str(self.home), buffer, length):
self.skipTest("Windows did not provide a short path")
short_home = Path(buffer.value)
if os.path.normcase(str(short_home)) == os.path.normcase(str(self.home)):
self.skipTest("8.3 aliases are disabled for this fixture")

profile = VolunteerProfile(short_home / ".communityai" / "multigpu-volunteer")
profile.prepare()
from communityai_desktop.lifecycle import NodeLifecycleSupervisor

supervisor = NodeLifecycleSupervisor(
profile.node_url,
types.SimpleNamespace(service=profile.credential_service, account=profile.credential_account),
config_path=profile.config_path,
data_dir=profile.data_dir,
node_command=(str(self.home / "volunteer-node"),),
pause_sharing_on_start=True,
local_inference_cpu_only=True,
allow_external_node=False,
)
with patch.object(VolunteerProfile, "for_current_user", return_value=profile):
self.assertEqual(self._run(supervisor._command()[1:]), 0)
self.assertEqual(self.dispatches[-1][1], profile.data_dir)

outside = self.home / "ordinary" / "escape.json"
with self.assertRaisesRegex(ValueError, "fixed profile root"):
launcher._node_arguments(["--data_dir", str(outside)], profile)
outside.parent.mkdir()
linked = profile.data_dir / "linked"
try:
linked.symlink_to(outside.parent, target_is_directory=True)
except OSError:
pass # External absolute path above still exercises the escape rejection.
else:
with self.assertRaisesRegex(ValueError, "symbolic links or junctions"):
launcher._node_arguments(["--data_dir", str(linked)], profile)

def test_worker_requires_inherited_current_parent_and_profile_before_dispatch(self):
arguments = self._worker()
for parent, root in (
Expand Down
44 changes: 27 additions & 17 deletions scripts/benchmark_qwen_local_gpu.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,23 +62,33 @@ def main() -> int:
torch.cuda.synchronize(device)
seconds = time.monotonic() - began
tokens = int(output.shape[-1] - prompt["input_ids"].shape[-1])
results.append({"output_tokens": tokens, "seconds": round(seconds, 3),
"tokens_per_second_including_prefill": round(tokens / seconds, 3)})
print(json.dumps({
"scope": "offline single-GPU Transformers baseline, not vLLM or release qualification",
"model": "Qwen/Qwen3-1.7B",
"revision": REVISION,
"backend": "transformers",
"transformers_version": __import__("transformers").__version__,
"torch_version": torch.__version__,
"gpu": torch.cuda.get_device_name(device),
"gpu_count_used": 1,
"prompt_tokens": int(prompt["input_ids"].shape[-1]),
"load_seconds": round(loaded_seconds, 3),
"peak_cuda_bytes": torch.cuda.max_memory_allocated(device),
"runs": results,
"median_16_token_seconds": round(statistics.median(run["seconds"] for run in results[1:]), 3),
}, sort_keys=True))
results.append(
{
"output_tokens": tokens,
"seconds": round(seconds, 3),
"tokens_per_second_including_prefill": round(tokens / seconds, 3),
}
)
print(
json.dumps(
{
"scope": "offline single-GPU Transformers baseline, not vLLM or release qualification",
"model": "Qwen/Qwen3-1.7B",
"revision": REVISION,
"backend": "transformers",
"transformers_version": __import__("transformers").__version__,
"torch_version": torch.__version__,
"gpu": torch.cuda.get_device_name(device),
"gpu_count_used": 1,
"prompt_tokens": int(prompt["input_ids"].shape[-1]),
"load_seconds": round(loaded_seconds, 3),
"peak_cuda_bytes": torch.cuda.max_memory_allocated(device),
"runs": results,
"median_16_token_seconds": round(statistics.median(run["seconds"] for run in results[1:]), 3),
},
sort_keys=True,
)
)
return 0


Expand Down
Loading
Loading