phase: 134_sync_cancel
Build and Push Containers / build-and-push-app (push) Successful in 2m26s
Build and Push Containers / build-and-push-db (push) Successful in 13s

**Phase 134 final verification pass — all green** (all 3 task files already in `complete/`; no implementation was missing)

- Verified: 5-state `SyncStatus` + `cancelled` terminal (error None, final counts kept), `POST /api/sync/cancel` (202/409/403), cooperative `_check_cancelled` boundaries, shutdown `CancelledError` contract preserved, `#sync-cancel-btn` show/hide + neutral "Sync cancelled" settle, 8-branch poll tree, re-attach — code, unit, integration, and E2E layers all present
- `uv run pytest --cov=app --cov-report=term-missing` → **3150 passed**, `app/` coverage **99%** (gate: >90%)
- `uv run pytest tests/e2e/test_sync_cancel.py -v --no-cov` (isolated) → **3 passed** (47.6 s)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings

**Completion criteria:** ① cancel endpoint 202/409/403 ✅ (unit + E2E) ② `cancelled` terminal reported by status, error null, counts kept ✅ (integration `test_cancel_mid_run_lands_in_cancelled_then_resyncs_clean`) ③ unrequested-cancel pin ✅ (`test_unrequested_cancel_is_never_swallowed_and_never_converts`) ④ UI lifecycle + clean re-sync (sentinel row, full 40-file import) ✅ (E2E) ⑤ suite/coverage/E2E gates ✅ ⑥ uncancelled runs byte-identical ✅ (all 3150 pre-existing tests green untouched)

- Docs: README.md reviewed in full against code (pages, auth endpoints, every config default vs `app/config.py`, palette 9+8, fail-loud, cancel section) — consistent, no changes needed. AGENTS.md: fixed one pre-existing inaccuracy (rule 2 "move its file" → "move its directory" — phases are directories); everything else verified accurate
- No live infrastructure touched; no deviations

**Next pending phase:** `135_git_source_branch`
This commit is contained in:
2026-09-28 18:08:11 -04:00
parent 25de518dc9
commit fcac876d40
26 changed files with 2535 additions and 30 deletions
+797 -3
View File
@@ -59,18 +59,59 @@ positions between ticks (1/38 → 13/38) through the runner's own
position clears at every point the import phase ends (the
overview/summaries transitions + both terminals) while the FILE
counts keep their final values.
Phase 134 (task 01): the FIFTH state — ``cancelled`` — joins the
contract. The admin-only ``POST /api/sync/cancel`` (the 403 is pinned
in the integration suite — the router's ``require_admin`` dependency,
no DB/HTTP layer here) is 202 ``cancel requested`` while a run is in
flight (it sets the module ``_cancel_event`` AND ``_task.cancel()``)
and 409 ``no sync is running`` idle / after a terminal run. A
user-requested cancel converts the ``CancelledError`` into the
``cancelled`` terminal (``error`` None, ``detail`` empty, the final
file counts kept, the task ends CANCELLED); an UNREQUESTED cancel
(bare ``task.cancel()`` — the app-shutdown path) re-raises with the
state untouched (never swallowed — the completion criterion's pin);
``start_sync`` clears a stale event so the next run completes to
``success``. The cancel runs here are driven through the REAL
``start_sync`` on a worker loop — ``sync_api._task`` is the endpoint's
target — and the cancel is issued FROM that loop thread (a same-thread
``Task.cancel()`` — a cross-thread cancel would schedule through the
loop's selector wake, which a parked loop never receives).
Phase 134 (task 02): the FRONTEND cancel contract is pinned at the
source level (the browser behavior is E2E-covered in
tests/e2e/test_sync_cancel.py, task 03): the ship-hidden
``#sync-cancel-btn`` markup right after ``#sync-btn`` (the visible
"Cancel" label is the accessible name — WCAG 2.4), the
``.sync-cancel-btn`` secondary-pill CSS (the 44px floor, the AA
ink-soft pair, the guarded hover, the :disabled + ``[hidden]`` rules,
the full-width mobile rule next to the main button's), the show/hide
helpers (show = unhide + enable + aria-label, hide = hide + disable),
the two choke points (``enterSyncRunningState`` shows for kind
"sync" / hides for kind "upload" — the upload job is NOT cancellable
in this phase — and ``settleSyncButton`` hides on EVERY settle), the
``requestCancel`` never-stale lifecycle (disable FIRST, then
POST /api/sync/cancel; the 403 backstop, the silent 409, the 202
wait, the phase-55 retry copy + re-enable on any other failure), the
poll tree's new 5th branch (after failed, before the upload branches
— the 7-branch tree is now 8), the neutral ``applySyncCancelled``
(no modal, no banner, no error styling), and the
``initSyncButton`` re-attach of a ``cancelled`` run.
"""
from __future__ import annotations
import asyncio
import logging
import re
import threading
import time
from collections.abc import Callable, Iterator, Sequence
from datetime import datetime
from pathlib import Path
from typing import Any
import pytest
from fastapi import HTTPException
from app.api import sync as sync_api
from app.config import Settings
@@ -754,12 +795,18 @@ def test_sync_modal_respects_reduced_motion() -> None:
def fresh_sync_status() -> Iterator[None]:
"""The module-level status object + task are process-global: reset
them before AND after every progress test (the integration suite's
``_fresh_sync_state`` pattern)."""
``_fresh_sync_state`` pattern).
Phase 134 (task 01): the module ``_cancel_event`` joins the reset —
a stale (set) event from a previous test's cancelled run would
otherwise bleed into the next run's boundary checks."""
sync_api._status = sync_api.SyncStatus()
sync_api._task = None
sync_api._cancel_event = asyncio.Event()
yield
sync_api._status = sync_api.SyncStatus()
sync_api._task = None
sync_api._cancel_event = asyncio.Event()
class _GatedClone:
@@ -861,8 +908,8 @@ class _GatedImport:
def _patch_sync_seams(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
fake_import: _GatedImport,
fake_clone: _GatedClone,
fake_import: object, # any import-shaped fake (the phase-134 fakes too)
fake_clone: object, # any clone-shaped fake (the phase-134 fakes too)
) -> None:
"""The runner's seams, monkeypatched on ``app.api.sync`` (the house
mock-import pattern): fresh settings (no ``.env`` leak), a no-op
@@ -1333,3 +1380,750 @@ def test_chunk_position_clears_at_phase_transitions_and_terminal(
assert s["current_file"] is None
assert s["current_chunk"] == 0 and s["chunks_total"] == 0
assert s["files_done"] == 1 and s["files_total"] == 3
# ---------- phase 134 (task 01): the cooperative cancel (5th state) ----------
#
# The cancel runs are driven through the REAL ``start_sync`` on a
# worker thread's own event loop — ``sync_api._task`` is the running
# task (the cancel endpoint's target), unlike the direct-``_run_sync``
# tests above. Two driver shapes:
#
# * ``_run_via_start_sync`` — the ACTION runs ON the loop thread once
# the run is in flight (a same-thread ``Task.cancel()`` — a
# cross-thread cancel would schedule through the loop's selector
# wake, which a parked loop never receives); the loop is drained
# afterwards so the runner's terminal step (the
# ``except CancelledError`` conversion) lands before the test reads
# the status.
# * ``_start_run_via_api`` — the OLD house pattern (blocking threading
# gates; the loop thread may park inside a sync span — the
# clone/``file_commit_dates``) with the main thread orchestrating
# the event set / releases.
def _fast_clone(url: str, dest: Path | str) -> Path:
"""A non-gated ``clone_or_pull`` — the loop thread must STAY FREE
for the loop-thread action, so nothing in the prelude may block it
(the import's gate is an ``asyncio.to_thread`` park — loop-safe)."""
dest = Path(dest)
dest.mkdir(parents=True, exist_ok=True)
(dest / "notes.md").write_text("# repo\ncontent for the KB\n", encoding="utf-8")
return dest
def _run_via_start_sync(
action: Callable[[asyncio.AbstractEventLoop], Any],
) -> tuple[threading.Thread, list[BaseException], dict[str, Any]]:
"""Start a run through the real ``start_sync`` on a worker thread's
own event loop; once the run is in flight, run *action* ON that
loop thread, then drain the loop so the runner's terminal step
(the ``except CancelledError`` conversion) lands before the test
reads the status. Returns (thread, the exceptions the run
surfaced — the CancelledError of a converted cancel, the action's
result under ``"result"``)."""
errors: list[BaseException] = []
out: dict[str, Any] = {}
started = threading.Event()
def _run() -> None:
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
loop.run_until_complete(sync_api.start_sync())
started.set()
out["result"] = action(loop)
for _ in range(20):
if sync_api._task is not None and sync_api._task.done():
break
loop.run_until_complete(asyncio.sleep(0))
assert sync_api._task is not None and sync_api._task.done(), (
"the runner task never finished"
)
except BaseException as e: # noqa: BLE001 — surfaced to the test
errors.append(e)
finally:
asyncio.set_event_loop(None)
loop.close()
thread = threading.Thread(target=_run, daemon=True)
thread.start()
assert started.wait(5.0), "start_sync never ran"
return thread, errors, out
def _start_run_via_api() -> tuple[threading.Thread, list[BaseException]]:
"""The house background-task pattern over the real ``start_sync`` —
the worker loop thread may park inside a SYNC span (the blocking
clone / ``file_commit_dates`` park); the main thread orchestrates
the event set / gate releases. The runner's surfaced exception
(the CancelledError of a converted cancel) is captured."""
errors: list[BaseException] = []
def _run() -> None:
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
loop.run_until_complete(sync_api.start_sync())
if sync_api._task is not None:
loop.run_until_complete(sync_api._task)
except BaseException as e: # noqa: BLE001 — surfaced to the test
errors.append(e)
finally:
asyncio.set_event_loop(None)
loop.close()
thread = threading.Thread(target=_run, daemon=True)
thread.start()
return thread, errors
def test_cancel_is_409_idle_and_after_a_terminal_run(
fresh_sync_status: None,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""The 409 contract (the exact detail ``no sync is running``):
idle (never started — ``_task`` None) AND after a terminal run
(``_task`` done) — the harmless race the UI's poll settles (no
error copy, no state change)."""
fake_import = _GatedImport(
ImportSummary(files=1, added=1), threading.Event(), threading.Event()
)
_patch_sync_seams(monkeypatch, tmp_path, fake_import, _fast_clone)
# Idle (never started): 409, the state untouched.
with pytest.raises(HTTPException) as idle:
asyncio.run(sync_api.cancel_sync())
assert idle.value.status_code == 409
assert idle.value.detail == "no sync is running"
assert sync_api.sync_status()["state"] == "idle"
# A full (uncancelled) run to the success terminal ...
def act(loop: asyncio.AbstractEventLoop) -> None:
assert fake_import.started.wait(5.0), "the run never reached the import"
fake_import.release.set()
task = sync_api._task
assert task is not None
loop.run_until_complete(task)
thread, errors, out = _run_via_start_sync(act)
thread.join(10.0)
assert not thread.is_alive()
assert errors == [], f"the runner raised: {errors!r}"
assert sync_api.sync_status()["state"] == "success"
# ... and after a terminal run the cancel is 409 again.
with pytest.raises(HTTPException) as after:
asyncio.run(sync_api.cancel_sync())
assert after.value.status_code == 409
assert after.value.detail == "no sync is running"
# The state is untouched by the rejected cancel.
assert sync_api.sync_status()["state"] == "success"
def test_cancel_endpoint_stops_the_in_flight_run(
fresh_sync_status: None,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The 202 path, end to end at the function level: a started run
(parked inside the import) + ``cancel_sync`` → 202 ``cancel
requested``; the event is set AND the task is cancelled, the run
converts the ``CancelledError`` into the ``cancelled`` terminal —
``error`` None, ``detail`` empty, ``finished_at`` set, the FINAL
file counts kept (the hook fired 1/3 before the park), the file /
phase / summary / chunk positions cleared — the PLAN §9 line is
logged, and the task ends CANCELLED (the re-raise; the status
object stays authoritative)."""
fake_import = _GatedImport(
ImportSummary(files=3, added=1, updated=1, unchanged=1),
threading.Event(),
threading.Event(),
)
_patch_sync_seams(monkeypatch, tmp_path, fake_import, _fast_clone)
def act(loop: asyncio.AbstractEventLoop) -> dict[str, str]:
assert fake_import.started.wait(5.0), "the run never reached the import"
return loop.run_until_complete(sync_api.cancel_sync())
try:
with caplog.at_level(logging.INFO, logger="app.api.sync"):
thread, errors, out = _run_via_start_sync(act)
thread.join(10.0)
finally:
fake_import.release.set() # free the to_thread park thread (non-daemon)
assert not thread.is_alive()
assert out.get("result") == {"detail": "cancel requested"}
# The endpoint did BOTH: the event (the cooperative marker) ...
assert sync_api._cancel_event.is_set()
# ... and the task cancel (the task ended CANCELLED — the re-raise
# happened; a swallowed cancel would have ended the task plain).
# The CancelledError itself is stored in the task (nothing awaits
# it directly — start_sync's create_task), so no thread-level
# exception surfaces — and nothing ELSE may have either.
assert sync_api._task is not None
assert sync_api._task.cancelled()
assert errors == [], f"the runner raised: {errors!r}"
s = sync_api.sync_status()
assert s["state"] == "cancelled"
assert s["error"] is None # intent, not failure
assert s["detail"] == {}
assert s["started_at"] is not None and s["finished_at"] is not None
assert s["current_file"] is None
assert s["phase"] is None
assert s["current_summary"] is None
assert s["files_done"] == 1 and s["files_total"] == 3 # final counts kept
assert s["summaries_done"] == 0 and s["summaries_total"] == 0
assert s["current_chunk"] == 0 and s["chunks_total"] == 0
# PLAN §9: the ample-logging line for the converted cancel.
assert any(
r.getMessage()
== "sync: cancelled by user state=cancelled files_done=1 files_total=3"
for r in caplog.records
)
def test_unrequested_cancel_is_never_swallowed_and_never_converts(
fresh_sync_status: None,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""The shutdown contract pin (the completion criterion): a bare
``task.cancel()`` WITHOUT the event set (the app-shutdown path —
no one pressed Cancel) re-raises the ``CancelledError`` with the
state UNTOUCHED — the task dies mid-run in ``running`` (no
terminal state, exactly as today's shutdown path does), the event
stays unset, and nothing is swallowed."""
fake_import = _GatedImport(
ImportSummary(files=3, added=1, updated=1, unchanged=1),
threading.Event(),
threading.Event(),
)
_patch_sync_seams(monkeypatch, tmp_path, fake_import, _fast_clone)
def act(loop: asyncio.AbstractEventLoop) -> None:
assert fake_import.started.wait(5.0), "the run never reached the import"
assert sync_api._cancel_event.is_set() is False
task = sync_api._task
assert task is not None
task.cancel() # NO endpoint, NO event — the shutdown path
loop.run_until_complete(task) # raises the CancelledError
try:
thread, errors, out = _run_via_start_sync(act)
thread.join(10.0)
finally:
fake_import.release.set() # free the to_thread park thread (non-daemon)
assert not thread.is_alive()
assert "result" not in out # the action raised
assert len(errors) == 1
assert isinstance(errors[0], asyncio.CancelledError)
s = sync_api.sync_status()
assert s["state"] == "running" # the task died mid-run — no terminal
assert s["finished_at"] is None
assert s["error"] is None
assert s["detail"] == {}
assert sync_api._cancel_event.is_set() is False
def test_check_cancelled_raises_only_when_the_event_is_set(
fresh_sync_status: None,
) -> None:
"""The boundary helper is pure polling: a no-op while the event is
unset, ``asyncio.CancelledError`` when it is set (the run's
``except CancelledError`` clause converts THAT into the
``cancelled`` terminal)."""
assert sync_api._cancel_event.is_set() is False
sync_api._check_cancelled() # unset: no raise
sync_api._cancel_event.set()
with pytest.raises(asyncio.CancelledError):
sync_api._check_cancelled()
def test_fresh_run_clears_a_stale_cancel_event(
fresh_sync_status: None,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""The stale-event pin: run ONE is cancelled (the terminal keeps
the event SET — only start_sync clears it), then a FRESH run
starts → ``start_sync`` cleared the event before the task was
created → the new run completes to ``success`` with no cancel
request (a stale event cannot bleed into the next run)."""
first_import = _GatedImport(
ImportSummary(files=3, added=1, updated=1, unchanged=1),
threading.Event(),
threading.Event(),
)
_patch_sync_seams(monkeypatch, tmp_path, first_import, _fast_clone)
def act1(loop: asyncio.AbstractEventLoop) -> dict[str, str]:
assert first_import.started.wait(5.0), "run 1 never reached the import"
return loop.run_until_complete(sync_api.cancel_sync())
try:
thread, errors, out = _run_via_start_sync(act1)
thread.join(10.0)
finally:
first_import.release.set() # free the to_thread park thread
assert not thread.is_alive()
assert errors == [], f"run 1 raised: {errors!r}"
task1 = sync_api._task
assert task1 is not None
assert task1.cancelled() # the re-raise ended it cancelled
assert out.get("result") == {"detail": "cancel requested"}
assert sync_api.sync_status()["state"] == "cancelled"
assert sync_api._cancel_event.is_set() # the terminal keeps the event set
# The FRESH run — a new gated import (the first one's gates are
# spent), the same fast clone.
second_import = _GatedImport(
ImportSummary(files=1, added=1), threading.Event(), threading.Event()
)
monkeypatch.setattr(sync_api, "import_sources", second_import)
def act2(loop: asyncio.AbstractEventLoop) -> None:
assert second_import.started.wait(5.0), "run 2 never reached the import"
# start_sync cleared the stale event before the task was
# created — by the import park it is definitely clear.
assert sync_api._cancel_event.is_set() is False
second_import.release.set()
task = sync_api._task
assert task is not None
loop.run_until_complete(task)
try:
thread2, errors2, out2 = _run_via_start_sync(act2)
thread2.join(10.0)
finally:
second_import.release.set() # free the to_thread park thread
assert not thread2.is_alive()
assert errors2 == [], f"the fresh run raised: {errors2!r}"
s = sync_api.sync_status()
assert s["state"] == "success" # no cancel request — clean completion
def test_event_alone_cancels_at_the_import_boundary(
fresh_sync_status: None,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""The cooperative path WITHOUT ``task.cancel()``: the event is set
while the run is parked in the clone prelude (simulating a
registered cancel whose ``_task.cancel()`` hit a blocking
subprocess and cannot interrupt it) — the span runs to completion
and the NEXT boundary (immediately before ``import_sources``)
converts it: the import never runs (the hook never fired), the
state is ``cancelled``, the counts stay 0/0."""
clone_parked = threading.Event()
clone_release = threading.Event()
clone_calls: list[tuple[str, Path]] = []
def parking_clone(url: str, dest: Path | str) -> Path:
dest = Path(dest)
dest.mkdir(parents=True, exist_ok=True)
(dest / "notes.md").write_text("# repo\ncontent for the KB\n", encoding="utf-8")
clone_calls.append((url, dest))
clone_parked.set()
clone_release.wait() # blocking is fine: the clone is a sync call
return dest
fake_import = _GatedImport(
ImportSummary(files=3, added=1, updated=1, unchanged=1),
threading.Event(),
threading.Event(),
)
_patch_sync_seams(monkeypatch, tmp_path, fake_import, parking_clone)
thread, errors = _start_run_via_api()
try:
assert clone_parked.wait(5.0), "the run never reached the clone"
# The event ALONE (no task.cancel — the subprocess cannot be
# interrupted): the cooperative boundary must catch it.
sync_api._cancel_event.set()
finally:
clone_release.set()
thread.join(10.0)
assert not thread.is_alive()
assert clone_calls == [("https://git.example.com/repo.git", tmp_path / "bor" / "repo")]
assert len(errors) == 1
assert isinstance(errors[0], asyncio.CancelledError)
assert fake_import.hook_calls == [] # the import never ran
s = sync_api.sync_status()
assert s["state"] == "cancelled"
assert s["error"] is None
assert s["detail"] == {}
assert s["files_done"] == 0 and s["files_total"] == 0
assert s["current_file"] is None
assert s["phase"] is None
def test_event_set_during_import_cancels_before_the_lite_spans(
fresh_sync_status: None,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""The overview + summaries boundary: the cancel's event lands
WHILE the import span runs (the fake import sets it mid-call —
the endpoint would have set it too) — the run converts at the
boundary immediately before ``if summary.added + summary.updated
> 0:``: the overview AND the folder summaries never start, the
state is ``cancelled``, and the import's final file counts are
kept."""
overview_calls: list[Any] = []
folder_calls: list[Any] = []
class _CancelDuringImport:
def __init__(self, summary: ImportSummary) -> None:
self.summary = summary
self.calls = 0
async def __call__(
self,
sources: list[Path],
llm: object,
*,
prune: bool = False,
limit: int | None = None,
session: object = None,
progress: Callable[[str, str, int, int], None] | None = None,
on_chunk_progress: Callable[[int, int], None] | None = None, # phase 129
ignore_by_root: dict[str, list[str]] | None = None, # phase 89
include_hidden_by_root: dict[str, bool] | None = None, # phase 105
doc_dates_by_root: dict[str, dict[str, datetime]] | None = None, # phase 106
) -> ImportSummary:
self.calls += 1
if progress is not None:
progress("repo", "notes/deep.md", 1, 2)
# The user's cancel lands mid-import (the endpoint's
# _task.cancel() would interrupt here at the next per-file
# await; the event ALONE isolates the boundary check — the
# next boundary is the overview gate).
sync_api._cancel_event.set()
return self.summary
fake_import = _CancelDuringImport(ImportSummary(files=2, added=1, updated=0))
_patch_sync_seams(monkeypatch, tmp_path, fake_import, _fast_clone)
async def spy_overview(llm: object, session: object = None) -> bool:
overview_calls.append(llm)
return False
async def spy_folders(
db: object,
llm: object,
*,
skip: bool = False,
only_missing: bool = False,
changed_docs: Sequence[tuple[str, str]] | None = None, # phase 127
on_progress: Callable[[int, int, str, str], None] | None = None,
) -> dict[str, int]:
folder_calls.append(llm)
return {"generated": 0, "failed": 0, "pruned": 0}
monkeypatch.setattr(sync_api, "regenerate_overview", spy_overview)
monkeypatch.setattr(sync_api, "generate_folder_summaries", spy_folders)
thread, errors = _start_run_via_api()
thread.join(10.0)
assert not thread.is_alive()
assert fake_import.calls == 1 # the import itself ran ...
assert len(errors) == 1
assert isinstance(errors[0], asyncio.CancelledError)
assert overview_calls == [] # ... but the overview never started
assert folder_calls == [] # ... and neither did the folder summaries
s = sync_api.sync_status()
assert s["state"] == "cancelled"
assert s["error"] is None
assert s["detail"] == {}
assert s["files_done"] == 1 and s["files_total"] == 2 # final counts kept
assert s["current_file"] is None
assert s["phase"] is None
assert s["current_summary"] is None
def test_event_cancels_at_the_top_of_the_source_loop(
fresh_sync_status: None,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""The per-source boundary (two git rows): the event is set while
the FIRST source's post-clone date walk runs (a blocking span —
the cooperative consequence: it runs to completion) — the cancel
converts at the top of iteration TWO, before the second source's
clone/pull: exactly one clone ran, the import never started, the
state is ``cancelled``."""
parked = threading.Event()
release = threading.Event()
clone_calls: list[tuple[str, Path]] = []
def parking_dates(root: Path) -> dict[str, datetime]:
parked.set()
release.wait() # blocking is fine: the date walk is a sync call
return {}
def fast_clone(url: str, dest: Path | str) -> Path:
dest = Path(dest)
dest.mkdir(parents=True, exist_ok=True)
(dest / "notes.md").write_text("# repo\ncontent for the KB\n", encoding="utf-8")
clone_calls.append((url, dest))
return dest
class _NeverReachedImport:
def __init__(self) -> None:
self.calls = 0
async def __call__(self, *args: Any, **kwargs: Any) -> ImportSummary:
self.calls += 1
return ImportSummary()
fake_import = _NeverReachedImport()
_patch_sync_seams(monkeypatch, tmp_path, fake_import, fast_clone)
monkeypatch.setattr(sync_api, "file_commit_dates", parking_dates)
# Two git rows: the cancel must fire at the top of iteration TWO
# (after the first source's git ops ran to completion).
monkeypatch.setattr(
sync_api,
"effective_sources",
lambda session: (
[
GitSource(url="https://git.example.com/one.git", kind="git"),
GitSource(url="https://git.example.com/two.git", kind="git"),
],
"db",
),
)
thread, errors = _start_run_via_api()
try:
assert parked.wait(5.0), "the run never reached the first date walk"
sync_api._cancel_event.set()
finally:
release.set()
thread.join(10.0)
assert not thread.is_alive()
assert len(errors) == 1
assert isinstance(errors[0], asyncio.CancelledError)
# Exactly the first source's git ops ran — the second clone never
# started (the boundary fired before it).
assert clone_calls == [("https://git.example.com/one.git", tmp_path / "bor" / "one")]
assert fake_import.calls == 0
s = sync_api.sync_status()
assert s["state"] == "cancelled"
assert s["error"] is None
assert s["detail"] == {}
assert s["files_done"] == 0 and s["files_total"] == 0
assert s["current_file"] is None
# ---------- phase 134 (task 02): the frontend cancel contract ----------
#
# The browser behavior is E2E-covered (tests/e2e/test_sync_cancel.py,
# task 03); here the source-level contract is pinned the same way as
# the main button above — so a silent regression (a hidden leak, a
# branch-order swap, a stale re-enable) is caught without a browser.
def test_sync_cancel_button_ships_hidden_and_labeled() -> None:
"""#sync-cancel-btn SHIPS hidden (anonymous-safe + the idle state
— sources.js reveals it the same way it reveals #sync-btn) in the
sync-control row right after the main button, with the visible
"Cancel" label (the accessible name — WCAG 2.4, never
icon-only)."""
text = _text(SHELL_HTML)
m = re.search(r"<button\b([^>]*)id=\"sync-cancel-btn\"([^>]*)>([^<]*)</button>", text)
assert m, "the shell must carry the #sync-cancel-btn (RAG view)"
attrs = m.group(1) + m.group(2)
assert 'class="sync-cancel-btn"' in attrs
assert 'type="button"' in attrs
assert re.search(r"\bhidden\b", attrs), "#sync-cancel-btn must ship hidden"
assert m.group(3).strip() == "Cancel", "the visible label is the accessible name"
# Same row: right after the main button closes, before the announcer.
main_end = text.find("</button>", text.find('id="sync-btn"'))
cancel_start = text.find('id="sync-cancel-btn"')
assert -1 < main_end < cancel_start < text.find('id="sync-result"')
def test_sync_cancel_btn_css_secondary_pill_and_states() -> None:
""".sync-cancel-btn is the house SECONDARY pill (the
.kb-summary-cancel language): >=44px touch floor, --line border,
the AA ink-soft text pair on the surface, the global 3px
:focus-visible ring (no per-button override), the :disabled look
(the request in flight — one request at a time), and the [hidden]
rule that keeps the attribute authoritative over the display. The
hover is guarded on :not(:disabled) — a disabled button under the
cursor never reads as actionable."""
css = _text(STYLES_CSS)
block = re.search(r"\.sync-cancel-btn\s*\{([^}]*)\}", css)
assert block, "styles.css must define .sync-cancel-btn"
body = block.group(1)
for prop in (
"display: inline-flex",
"min-height: 44px",
"border-radius: 999px",
"border: 1px solid var(--line)",
"color: var(--ink-soft)",
):
assert prop in body, f".sync-cancel-btn must carry {prop!r}"
hover = re.search(r"\.sync-cancel-btn:hover:not\(:disabled\)\s*\{([^}]*)\}", css)
assert hover, "the hover must be guarded on :not(:disabled)"
disabled = re.search(r"\.sync-cancel-btn:disabled\s*\{([^}]*)\}", css)
assert disabled, ".sync-cancel-btn:disabled must be styled"
assert "opacity: 0.6" in disabled.group(1)
hidden_rule = re.search(r"\.sync-cancel-btn\[hidden\]\s*\{([^}]*)\}", css)
assert hidden_rule, "the [hidden] rule must beat the display"
assert "display: none" in hidden_rule.group(1)
def test_sync_cancel_btn_css_full_width_on_mobile() -> None:
"""≤640px: the sync-control row already wraps (the 360px case) —
the Cancel pill stacks full-width under the full-width Sync pill
(the same mobile rule as the main button, next to it)."""
css = _text(STYLES_CSS)
main = css.find(".sync-btn { width: 100%; }")
assert main != -1, "the main button's mobile full-width rule"
near = css[main : main + 300]
assert ".sync-cancel-btn { width: 100%; }" in near, (
"the Cancel pill must join the main button's mobile full-width rule"
)
def test_sources_js_owns_the_cancel_button_and_helpers() -> None:
"""The control ref is null-safe like the main button's, and the
show/hide helpers carry the exact contract: show = unhide +
enable + aria-label "Cancel sync" (the stable accessible name);
hide = hide + disable (a hidden control is never focusable or
activatable in any state — the never-stale guarantee)."""
js = _text(SOURCES_JS)
assert 'querySelector("#sync-cancel-btn")' in js
show = _body(js, "showSyncCancelBtn")
assert "syncCancelBtn.hidden = false" in show
assert "syncCancelBtn.disabled = false" in show
assert 'syncCancelBtn.setAttribute("aria-label", "Cancel sync")' in show
hide = _body(js, "hideSyncCancelBtn")
assert "syncCancelBtn.hidden = true" in hide
assert "syncCancelBtn.disabled = true" in hide
def test_sources_js_cancel_visible_only_while_a_sync_runs() -> None:
"""The show/hide contract's two choke points: enterSyncRunningState
shows the control for kind "sync" (the live click, the re-attach,
every running tick) and hides it for kind "upload" (the upload job
is NOT cancellable in this phase — its run keeps the control
hidden); settleSyncButton hides it on EVERY settle (every
terminal + both idles pass through it)."""
js = _text(SOURCES_JS)
enter = _body(js, "enterSyncRunningState")
assert 'if (kind === "sync") showSyncCancelBtn()' in enter
assert "else hideSyncCancelBtn();" in enter
settle = _body(js, "settleSyncButton")
assert "hideSyncCancelBtn()" in settle, (
"every settle (terminal + idle) must hide the Cancel control"
)
def test_sources_js_cancel_request_is_never_stale() -> None:
"""requestCancel: the control disables IMMEDIATELY (before the
fetch — one request at a time, no double fire) and #sync-result
clears with it; then the branch set — 403 the whoami backstop
(stop polling, hide the main button, settle idle — the same depth
as every other 403 in this module), 409 the silent no-op (the
poll settles), 202 (r.ok) nothing (the poll's cancelled branch
settles everything), any other non-2xx / network failure the
phase-55 neutral retry copy + re-enable (the run is still going —
the owner can retry)."""
js = _text(SOURCES_JS)
body = _body(js, "requestCancel")
disable = body.find("syncCancelBtn.disabled = true")
fetch = body.find('fetch("/api/sync/cancel", { method: "POST" })')
assert disable != -1 and fetch != -1 and disable < fetch, (
"the disable must land BEFORE the fetch (never stale)"
)
assert 'syncResult.textContent = ""' in body, "the result line clears with the click"
assert "r.status === 403" in body
assert "stopSyncPolling()" in body
assert "syncBtn.hidden = true" in body
assert "applySyncIdle()" in body
assert "r.status === 409" in body
assert "r.ok" in body
# The phase-55 neutral retry copy + the re-enable — in the
# network-failure catch AND the non-2xx fall-through.
assert body.count("Couldn't cancel the sync — try again.") == 2
assert body.count("syncCancelBtn.disabled = false") == 2
def test_sources_js_wires_the_cancel_click_next_to_the_main() -> None:
"""The click listener is armed right after the main button's
(guarded on the control's presence — a page without the markup is
a complete no-op, the main button's convention)."""
js = _text(SOURCES_JS)
main = js.find('syncBtn.addEventListener("click", startSync)')
cancel = js.find('syncCancelBtn.addEventListener("click", requestCancel)')
assert main != -1 and cancel != -1
assert cancel > main, "the cancel wiring sits next to the main button's"
def test_sources_js_poll_tree_cancelled_branch_in_order() -> None:
"""The poll tick's decision tree (now 8 branches) gains the
cancelled branch AFTER the failed branch and BEFORE the
upload-success branch: stop polling + applySyncCancelled — the
neutral terminal, not the failure path."""
js = _text(SOURCES_JS)
start = js.find("function startSyncPolling")
end = js.find("async function startSync", start)
tick = js[start:end]
failed = tick.find('syncStatus.state === "failed"')
cancelled = tick.find('syncStatus.state === "cancelled"')
upload_success = tick.find('uploadStatus.state === "success"')
assert -1 < failed < cancelled < upload_success, (
"the cancelled branch must sit between failed and upload success"
)
branch = tick[cancelled : cancelled + 300]
assert "stopSyncPolling()" in branch
assert "applySyncCancelled(syncStatus)" in branch
def test_sources_js_apply_sync_cancelled_is_neutral() -> None:
"""applySyncCancelled renders the NEUTRAL result (A1 — cancel is
intent, not failure): settle the main button retry-ready (which
also hides the Cancel control), the "Sync cancelled" line on the
aria-live result (superseding the settle's label write), the
error banner hidden (a failed run's banner must not survive), the
status frame emitted — NO modal, NO banner, NO error styling."""
js = _text(SOURCES_JS)
body = _body(js, "applySyncCancelled")
assert 'settleSyncButton("Sync sources")' in body
assert 'syncResult.textContent = "Sync cancelled"' in body
assert "hideSyncError()" in body
assert "emitSyncStatus(status)" in body
assert "showSyncModal" not in body, "cancel is intent — no modal"
assert "showSyncError" not in body, "cancel is intent — no banner"
assert "is-error" not in body, "cancel is intent — no error styling"
def test_sources_js_reattaches_a_cancelled_run() -> None:
"""initSyncButton: a reload during/right after a cancel renders
the neutral cancelled result (the branch sits after the failed
branch, before the upload check) — never a stuck running state."""
js = _text(SOURCES_JS)
body = _body(js, "initSyncButton")
assert 'status.state === "cancelled"' in body
assert "applySyncCancelled(status)" in body
failed = body.find('status.state === "failed"')
cancelled = body.find('status.state === "cancelled"')
assert -1 < failed < cancelled
def test_sources_js_section_header_documents_the_cancel_contract() -> None:
"""The house pattern: the sync section's header docstring carries
the decision tree (now 8 branches — the cancelled one is the
5th) and the Cancel control's show/hide contract."""
js = _text(SOURCES_JS)
start = js.find("Sync sources button (Sources page only)")
assert start != -1, "the sync section marker comment"
header = js[start : js.find("const syncBtn = root.querySelector", start)]
assert "5. sync cancelled" in header, "the tree's cancelled branch (the 5th)"
assert "8. both idle" in header, "the tree is now 8 branches"
assert "#sync-cancel-btn" in header, "the control's contract is documented"