phase: 134_sync_cancel
**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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user