Skip to content

feat: 支持复用已有进程执行任务 - #234

Merged
overflow65537 merged 4 commits into
overflow65537:mainfrom
sgpublic:feat/reuse-instance
Aug 12, 2026
Merged

feat: 支持复用已有进程执行任务#234
overflow65537 merged 4 commits into
overflow65537:mainfrom
sgpublic:feat/reuse-instance

Conversation

@sgpublic

@sgpublic sgpublic commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

fix #233

已在 MAA_bbb 项目中本地测试复用有效。

Summary by Sourcery

Add support for reusing an already-running MFW instance to execute a specified configuration, including CLI, IPC, scheduler, and UI integration.

New Features:

  • Introduce an IPC command and callback mechanism that allows an existing instance to run a specified config on request, with busy/invalid responses.
  • Add CLI and startup options for requesting reuse of an existing process instead of forcing restart when a config is run.
  • Extend scheduling logic on Windows and Unix so scheduled tasks can opt to reuse an existing process via a new reuse_existing flag.
  • Expose a new "Reuse existing process" option in the schedule configuration UI and list view with localization support.

Enhancements:

  • Refine single-instance IPC logging and socket handling to better trace command requests and responses.

Tests:

  • Add unit tests covering CLI parsing/serialization of the reuse-existing flag, ScheduleEntry reuse_existing defaults/serialization, and scheduler command generation on Windows and Unix.
  • Extend existing single-instance and Windows scheduler tests to validate new IPC command constants and reuse-existing CLI arguments.

@sourcery-ai

sourcery-ai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds end-to-end support for reusing an already-running MFW process to execute a scheduled configuration, including new single-instance IPC commands, CLI flag wiring, scheduler serialization/parsing, and UI controls.

Sequence diagram for reusing an existing process to run a scheduled config

sequenceDiagram
    actor Scheduler
    participant CliProcess as mfw_cli_process
    participant SingleInstanceGuard
    participant ActivationServer
    participant MainWindow
    participant ServiceCoordinator

    Scheduler->>CliProcess: start MFW (--config-id, --reuse-existing)
    CliProcess->>SingleInstanceGuard: acquire()

    alt existing_instance_not_running
        SingleInstanceGuard-->>CliProcess: acquired = True
        CliProcess->>CliProcess: normal startup
    else existing_instance_running
        SingleInstanceGuard-->>CliProcess: acquired = False
        CliProcess->>SingleInstanceGuard: request_existing_instance_run(config_id, force_start)
        SingleInstanceGuard->>ActivationServer: CMD_RUN_CONFIG + JSON
        ActivationServer->>ActivationServer: _handle_reuse_run_request(config_id, force_start)

        alt window_not_ready
            ActivationServer->>ActivationServer: pending_reuse_request[request] = (config_id, force_start)
            ActivationServer-->>SingleInstanceGuard: RESP_ACCEPTED
        else window_ready_and_config_valid
            ActivationServer->>MainWindow: _schedule_reused_run(window, config_id, force_start)
            ActivationServer-->>SingleInstanceGuard: RESP_ACCEPTED
            MainWindow->>ServiceCoordinator: stop_task(manual=True) [optional]
            MainWindow->>ServiceCoordinator: select_config(config_id)
            MainWindow->>ServiceCoordinator: run_tasks_flow()
        else task_running_and_not_force_start
            ActivationServer-->>SingleInstanceGuard: RESP_BUSY
        else invalid_config
            ActivationServer-->>SingleInstanceGuard: RESP_INVALID
        end

        SingleInstanceGuard-->>CliProcess: response (RESP_ACCEPTED | RESP_BUSY | RESP_INVALID)
        alt RESP_ACCEPTED or RESP_BUSY
            CliProcess->>CliProcess: exit with code 0
        else other_response
            CliProcess->>CliProcess: exit with code 1
        end
    end
Loading

File-Level Changes

Change Details Files
Extend single-instance IPC to allow an existing process to accept or reject a "run config" request with structured responses.
  • Introduce CMD_RUN_CONFIG command and RESP_BUSY/RESP_INVALID response codes and wire them into the single-instance server state
  • Add a run-config callback hook on the activation server and guard to process config reuse requests
  • Implement client-side helper to send run-config requests via QLocalSocket with JSON payloads and robust logging/timeout handling
  • Adjust command parsing to preserve case for payloads but still treat shutdown command case-insensitively, and improve logging around IPC responses
app/utils/single_instance.py
tests/test_single_instance_restart.py
Wire reuse-existing behavior into application startup so a new process can ask an existing instance to run a given config and exit based on IPC response.
  • Gate force-restart behavior so it is skipped when reuse_existing is requested
  • On failed single-instance acquisition with reuse_existing, send IPC run-config request and interpret RESP_ACCEPTED/RESP_BUSY to decide exit status
  • In main event loop, add callbacks and async scheduling to handle reuse-run requests both before and after the main window is created, including task-running checks and config validation
main.py
Expose a "reuse existing process" option in the scheduling UI and propagate it through ScheduleEntry serialization and table rendering.
  • Add reuse_existing checkbox to the schedule creation form and pass its value into new ScheduleEntry instances
  • Extend ScheduleEntry dataclass with reuse_existing field, including to_dict/from_dict support and default False behavior
  • Add a "Reuse" column to the schedule list table, adjust column indices/resizing, and render Yes/No values with centered alignment
app/view/schedule_interface/schedule_interface.py
app/core/service/schedule_service.py
app/i18n/i18n.ja_JP.ts
app/i18n/i18n.zh_CN.ts
app/i18n/i18n.zh_TW.ts
Propagate reuse_existing through Windows and Unix system scheduler command generation and parsing, including tests.
  • Extend resolve_schedule_launch_command to accept reuse_existing and add the corresponding CLI flag when building arguments
  • Modify Windows task XML builder and parser to forward/recognize the reuse-existing flag in Arguments and map it back to ScheduleEntry.reuse_existing
  • Update Unix cron job builder and crontab parser to include/parse --reuse-existing, ensuring ScheduleEntry carries the flag
  • Add tests for Windows scheduler to check reuse_existing wiring and for Unix scheduler to assert that reuse-existing appears in generated shell jobs
app/utils/install_paths.py
app/core/service/system_scheduler/windows.py
app/core/service/system_scheduler/unix_common.py
app/core/service/system_scheduler/crontab.py
tests/test_windows_system_scheduler.py
tests/test_unix_system_scheduler.py
Add CLI-level support for the reuse-existing flag and basic regression tests for ScheduleEntry and CLI behavior.
  • Introduce FLAG_REUSE_EXISTING, include it in MFW_FLAGS, document it in the CLI help text, and add a corresponding argparse option
  • Extend StartupOptions and build_startup_argv/parse_startup_cli to carry reuse_existing through process startup
  • Add a focused test module that validates CLI parsing/serialization of reuse_existing and ScheduleEntry reuse_existing defaults/serialization
mfw_cli.py
tests/test_reuse_existing.py

Assessment against linked issues

Issue Objective Addressed Explanation
#233 Implement runtime and IPC support to reuse an already running MFW process to execute a target configuration according to the specified behavior matrix (idle vs task-running, with/without force start).
#233 Expose a "reuse existing process" option in the scheduling UI, persist it in schedule entries, and propagate it through CLI and system schedulers (Windows Task Scheduler and Unix cron) so scheduled tasks can request reuse of an existing process.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 7 issues, and left some high level feedback:

  • In _handle_reuse_run_request, a request with an invalid config_id is accepted when window is None and only rejected later in _schedule_reused_run, so the IPC caller receives RESP_ACCEPTED even though the config doesn’t exist; consider validating config_id before setting pending_reuse_request to keep responses consistent.
  • When the main window already exists in _handle_reuse_run_request, you bypass pending_reuse_request and schedule _schedule_reused_run directly; it may be clearer and less error-prone to route both paths through the same pending/dispatch mechanism so reuse requests are handled uniformly.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `_handle_reuse_run_request`, a request with an invalid `config_id` is accepted when `window is None` and only rejected later in `_schedule_reused_run`, so the IPC caller receives `RESP_ACCEPTED` even though the config doesn’t exist; consider validating `config_id` before setting `pending_reuse_request` to keep responses consistent.
- When the main window already exists in `_handle_reuse_run_request`, you bypass `pending_reuse_request` and schedule `_schedule_reused_run` directly; it may be clearer and less error-prone to route both paths through the same pending/dispatch mechanism so reuse requests are handled uniformly.

## Individual Comments

### Comment 1
<location path="main.py" line_range="171-180" />
<code_context>

     single_instance = SingleInstanceGuard(instance_key)
     if not single_instance.acquire():
+        if options.reuse_existing:
+            from app.utils.single_instance import RESP_ACCEPTED, RESP_BUSY
+            from PySide6.QtCore import QCoreApplication
+
+            _ = QCoreApplication.instance()
+            if ipc_app is None:
+                _ = QCoreApplication([sys.argv[0]])
+            response = single_instance.request_existing_instance_run(
+                options.config_id or "", force_start=options.force_restart
+            )
+            if response == RESP_ACCEPTED:
+                logger.info("已有实例已接收复用执行请求")
+                return 0
+            if response == RESP_BUSY:
+                logger.info("已有实例正在执行任务,已跳过复用执行请求")
+                return 0
+            logger.warning("向已有实例发送复用执行请求失败: %s", response)
+            return 1
+
         from app.utils.startup_dialog import run_duplicate_instance_flow
</code_context>
<issue_to_address>
**question:** When reuse IPC fails, the process exits with code 1 without trying the duplicate-instance flow, which may be surprising for users.

In the `not single_instance.acquire()` branch, when `options.reuse_existing` is set and the IPC request fails or returns `RESP_INVALID`/`RESP_FAIL`, you log a warning and return `1`, skipping `run_duplicate_instance_flow`. Previously, duplicate-instance cases would still show the startup dialog. For `--reuse-existing`, it may be preferable to fall back to the usual duplicate-instance UI (or allow starting a new instance) rather than a hard error exit, especially if the existing instance is stuck. Consider routing non-success responses to the duplicate-instance flow instead of always exiting nonzero.
</issue_to_address>

### Comment 2
<location path="app/view/schedule_interface/schedule_interface.py" line_range="468-471" />
<code_context>

         self.schedule_table = TableWidget(self)
-        self.schedule_table.setColumnCount(8)
+        self.schedule_table.setColumnCount(9)
         self.schedule_table.setSizePolicy(
             QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
</code_context>
<issue_to_address>
**suggestion:** Column indices for the schedule table are now more fragile; consider centralizing them to avoid off-by-one errors.

The new 9-column layout hard-codes the indices for "Force", "Reuse", "Admin", "Enabled", and "Action" in several places (headers, resize modes, widths, hidden-column logic, row population). This scatters magic numbers (5/6/7/8) and makes future reordering brittle. Defining named constants or an enum for these column indices would localize changes and reduce the risk of mismatched columns as the table evolves.

Suggested implementation:

```python
        layout.addWidget(title)

        # Centralized column indices for schedule_table to avoid scattered magic numbers.
        # NOTE: Verify these indices match the actual header order:
        # [ ..., "Pattern", "Next run", "Force", "Reuse", "Admin", "Enabled", "Action" ]
        self.COL_FORCE = 5
        self.COL_REUSE = 6
        self.COL_ADMIN = 7
        self.COL_ENABLED = 8
        self.COL_ACTION = 9  # Adjust to the correct index based on actual column layout.

        self.schedule_table = TableWidget(self)
        self.schedule_table.setColumnCount(9)
        self.schedule_table.setSizePolicy(
            QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
        )

```

To fully implement the centralization and remove fragile indices, you should:

1. Replace all hard-coded indices for these columns in the file:
   - Any occurrence of `5` that refers to "Force" should become `self.COL_FORCE`.
   - Any occurrence of `6` that refers to "Reuse" should become `self.COL_REUSE`.
   - Any occurrence of `7` that refers to "Admin" should become `self.COL_ADMIN`.
   - Any occurrence of the "Enabled" column index (likely `4` or `8` depending on your layout) should become `self.COL_ENABLED`.
   - Any occurrence of `8` or `9` that refers to "Action" should become `self.COL_ACTION`.

2. Update:
   - Header setup (if you manually set header labels by index rather than via `enumerate`).
   - Column resize modes (e.g. `self.schedule_table.horizontalHeader().setSectionResizeMode(index, ...)`).
   - Column widths (e.g. `setColumnWidth(index, ...)`).
   - Hidden/visible column logic (e.g. `setColumnHidden(index, ...)`).
   - Row population logic (e.g. `setItem(row, index, ...)`, `setCellWidget(row, index, ...)`).

3. After replacing all usages, double-check the actual header order and adjust the values assigned to `self.COL_FORCE`, `self.COL_REUSE`, `self.COL_ADMIN`, `self.COL_ENABLED`, and `self.COL_ACTION` so they match the real indices (0-based) of your table columns. This will ensure future column reordering only requires changes in this one centralized block.
</issue_to_address>

### Comment 3
<location path="tests/test_reuse_existing.py" line_range="13-22" />
<code_context>
+)
+
+
+class ReuseExistingTests(unittest.TestCase):
+    def test_cli_parses_reuse_existing(self) -> None:
+        options, _, _ = parse_startup_cli(
+            ["--config-id=cfg_demo", "--direct-run", FLAG_REUSE_EXISTING]
+        )
+        self.assertTrue(options.reuse_existing)
+        self.assertEqual(options.config_id, "cfg_demo")
+
+    def test_cli_serializes_reuse_existing(self) -> None:
+        argv = build_startup_argv(
+            StartupOptions(config_id="cfg_demo", reuse_existing=True)
+        )
+        self.assertIn(FLAG_REUSE_EXISTING, argv)
+
+    def test_schedule_entry_defaults_reuse_existing_to_false(self) -> None:
</code_context>
<issue_to_address>
**suggestion (testing):** Add tests to cover the negative/default cases for `reuse_existing` in CLI

Right now only the "flag present" path is tested. Please also add tests that assert: (1) `parse_startup_cli` sets `options.reuse_existing` to `False` when the flag is omitted, and (2) `build_startup_argv` omits `FLAG_REUSE_EXISTING` when `reuse_existing` is `False`, so changes to defaults or unintended flag inclusion are caught early.
</issue_to_address>

### Comment 4
<location path="tests/test_unix_system_scheduler.py" line_range="147-151" />
<code_context>
         self.assertTrue(job.startswith("sudo "), f"expected sudo prefix, got: {job}")
         self.assertIn("--config-id=cfg_demo", job)

+    def test_build_shell_job_includes_reuse_existing(self) -> None:
+        job = build_shell_job(
+            "cfg_demo", force_start=False, reuse_existing=True
+        )
+        self.assertIn("--reuse-existing", job)
+
     def test_split_preserves_other_instance_blocks(self) -> None:
</code_context>
<issue_to_address>
**suggestion (testing):** Also assert that `--reuse-existing` is absent when `reuse_existing=False`

Since we now cover the `reuse_existing=True` case, please add a complementary test for `reuse_existing=False` that asserts `"--reuse-existing"` is not present in the job string. This helps ensure the flag is only added when explicitly requested and protects against regressions where it could be appended unconditionally.
</issue_to_address>

### Comment 5
<location path="tests/test_windows_system_scheduler.py" line_range="74-70" />
<code_context>
+            "cfg_demo", force_start=True, reuse_existing=False
+        )
+
+    @patch(
+        "app.core.service.system_scheduler.windows.resolve_schedule_launch_command",
+        return_value=(
+            r"C:\MFW\MFW.exe",
+            "--config-id=cfg_demo --direct-run --reuse-existing",
+        ),
+    )
+    def test_reuse_existing_uses_cli_arguments(self, mock_command: object) -> None:
+        build_task_xml(
+            self._entry(
+                schedule_type=SCHEDULE_SINGLE,
+                params={"run_at": "2025-06-18T09:30:00"},
+                reuse_existing=True,
+            )
+        )
+        mock_command.assert_called_once_with(
+            "cfg_demo", force_start=False, reuse_existing=True
+        )
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding tests for parsing `--reuse-existing` back into `ScheduleEntry` from Windows tasks

The current test verifies `build_task_xml` forwards `reuse_existing=True` into `resolve_schedule_launch_command`, but we’re missing coverage for the reverse direction. Since the Windows scheduler now parses `--reuse-existing` into `ScheduleEntry.reuse_existing`, please add tests that construct minimal task XML with and without `--reuse-existing` in the arguments and assert that the parsed `ScheduleEntry.reuse_existing` is `True` and `False` respectively to validate round-trip behavior.

Suggested implementation:

```python
    @patch(
        "app.core.service.system_scheduler.windows.resolve_schedule_launch_command",
        return_value=(
            r"C:\MFW\MFW.exe",
            "--config-id=cfg_demo --direct-run --reuse-existing",
        ),
    )
    def test_reuse_existing_uses_cli_arguments(self, mock_command: object) -> None:
        build_task_xml(
            self._entry(
                schedule_type=SCHEDULE_SINGLE,
                params={"run_at": "2025-06-18T09:30:00"},
                reuse_existing=True,
            )
        )
        mock_command.assert_called_once_with(
            "cfg_demo", force_start=False, reuse_existing=True
        )

    def test_parse_task_xml_sets_reuse_existing_true(self) -> None:
        """Task XML with --reuse-existing in the arguments should set ScheduleEntry.reuse_existing=True."""
        # Minimal task XML containing the expected launch command arguments including --reuse-existing
        task_xml = """
        <Task xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
          <RegistrationInfo>
            <Date>2025-06-18T09:30:00</Date>
          </RegistrationInfo>
          <Triggers>
            <TimeTrigger>
              <StartBoundary>2025-06-18T09:30:00</StartBoundary>
            </TimeTrigger>
          </Triggers>
          <Actions Context="Author">
            <Exec>
              <Command>C:\\MFW\\MFW.exe</Command>
              <Arguments>--config-id=cfg_demo --direct-run --reuse-existing</Arguments>
            </Exec>
          </Actions>
        </Task>
        """.strip()

        # This helper/function name should match whatever is currently used in tests
        # to parse Windows task XML into ScheduleEntry instances.
        entry = parse_task_xml_to_schedule_entry(task_xml)

        assert isinstance(entry, ScheduleEntry)
        assert entry.reuse_existing is True

    def test_parse_task_xml_sets_reuse_existing_false_by_default(self) -> None:
        """Task XML without --reuse-existing should set ScheduleEntry.reuse_existing=False."""
        task_xml = """
        <Task xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
          <RegistrationInfo>
            <Date>2025-06-18T09:30:00</Date>
          </RegistrationInfo>
          <Triggers>
            <TimeTrigger>
              <StartBoundary>2025-06-18T09:30:00</StartBoundary>
            </TimeTrigger>
          </Triggers>
          <Actions Context="Author">
            <Exec>
              <Command>C:\\MFW\\MFW.exe</Command>
              <Arguments>--config-id=cfg_demo --direct-run</Arguments>
            </Exec>
          </Actions>
        </Task>
        """.strip()

        entry = parse_task_xml_to_schedule_entry(task_xml)

        assert isinstance(entry, ScheduleEntry)
        assert entry.reuse_existing is False

```

The added tests assume the existence of:
- A function `parse_task_xml_to_schedule_entry(task_xml: str) -> ScheduleEntry` that takes the Windows task XML and returns a `ScheduleEntry`.
- A `ScheduleEntry` type with a `reuse_existing` boolean attribute.

To integrate with your existing codebase, you will likely need to:
1. Replace `parse_task_xml_to_schedule_entry` with the actual helper/function already used in this test module to parse Windows task XML (for example, it might be a method on a scheduler instance or a module-level function).
2. Ensure the minimal XML structure in these tests matches what the parser expects; if your parser requires additional elements or specific namespaces, adjust the XML strings accordingly (follow patterns from other parsing tests in `tests/test_windows_system_scheduler.py`).
3. If you already have tests that parse tasks into `ScheduleEntry`, you may prefer to extend those existing tests by adding `reuse_existing` assertions instead of introducing a new helper; in that case, inline the XML and parsing calls following the existing conventions.
4. If `ScheduleEntry` lives in a different module, add/adjust the import at the top of the test file (e.g., `from app.core.service.system_scheduler.model import ScheduleEntry`) to make the `isinstance(entry, ScheduleEntry)` checks compile.
These adjustments will ensure the new tests correctly validate round-trip behavior of the `reuse_existing` flag between CLI arguments and `ScheduleEntry` parsing for Windows tasks.
</issue_to_address>

### Comment 6
<location path="tests/test_single_instance_restart.py" line_range="29-32" />
<code_context>
     def test_shutdown_command_constants(self) -> None:
         self.assertEqual(CMD_ACTIVATE, b"activate")
         self.assertEqual(CMD_SHUTDOWN, b"shutdown")
+        self.assertEqual(CMD_RUN_CONFIG, b"run-config:")
         self.assertEqual(RESP_OK, b"ok")

</code_context>
<issue_to_address>
**suggestion (testing):** Add tests for the new IPC `run-config` server/client behavior, not just the constant

The constant is covered, but the core IPC behavior isn’t. Please add tests that:

- Mock `_on_run_config` and verify that a valid JSON payload (`config_id`, `force_start`) causes `_consume_activation_request` to return the bytes from `_on_run_config`, and that invalid JSON or missing `config_id` yields `RESP_INVALID`.
- Cover `request_existing_instance_run` by mocking `QLocalSocket` to simulate connect/write/read success and different server responses (`RESP_ACCEPTED`, `RESP_BUSY`, `RESP_INVALID`, failures), including connection failures, partial writes, and timeouts, and assert the client always returns lowercased response bytes.

Suggested implementation:

```python
import json
from unittest import TestCase
from unittest.mock import patch

from app.utils.single_instance import (
    CMD_ACTIVATE,
    CMD_RUN_CONFIG,
    CMD_SHUTDOWN,
    RESP_OK,
    RESP_INVALID,
    RESP_ACCEPTED,
    RESP_BUSY,
    _consume_activation_request,
    request_existing_instance_run,
    process_matches_install_anchor,
)


class SingleInstanceRestartTests(TestCase):
    def test_shutdown_command_constants(self) -> None:
        self.assertEqual(CMD_ACTIVATE, b"activate")
        self.assertEqual(CMD_SHUTDOWN, b"shutdown")
        self.assertEqual(CMD_RUN_CONFIG, b"run-config:")

    def test_consume_activation_request_run_config_valid_payload(self) -> None:
        payload = {
            "config_id": "abc123",
            "force_start": True,
        }
        data = CMD_RUN_CONFIG + json.dumps(payload).encode("utf-8")

        with patch("app.utils.single_instance._on_run_config", return_value=b"config-run") as on_run:
            resp = _consume_activation_request(data)

        on_run.assert_called_once_with(config_id="abc123", force_start=True)
        self.assertEqual(resp, b"config-run")

    def test_consume_activation_request_run_config_invalid_json(self) -> None:
        # invalid JSON after the run-config prefix
        data = CMD_RUN_CONFIG + b'{"config_id": '

        resp = _consume_activation_request(data)

        self.assertEqual(resp, RESP_INVALID)

    def test_consume_activation_request_run_config_missing_config_id(self) -> None:
        # valid JSON but missing required config_id
        payload = {"force_start": False}
        data = CMD_RUN_CONFIG + json.dumps(payload).encode("utf-8")

        resp = _consume_activation_request(data)

        self.assertEqual(resp, RESP_INVALID)

    def test_request_existing_instance_run_success_responses(self) -> None:
        # Simulate a server that accepts the request
        with patch("app.utils.single_instance.QLocalSocket") as socket_cls:
            socket = socket_cls.return_value
            socket.connectToServer.return_value = None
            socket.waitForConnected.return_value = True
            socket.write.return_value = len(CMD_RUN_CONFIG)
            socket.waitForBytesWritten.return_value = True
            socket.readAll.return_value = RESP_ACCEPTED

            resp = request_existing_instance_run(config_id="abc123", force_start=False)

        socket.connectToServer.assert_called_once()
        socket.write.assert_called_once()
        socket.waitForBytesWritten.assert_called_once()
        socket.readAll.assert_called_once()
        self.assertEqual(resp, RESP_ACCEPTED.lower())

        # Simulate a busy server
        with patch("app.utils.single_instance.QLocalSocket") as socket_cls:
            socket = socket_cls.return_value
            socket.connectToServer.return_value = None
            socket.waitForConnected.return_value = True
            socket.write.return_value = len(CMD_RUN_CONFIG)
            socket.waitForBytesWritten.return_value = True
            socket.readAll.return_value = RESP_BUSY

            resp = request_existing_instance_run(config_id="abc123", force_start=False)

        self.assertEqual(resp, RESP_BUSY.lower())

        # Simulate invalid request response
        with patch("app.utils.single_instance.QLocalSocket") as socket_cls:
            socket = socket_cls.return_value
            socket.connectToServer.return_value = None
            socket.waitForConnected.return_value = True
            socket.write.return_value = len(CMD_RUN_CONFIG)
            socket.waitForBytesWritten.return_value = True
            socket.readAll.return_value = RESP_INVALID

            resp = request_existing_instance_run(config_id="abc123", force_start=False)

        self.assertEqual(resp, RESP_INVALID.lower())

    def test_request_existing_instance_run_failures(self) -> None:
        # Connection failure
        with patch("app.utils.single_instance.QLocalSocket") as socket_cls:
            socket = socket_cls.return_value
            socket.connectToServer.return_value = None
            socket.waitForConnected.return_value = False

            resp = request_existing_instance_run(config_id="abc123", force_start=False)

        self.assertEqual(resp, RESP_INVALID.lower())

        # Partial write
        with patch("app.utils.single_instance.QLocalSocket") as socket_cls:
            socket = socket_cls.return_value
            socket.connectToServer.return_value = None
            socket.waitForConnected.return_value = True
            socket.write.return_value = 0  # less than expected
            socket.waitForBytesWritten.return_value = False

            resp = request_existing_instance_run(config_id="abc123", force_start=False)

        self.assertEqual(resp, RESP_INVALID.lower())

        # Timeout / empty read
        with patch("app.utils.single_instance.QLocalSocket") as socket_cls:
            socket = socket_cls.return_value
            socket.connectToServer.return_value = None
            socket.waitForConnected.return_value = True
            socket.write.return_value = len(CMD_RUN_CONFIG)
            socket.waitForBytesWritten.return_value = True
            socket.readAll.return_value = b""

            resp = request_existing_instance_run(config_id="abc123", force_start=False)

        self.assertEqual(resp, RESP_INVALID.lower())

```

- Ensure `app.utils.single_instance` actually exposes `_consume_activation_request`, `_on_run_config`, `request_existing_instance_run`, `RESP_INVALID`, `RESP_ACCEPTED`, `RESP_BUSY`, and `QLocalSocket` at the import paths used above; if they live in a submodule, adjust the imports and patch targets accordingly.
- The tests assume `_consume_activation_request` treats any JSON parsing error or missing `config_id` as `RESP_INVALID`, and that `request_existing_instance_run` normalizes all server responses to lowercase and returns `RESP_INVALID` on connection/write/read failures; align the implementation with these semantics if it currently differs.
- If the test module already defines a `TestCase` subclass, you may want to merge these tests into that existing class instead of introducing `SingleInstanceRestartTests`, or rename to match your existing naming convention.
</issue_to_address>

### Comment 7
<location path="tests/test_reuse_existing.py" line_range="39-48" />
<code_context>
+        )
+        self.assertFalse(entry.reuse_existing)
+
+    def test_schedule_entry_serializes_reuse_existing(self) -> None:
+        entry = ScheduleEntry(
+            entry_id="sched_demo",
+            config_id="cfg_demo",
+            name="Demo",
+            schedule_type="daily",
+            params={},
+            force_start=False,
+            enabled=True,
+            created_at=datetime.now(),
+            reuse_existing=True,
+        )
+        self.assertTrue(entry.to_dict()["reuse_existing"])
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Consider a round‑trip test for `ScheduleEntry.reuse_existing` through system schedulers

These tests cover `ScheduleEntry`’s serialization/deserialization of `reuse_existing` in isolation. Given the new Windows/Unix scheduler integrations, please also add an end‑to‑end test that builds scheduler artifacts from a `ScheduleEntry` with `reuse_existing=True`, parses them back via the scheduler APIs, and asserts that `reuse_existing` is preserved. This helps detect any mismatch between `ScheduleEntry` and scheduler handling of the new flag.

Suggested implementation:

```python
class ReuseExistingTests(unittest.TestCase):

    def test_startup_cli_round_trip_preserves_reuse_existing(self) -> None:
        # Build a ScheduleEntry with reuse_existing enabled
        entry = ScheduleEntry(
            entry_id="sched_demo_cli",
            config_id="cfg_demo_cli",
            name="Demo CLI",
            schedule_type="daily",
            params={},
            force_start=False,
            enabled=True,
            created_at=datetime.now(),
            reuse_existing=True,
        )

        # Convert ScheduleEntry into StartupOptions and build argv for the system scheduler
        startup_options = StartupOptions(
            config_id=entry.config_id,
            force_start=entry.force_start,
            reuse_existing=entry.reuse_existing,
        )
        argv = build_startup_argv(startup_options)

        # Sanity check that the CLI flag for reuse_existing is present
        self.assertIn(FLAG_REUSE_EXISTING, argv)

        # Parse back via the scheduler CLI and assert reuse_existing is preserved
        parsed_options = parse_startup_cli(argv)
        self.assertTrue(parsed_options.reuse_existing)

    def test_startup_cli_round_trip_without_reuse_existing(self) -> None:
        # Build a ScheduleEntry with reuse_existing disabled
        entry = ScheduleEntry(
            entry_id="sched_demo_cli_disabled",
            config_id="cfg_demo_cli_disabled",
            name="Demo CLI Disabled",
            schedule_type="daily",
            params={},
            force_start=False,
            enabled=True,
            created_at=datetime.now(),
            reuse_existing=False,
        )

        startup_options = StartupOptions(
            config_id=entry.config_id,
            force_start=entry.force_start,
            reuse_existing=entry.reuse_existing,
        )
        argv = build_startup_argv(startup_options)

        # Sanity check that the CLI flag for reuse_existing is not present
        self.assertNotIn(FLAG_REUSE_EXISTING, argv)

        # Parse back via the scheduler CLI and assert reuse_existing remains False
        parsed_options = parse_startup_cli(argv)
        self.assertFalse(parsed_options.reuse_existing)

```

Depending on the actual `StartupOptions` signature and scheduler integration, you may need to:
1. Adjust the `StartupOptions` constructor arguments to match the real fields (e.g. if `config_id` or `force_start` are named differently, or additional mandatory parameters exist).
2. Ensure that `build_startup_argv` and `parse_startup_cli` are the correct APIs for round‑tripping through both Windows and Unix schedulers; if there are OS‑specific wrappers, you might want mirrored tests for each wrapper that call into these functions.
3. If there is already a helper that maps `ScheduleEntry` to `StartupOptions`, replace the manual `StartupOptions(...)` construction with that helper to stay consistent with the production pipeline.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread main.py Outdated
Comment thread app/view/schedule_interface/schedule_interface.py
Comment thread tests/test_reuse_existing.py
Comment thread tests/test_unix_system_scheduler.py
Comment thread tests/test_windows_system_scheduler.py
Comment thread tests/test_single_instance_restart.py
Comment thread tests/test_reuse_existing.py
Comment thread main.py Outdated
Comment thread main.py Outdated
Comment thread main.py Outdated
Comment thread app/utils/single_instance.py
Comment thread app/utils/single_instance.py Outdated
Comment thread main.py Outdated
@overflow65537
overflow65537 merged commit 20317f3 into overflow65537:main Aug 12, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: 支持复用已有进程执行任务

2 participants