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:
stop() sets the flag and the state (moon_engine.py:509-514):
self._stop_flag = True
self._state = "stopping"
-
_browser_worker exits its loop promptly (moon_engine.py:199): while not self._get("_stop_flag"): — so no new URL is picked up. Correct.
-
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.
-
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)
- 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
- 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.
- 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.
- 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.
- 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
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.
Context
Pressing Stop during a run leaves the interface on
STOPPINGand the button onCLOSING…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:
stop()sets the flag and the state (moon_engine.py:509-514):_browser_workerexits its loop promptly (moon_engine.py:199):while not self._get("_stop_flag"):— so no new URL is picked up. Correct.But each download runs as its own task in
all_tasks, anddownload_fileinmoon_download.pyonly ever checkskill_evt(L489), which is the stall kill. No stop signal reaches an in-flight download.moon_engine.py:354-358then waits for every straggler to finish naturally:_on_done()(L388-392) set the state todone.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_filecan already abort mid-stream:_StallKillis raised from inside the chunk loop (moon_download.py:488, L531) when the stall detector fires, and the partial.tmpis deliberately left in place so the next attempt resumes with aRangeheader (L434, L443-444). A stop should take the same path.What to do
download_filea stop signal. It already acceptskill_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 existingif kill_evt.is_set():at L489..tmpalone. 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._do_dldoes with the returned status (moon_engine.py:170-192) and make sure_failis not incremented and the URL is not written tofailed_links.txt.stoppingshould be visibly finite: the log line at L357 already reports the straggler count, but the GUI header only shows the wordSTOPPING. 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
donein seconds, not minutes.tmpfiles survive, and a later run resumes from themfailand is not written tofailed_links.txtdonepytest tests/ -qpassesNotes for the contributor
Windows is helpful but not required. The engine is driveable headlessly —
pytest tests/ -qstubs Chrome and the network at themoon_extractboundary, and a test can start a run against stubbed slow transfers, callstop(), and assert the state reachesdonewithin a bounded time and that.tmpfiles 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.