Skip to content
Merged
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
67 changes: 67 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,73 @@ jobs:
run: uv run pytest tests/ -v --ignore=tests/test_browser_bridge.py --timeout=120
timeout-minutes: 10

# Windows is the primary live-recording platform: this job runs the full
# unit suite AND the live Recorder integration tests (tests/test_performance.py,
# marked 'slow'), which start the real multiprocessing pipeline (pynput
# listeners -> writer processes -> per-capture SQLite + video) and verify
# clean startup, bounded shutdown/memory, and db creation. The three
# listener-dependent tests (roundtrip/reuse/throughput) skip here via
# OPENADAPT_CI_NO_INPUT_INJECTION: hosted runners execute jobs in a
# non-interactive session, so injected input never reaches pynput's hooks;
# run those on an interactive Windows desktop. These tests skip everywhere
# except Windows, so this job is the org's live-recording CI proof.
test-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"

- name: Set up Python
run: uv python install 3.12

- name: Install dependencies
run: uv sync --extra dev

- name: Run unit tests
run: uv run pytest tests/ -v --ignore=tests/test_browser_bridge.py -m "not slow" --timeout=120
timeout-minutes: 10

- name: Run live recorder integration tests
env:
# Non-interactive session: SendInput never reaches pynput's
# low-level hooks, so the event-capture tests skip (see
# tests/test_performance.py); pipeline start/stop/db/memory
# tests still run the live recorder for real.
OPENADAPT_CI_NO_INPUT_INJECTION: "1"
run: uv run pytest tests/test_performance.py -v -m slow --timeout=300
timeout-minutes: 20

# macOS coverage: runs the unit suite (event processing, storage, video
# encode/decode via PyAV, headless-import invariant, Recorder API surface —
# CGWindowList-backed screen capture works on GitHub macOS runners). The
# 'slow' live-recording tests stay skipped here: pynput input injection
# requires Accessibility permissions a hosted runner cannot grant, and the
# multiprocessing 'spawn' writer path is not yet validated on macOS (see
# tests/test_performance.py).
test-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"

- name: Set up Python
run: uv python install 3.12

- name: Install dependencies
run: uv sync --extra dev

- name: Run unit tests
run: uv run pytest tests/ -v --ignore=tests/test_browser_bridge.py -m "not slow" --timeout=120
timeout-minutes: 10

lint:
runs-on: ubuntu-latest
steps:
Expand Down
25 changes: 22 additions & 3 deletions openadapt_capture/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,14 +435,24 @@ def load(cls, capture_dir: str | Path) -> "CaptureSession":
from openadapt_capture.db.models import Recording

session = get_session_for_path(str(db_path))

def _discard_session() -> None:
# Close the session AND dispose its engine: the pool otherwise
# keeps the SQLite file handle open, which on Windows leaves
# recording.db locked (WinError 32 when deleting the directory).
bind = session.get_bind()
session.close()
if bind is not None:
bind.dispose()

try:
recording = session.query(Recording).first()
except Exception:
session.close()
_discard_session()
raise

if recording is None:
session.close()
_discard_session()
raise FileNotFoundError(f"Invalid capture (no recording found): {capture_dir}")

return cls(capture_dir, session, recording)
Expand Down Expand Up @@ -627,9 +637,18 @@ def get_frame_at(self, timestamp: float, tolerance: float = 0.5) -> "Image" | No
return None

def close(self) -> None:
"""Close the capture and release resources."""
"""Close the capture and release resources.

Disposes the session's engine as well: SQLAlchemy's connection pool
keeps the SQLite file handle open after ``Session.close()``, which on
Windows leaves ``recording.db`` locked — deleting the capture
directory would fail with WinError 32 (sharing violation).
"""
if self._session is not None:
bind = self._session.get_bind()
self._session.close()
if bind is not None:
bind.dispose()
self._session = None

def __enter__(self) -> "CaptureSession":
Expand Down
19 changes: 13 additions & 6 deletions openadapt_capture/db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,16 @@ def get_session_for_path(db_path: str, echo: bool = False):
"""
db_url = f"sqlite:///{db_path}"
engine = get_engine(db_url, echo=echo)
# Older recording.db files may predate columns the models now expect;
# add any missing ones so loading them does not fail with 'no such
# column'. Safe/no-op when the schema is already current.
migrate_missing_columns(engine)
Session = get_session_maker(engine)
return Session()
try:
# Older recording.db files may predate columns the models now expect;
# add any missing ones so loading them does not fail with 'no such
# column'. Safe/no-op when the schema is already current.
migrate_missing_columns(engine)
Session = get_session_maker(engine)
return Session()
except Exception:
# A corrupt/unreadable db raises above; dispose the engine so its
# pooled connection does not keep the file handle open (on Windows
# a lingering handle makes the capture directory undeletable).
engine.dispose()
raise
28 changes: 23 additions & 5 deletions openadapt_capture/recorder.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
"""Script for creating Recordings.

Copied from legacy OpenAdapt record.py. Only import paths changed +
adaptation for per-capture databases.
"""OpenAdapt's original, hand-built recorder — the live recording engine.

This is the production recorder carried forward from OpenAdapt's record.py:
the most battle-tested recording code in the org, refined against years of
real desktop workflows. It is the recorder behind OpenAdapt's desktop path
(openadapt-flow ``record --backend windows|rdp`` wraps ``Recorder``).

Architecture: a multiprocessing pipeline. pynput listeners feed raw
mouse/keyboard events into synchronized queues; dedicated writer processes
persist action events, screenshots, video frames, and (optionally) audio and
window state into a per-capture SQLite database plus time-aligned media files.
Adapted from the original for per-capture databases. Importing this module
must never touch the display (no screenshot at module scope — enforced by
tests/test_headless_import.py); pynput itself may be unavailable headless, in
which case the package ``__init__`` degrades ``Recorder`` to ``None``.

Usage:

Expand Down Expand Up @@ -252,7 +263,14 @@ def process_events(
prev_saved_window_timestamp = 0
started = False
while not terminate_processing.is_set() or not event_q.empty():
event = event_q.get()
# Bounded get: a bare event_q.get() deadlocks shutdown when terminate
# is set while the queue is empty and the readers have already exited
# (nobody left to feed an event, so the loop condition is never
# re-checked and join_tasks() hangs forever on this thread).
try:
event = event_q.get(timeout=1)
except queue.Empty:
continue
if not started:
started_event.set()
started = True
Expand Down
10 changes: 5 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ classifiers = [

dependencies = [
"pydantic>=2.0.0",
"pynput>=1.7.0",
"av>=10.0.0",
"Pillow>=9.0.0",
"pynput>=1.7.6",
"av>=12.0.0",
"Pillow>=10.1.0",
"fire>=0.7.1",
"mss>=6.0.0",
"sounddevice>=0.5.3",
Expand All @@ -33,14 +33,14 @@ dependencies = [
"pydantic-settings>=2.12.0",
"openai>=2.11.0",
"websockets>=12.0",
# Legacy recording dependencies (matching OpenAdapt record.py)
# Recording-pipeline dependencies (carried forward from OpenAdapt record.py)
"sqlalchemy>=2.0.0",
"alembic>=1.0.0",
"loguru>=0.7.0",
"psutil>=5.0.0",
"pympler>=1.0.0",
"tqdm>=4.0.0",
"numpy>=1.20.0",
"numpy>=1.26.0",
"oa-atomacos>=3.2.0; sys_platform == 'darwin'",
]

Expand Down
23 changes: 21 additions & 2 deletions tests/test_highlevel.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,25 @@
Recorder = None


# Sessions/engines created by _create_test_recording, released by the
# temp_capture_dir teardown BEFORE the TemporaryDirectory is removed. Both
# must be released: crud.insert_recording ends with session.refresh(), which
# leaves the connection checked out until the session closes, and the engine
# pool keeps the SQLite file handle open until dispose(). On Windows an open
# recording.db makes the rmtree fail with WinError 32 (sharing violation).
_HELPER_SESSIONS = []
_HELPER_ENGINES = []


@pytest.fixture
def temp_capture_dir():
"""Create a temporary directory for captures."""
with tempfile.TemporaryDirectory() as tmpdir:
yield tmpdir
while _HELPER_SESSIONS:
_HELPER_SESSIONS.pop().close()
while _HELPER_ENGINES:
_HELPER_ENGINES.pop().dispose()


def _create_test_recording(capture_dir, task_description="Test task"):
Expand All @@ -34,7 +48,9 @@ def _create_test_recording(capture_dir, task_description="Test task"):
os.makedirs(capture_dir, exist_ok=True)
db_path = os.path.join(capture_dir, "recording.db")
engine, Session = create_db(db_path)
_HELPER_ENGINES.append(engine)
session = Session()
_HELPER_SESSIONS.append(session)

timestamp = time.time()
recording_data = {
Expand Down Expand Up @@ -355,8 +371,10 @@ def test_session_leak_on_no_recording(self, temp_capture_dir):
capture_path = str(Path(temp_capture_dir) / "capture")
os.makedirs(capture_path, exist_ok=True)
db_path = os.path.join(capture_path, "recording.db")
# Create DB with tables but no recording row
create_db(db_path)
# Create DB with tables but no recording row (dispose the setup
# engine so only Capture.load's own handle is under test)
engine, _ = create_db(db_path)
engine.dispose()

with pytest.raises(FileNotFoundError, match="no recording found"):
Capture.load(capture_path)
Expand Down Expand Up @@ -513,6 +531,7 @@ def test_pixel_ratio_round_trips_through_model(self, temp_capture_dir):
},
)
session.close()
engine.dispose()

# The column is real: it is present in the on-disk schema.
con = sqlite3.connect(db_path)
Expand Down
Loading
Loading