From 67d17a7f51eeecd74bb02fcf74fbc4999c609e6a Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Tue, 26 May 2026 11:31:13 +0100 Subject: [PATCH 1/6] fix(_run): break loop on return_code alone, not execution.finished The polling loop in _run required both `execution.return_code` and `execution.finished` to terminate. `execution.finished` is parsed from `.nextflow.log` and only set when the log ends with "Goodbye" or a recognised stack trace. When Nextflow exits early on a config parse error (e.g. an unresolved ${VAR} in nextflow.config), the log ends with the offending source line, `finished` is never set, and the loop spins forever even though the subprocess has exited and rc.txt has been written. `rc.txt` is the more reliable signal: the shell `; echo $? >rc.txt` runs unconditionally after Nextflow exits, so a non-empty return_code means the subprocess is done. This restores the behaviour from v0.11 without re-introducing the Popen reference that d681e3e was deliberately removing. Bumps version to 0.13.1. Fixes #14. Co-Authored-By: Claude Opus 4.7 --- README.rst | 8 ++++++++ nextflow/command.py | 2 +- setup.py | 2 +- tests/unit/test_command.py | 15 ++++++++++++++- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 5e154b4..5f1e6ca 100644 --- a/README.rst +++ b/README.rst @@ -262,6 +262,14 @@ during their execution too. These can be obtained as follows: Changelog --------- +Release 0.13.1 +~~~~~~~~~~~~~~ + +`26th May, 2026` + +* Fix run loop hanging when Nextflow exits with a config parse error (#14). + + Release 0.13.0 ~~~~~~~~~~~~~~ diff --git a/nextflow/command.py b/nextflow/command.py index 64071ad..ef1f38f 100644 --- a/nextflow/command.py +++ b/nextflow/command.py @@ -102,7 +102,7 @@ def _run( ) log_start += diff if execution and poll: yield execution - if execution and execution.return_code and execution.finished: + if execution and execution.return_code: if not poll: yield execution break diff --git a/setup.py b/setup.py index aef2e35..74e406c 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="nextflowpy", - version="0.13.0", + version="0.13.1", description="A Python wrapper around Nextflow.", long_description=long_description, long_description_content_type="text/x-rst", diff --git a/tests/unit/test_command.py b/tests/unit/test_command.py index 21613fc..23e3ce4 100644 --- a/tests/unit/test_command.py +++ b/tests/unit/test_command.py @@ -43,13 +43,26 @@ def test_can_run_with_custom_values(self, mock_ex, mock_sleep, mock_submit): self.assertEqual(executions, [mock_executions[1]]) + @patch("nextflow.command.submit_execution") + @patch("time.sleep") + @patch("nextflow.command.get_execution") + def test_loop_terminates_when_return_code_set_without_finished(self, mock_ex, mock_sleep, mock_submit): + submission = Mock() + mock_submit.return_value = submission + execution = Mock(return_code="1", finished=None) + mock_ex.side_effect = [(execution, 100)] + executions = list(_run("main.nf")) + self.assertEqual(executions, [execution]) + self.assertEqual(mock_ex.call_count, 1) + + @patch("nextflow.command.submit_execution") @patch("time.sleep") @patch("nextflow.command.get_execution") def test_can_run_and_poll(self, mock_ex, mock_sleep, mock_submit): submission = Mock() mock_submit.return_value = submission - mock_executions = [Mock(finished=False), Mock(finished=True)] + mock_executions = [Mock(return_code=""), Mock(return_code="0")] mock_ex.side_effect = [[None, 20], [mock_executions[0], 40], [mock_executions[1], 20]] executions = list(_run("main.nf", poll=True, output_path="/out")) mock_sleep.assert_called_with(1) From fe7005459ac3651d58c2b70836a51e5645e45927 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Tue, 26 May 2026 11:31:21 +0100 Subject: [PATCH 2/6] chore: update package author to Goodwright Ltd Point the published package's author metadata at the team rather than an individual maintainer. Co-Authored-By: Claude Opus 4.7 --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 74e406c..1e13828 100644 --- a/setup.py +++ b/setup.py @@ -10,8 +10,8 @@ long_description=long_description, long_description_content_type="text/x-rst", url="https://github.com/goodwright/nextflow.py", - author="Sam Ireland", - author_email="sam@goodwright.com", + author="Goodwright Ltd", + author_email="engineering@flow.bio", license="GPLv3+", classifiers=[ "Development Status :: 4 - Beta", From bcd0cfd8c054b3f6e10c956913282d0e21c0aa66 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Tue, 26 May 2026 11:42:44 +0100 Subject: [PATCH 3/6] fix(make_nextflow_command): truncate rc.txt before launching nextflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix made the polling loop break on `execution.return_code` alone. That works for fresh runs, but when the same output_path is reused (e.g. resume runs), rc.txt still holds the return code from the previous run while nextflow is starting up. The shell only writes the new return code after nextflow exits (`; echo $? >rc.txt`), so the polling loop would see the stale rc, treat the new run as finished immediately, and break before any output was captured. Prepending `:>rc.txt;` to the shell command truncates the file to zero bytes at the start, so the polling loop reads an empty rc until the new run actually finishes. `:` is a POSIX no-op, so this is portable. Caught by CI: `test_can_handle_pipeline_error` in both run and poll integration suites — the resume run after an errored first run was returning immediately with stale state. Co-Authored-By: Claude Opus 4.7 --- nextflow/command.py | 3 ++- tests/unit/test_command.py | 28 +++++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/nextflow/command.py b/nextflow/command.py index ef1f38f..ce732bb 100644 --- a/nextflow/command.py +++ b/nextflow/command.py @@ -207,9 +207,10 @@ def make_nextflow_command(run_path, output_path, log_path, pipeline_path, resume profiles = make_nextflow_command_profiles_string(profiles) reports = make_reports_string(output_path, report, timeline, dag, trace) command = f"{env}{nf} {log}{configs}run {pipeline_path} {resume}{params} {profiles} {reports}" + prefix = (str(output_path) + os.path.sep) if output_path != run_path else "" + command = f":>{prefix}rc.txt; {command}" abspath = io.abspath if io else os.path.abspath if run_path != abspath("."): command = f"cd {run_path}; {command}" - prefix = (str(output_path) + os.path.sep) if output_path != run_path else "" command = command.rstrip() + f" >{prefix}" command += f"stdout.txt 2>{prefix}" command += f"stderr.txt; echo $? >{prefix}rc.txt" diff --git a/tests/unit/test_command.py b/tests/unit/test_command.py index 23e3ce4..c0c807b 100644 --- a/tests/unit/test_command.py +++ b/tests/unit/test_command.py @@ -194,7 +194,7 @@ def test_can_get_full_nextflow_command(self, mock_report, mock_prof, mock_params mock_params.assert_called_with({"param": "2"}) mock_prof.assert_called_with(["docker"]) mock_report.assert_called_with("/out", "report.html", "time.html", "dag.html", "trace.html") - self.assertEqual(command, "cd /exdir; A=B C=D nextflow -Duser.country=US -log '.nextflow.log' -c conf1 -c conf2 run main.nf -resume X --p1=10 --p2=20 -profile docker,test --dag.html >/out/stdout.txt 2>/out/stderr.txt; echo $? >/out/rc.txt") + self.assertEqual(command, "cd /exdir; :>/out/rc.txt; A=B C=D nextflow -Duser.country=US -log '.nextflow.log' -c conf1 -c conf2 run main.nf -resume X --p1=10 --p2=20 -profile docker,test --dag.html >/out/stdout.txt 2>/out/stderr.txt; echo $? >/out/rc.txt") @patch("nextflow.command.make_nextflow_command_env_string") @@ -221,7 +221,7 @@ def test_can_get_minimal_nextflow_command(self, mock_abspath, mock_report, mock_ mock_params.assert_called_with({"param": "2"}) mock_prof.assert_called_with(["docker"]) mock_report.assert_called_with("/exdir", None, None, None, None) - self.assertEqual(command, "nextflow -Duser.country=US run main.nf >stdout.txt 2>stderr.txt; echo $? >rc.txt") + self.assertEqual(command, ":>rc.txt; nextflow -Duser.country=US run main.nf >stdout.txt 2>stderr.txt; echo $? >rc.txt") @patch("nextflow.command.make_nextflow_command_env_string") @@ -248,7 +248,29 @@ def test_can_use_custom_io(self, mock_report, mock_prof, mock_params, mock_resum mock_params.assert_called_with({"param": "2"}) mock_prof.assert_called_with(["docker"]) mock_report.assert_called_with("/exdir", None, None, None, None) - self.assertEqual(command, "nextflow -Duser.country=US run main.nf >stdout.txt 2>stderr.txt; echo $? >rc.txt") + self.assertEqual(command, ":>rc.txt; nextflow -Duser.country=US run main.nf >stdout.txt 2>stderr.txt; echo $? >rc.txt") + + + @patch("nextflow.command.make_nextflow_command_env_string") + @patch("nextflow.command.make_nextflow_command_log_string") + @patch("nextflow.command.make_nextflow_command_config_string") + @patch("nextflow.command.make_nextflow_command_resume_string") + @patch("nextflow.command.make_nextflow_command_params_string") + @patch("nextflow.command.make_nextflow_command_profiles_string") + @patch("nextflow.command.make_reports_string") + def test_command_truncates_stale_rc_before_running(self, mock_report, mock_prof, mock_params, mock_resume, mock_conf, mock_log, mock_env): + mock_env.return_value = "" + mock_log.return_value = "" + mock_conf.return_value = "" + mock_resume.return_value = "" + mock_params.return_value = "" + mock_prof.return_value = "" + mock_report.return_value = "" + io = Mock() + io.abspath.return_value = "/exdir" + command = make_nextflow_command("/exdir", "/out", "/log", "main.nf", False, None, None, None, None, None, None, None, None, None, None, io) + self.assertIn(":>/out/rc.txt;", command) + self.assertLess(command.index(":>/out/rc.txt;"), command.index("nextflow")) From 5236f7bb1af1a3572bf8a7c50c51abf86f5a7671 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Tue, 26 May 2026 12:20:11 +0100 Subject: [PATCH 4/6] ci: re-trigger build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous push (bcd0cfd) did not trigger a CI run — webhook dropped. This empty commit forces Actions to schedule a fresh run on the fixed code. Co-Authored-By: Claude Opus 4.7 From e842ece08a0308d6b0e4a8eba8587be0e8c5c978 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Tue, 26 May 2026 14:12:49 +0100 Subject: [PATCH 5/6] ci: re-trigger build GitHub Actions outage dropped earlier webhook deliveries; pushing an empty commit to schedule a fresh run on the fixed code. Co-Authored-By: Claude Opus 4.7 From 236f1eb8e303482522176b067102be147d347f68 Mon Sep 17 00:00:00 2001 From: Martin Husbyn Date: Tue, 26 May 2026 14:23:39 +0100 Subject: [PATCH 6/6] fix(_run): wait for execution.finished, fall back to rc-only after stale poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking the loop on rc.txt alone races the log on slower I/O: nextflow writes 'Task completed' lines for killed processes and 'Goodbye' just before exiting, but those entries can land on disk *after* rc.txt is read in the same poll iteration. The previous fix worked on macOS but the integration test 'test_can_handle_pipeline_error' failed on Linux CI because lower2.status hadn't been parsed yet when the loop broke. Restore execution.finished as the primary terminate signal (which chronologically guarantees log entries have settled). Fall back to rc-only after a second poll where rc is still set but finished hasn't arrived — covers the issue #14 case (config parse error) where the log doesn't end with 'Goodbye' or a stack trace and finished will never be parsed. Costs at most one extra poll interval (default 1s) on the early-exit path. Co-Authored-By: Claude Opus 4.7 --- nextflow/command.py | 6 ++++-- tests/unit/test_command.py | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/nextflow/command.py b/nextflow/command.py index ce732bb..c20cef2 100644 --- a/nextflow/command.py +++ b/nextflow/command.py @@ -94,7 +94,7 @@ def _run( params=params ) - execution, log_start = None, 0 + execution, log_start, rc_seen = None, 0, False while True: time.sleep(sleep) execution, diff = get_execution( @@ -102,9 +102,11 @@ def _run( ) log_start += diff if execution and poll: yield execution - if execution and execution.return_code: + if execution and execution.return_code and (execution.finished or rc_seen): if not poll: yield execution break + if execution and execution.return_code: + rc_seen = True def submit_execution( diff --git a/tests/unit/test_command.py b/tests/unit/test_command.py index c0c807b..8f81008 100644 --- a/tests/unit/test_command.py +++ b/tests/unit/test_command.py @@ -46,14 +46,14 @@ def test_can_run_with_custom_values(self, mock_ex, mock_sleep, mock_submit): @patch("nextflow.command.submit_execution") @patch("time.sleep") @patch("nextflow.command.get_execution") - def test_loop_terminates_when_return_code_set_without_finished(self, mock_ex, mock_sleep, mock_submit): + def test_loop_terminates_when_return_code_set_but_finished_never_arrives(self, mock_ex, mock_sleep, mock_submit): submission = Mock() mock_submit.return_value = submission execution = Mock(return_code="1", finished=None) - mock_ex.side_effect = [(execution, 100)] + mock_ex.side_effect = [(execution, 100), (execution, 0)] executions = list(_run("main.nf")) self.assertEqual(executions, [execution]) - self.assertEqual(mock_ex.call_count, 1) + self.assertEqual(mock_ex.call_count, 2) @patch("nextflow.command.submit_execution")