Skip to content

bug: Stop leaves the UI on STOPPING until every in-flight download finishes on its own #65

Description

@LeyckerS

Context

Pressing Stop during a run leaves the interface on STOPPING and the button on CLOSING… for a long time — minutes with large files, and in the worst case far longer. Reported from real use with 8 active transfers at ~213 MB/s and 21 files remaining.

It is not a deadlock. It is an unbounded wait with no feedback, and the reason is that stop only prevents new work from starting; it never reaches the downloads already in flight.

The sequence today:

  1. stop() sets the flag and the state (moon_engine.py:509-514):
self._stop_flag = True
self._state = "stopping"
  1. _browser_worker exits its loop promptly (moon_engine.py:199): while not self._get("_stop_flag"): — so no new URL is picked up. Correct.

  2. But each download runs as its own task in all_tasks, and download_file in moon_download.py only ever checks kill_evt (L489), which is the stall kill. No stop signal reaches an in-flight download.

  3. moon_engine.py:354-358 then waits for every straggler to finish naturally:

stragglers = [t for t in all_tasks if not t.done()]
if stragglers:
    self.log(f"  ⚠  {len(stragglers)} straggler tasks finishing...", "warn")
    await asyncio.gather(*stragglers, return_exceptions=True)
  1. Only after that does _on_done() (L388-392) set the state to done.

So the wait is bounded by whatever the in-flight transfers take. With eight concurrent streams on multi-hundred-megabyte files that is minutes, and the client timeout is aiohttp.ClientTimeout(total=7200, ...) (moon_download.py:84) — two hours for a transfer that hangs without dropping.

From the user's side there is no way to tell the difference between "finishing eight transfers" and "hung".

The mechanism to fix this already exists

download_file can already abort mid-stream: _StallKill is raised from inside the chunk loop (moon_download.py:488, L531) when the stall detector fires, and the partial .tmp is deliberately left in place so the next attempt resumes with a Range header (L434, L443-444). A stop should take the same path.

What to do

  1. Give download_file a stop signal. It already accepts kill_evt: asyncio.Event. Add a second event, or pass the engine's stop state in, and check it in the chunk loop next to the existing if kill_evt.is_set(): at L489.
  2. Abort promptly and leave the .tmp alone. Do not delete the partial file — resume already depends on it. A stopped transfer should look like a stall kill from the filesystem's point of view.
  3. Do not count a stopped transfer as a failure. It did not fail; the user stopped it. Check what _do_dl does with the returned status (moon_engine.py:170-192) and make sure _fail is not incremented and the URL is not written to failed_links.txt.
  4. Say what is happening while it happens. stopping should be visibly finite: the log line at L357 already reports the straggler count, but the GUI header only shows the word STOPPING. Surfacing "waiting for N transfers" is enough — the engine already knows the number.

Out of scope: changing the timeouts, changing the stall detector, cancelling the extraction phase differently.

Acceptance criteria

  • Pressing Stop mid-download reaches state done in seconds, not minutes
  • In-flight transfers abort rather than running to completion
  • Partial .tmp files survive, and a later run resumes from them
  • A stopped transfer is not counted in fail and is not written to failed_links.txt
  • Starting a new run after a stop works, with counters reset
  • No orphaned tasks: nothing keeps writing to disk after the state says done
  • pytest tests/ -q passes

Notes for the contributor

Windows is helpful but not required. The engine is driveable headlessly — pytest tests/ -q stubs Chrome and the network at the moon_extract boundary, and a test can start a run against stubbed slow transfers, call stop(), and assert the state reaches done within a bounded time and that .tmp files still exist.

This is not a first issue. It touches the interaction between the engine's state machine, task teardown and the download loop, and getting it wrong means either transfers that keep writing after "done" or partial files that stop resuming. Read _do_dl (moon_engine.py:149-192), the straggler block (L354-358) and the chunk loop (moon_download.py:481-535) together before you start.

Related: #59 covers the fail-fast/collect asymmetry in the same teardown path. They can be done independently, but whoever takes this will read the same code.

Comment here before you start.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinggood first issueGood for newcomershelp wantedExtra attention is needed

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions