Skip to content

Prevent duplicate instances and add IPC for check-now command#70

Merged
ericbsd merged 2 commits intomasterfrom
add-noduplication
Feb 7, 2026
Merged

Prevent duplicate instances and add IPC for check-now command#70
ericbsd merged 2 commits intomasterfrom
add-noduplication

Conversation

@ericbsd
Copy link
Member

@ericbsd ericbsd commented Feb 7, 2026

  • Use setproctitle and psutil to detect existing update-station processes
    • Send SIGUSR1 to running instance when 'check-now' is invoked, triggering update check in existing GTK loop
    • Consolidate notification.py into frontend.py to reduce module count
    • Simplify boolean returns in backend.py
    • Exit with error if tray mode started while instance is already running

This prevents multiple update-station processes from running simultaneously and allows the check-now command to communicate with the existing tray instance instead of spawning a separate GUI process.

Summary by Sourcery

Prevent multiple update-station GUI instances from running and route on-demand update checks to an existing tray process via IPC while improving major-upgrade handling and notifications.

New Features:

  • Add detection and handling of major system version upgrades, including prompting the user before upgrading.
  • Introduce an update notification and tray icon system integrated into the main frontend module.
  • Add a dedicated major upgrade confirmation window to guide users through upgrading between ABIs.

Enhancements:

  • Simplify several backend boolean helper functions and return paths for clarity and consistency.
  • Consolidate the previous notification functionality into the main frontend module to reduce module count and centralize UI behavior.

  - Use setproctitle and psutil to detect existing update-station processes
  - Send SIGUSR1 to running instance when 'check-now' is invoked, triggering update check in existing GTK loop
  - Consolidate notification.py into frontend.py to reduce module count
  - Simplify boolean returns in backend.py
  - Exit with error if tray mode started while instance is already running

This prevents multiple update-station processes from running simultaneously
and allows the check-now command to communicate with the existing tray
instance instead of spawning a separate GUI process.
@ericbsd ericbsd requested review from a team as code owners February 7, 2026 23:29
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 7, 2026

Reviewer's Guide

Implements single-instance behavior for update-station with IPC for the check-now command, consolidates tray/notification logic into the frontend, adds major-upgrade handling in the GTK UI, and simplifies several backend helpers’ boolean logic and I/O.

Sequence diagram for single-instance check-now IPC behavior

sequenceDiagram
    actor User
    participant CLIUpdateStation as CLI_update_station
    participant ProcessChecker as Process_checker
    participant RunningTray as Running_tray_instance
    participant GTK as GTK_main_loop
    participant Backend as Backend_helpers

    User->>CLIUpdateStation: run check-now
    CLIUpdateStation->>ProcessChecker: search for update-station process
    alt existing instance found
        ProcessChecker-->>CLIUpdateStation: PID of running instance
        CLIUpdateStation-->>RunningTray: send SIGUSR1
        RunningTray->>GTK: signal handler posts check-now event
        GTK->>Backend: check_for_update()
        Backend-->>GTK: update_available / none / major_upgrade
        alt updates available
            GTK-->>RunningTray: show update window / tray notification
        else major upgrade available
            GTK-->>RunningTray: show MajorUpgradeWindow
        else no updates
            GTK-->>RunningTray: show NoUpdateAvailable state
        end
        CLIUpdateStation-->>User: exit after notifying running instance
    else no existing instance
        ProcessChecker-->>CLIUpdateStation: no PID found
        CLIUpdateStation->>CLIUpdateStation: start tray mode GTK instance
        CLIUpdateStation-->>User: tray instance started
    end
Loading

Updated class diagram for frontend tray, notifier, and major upgrade UI

classDiagram
    class Data {
        <<static>> bool major_upgrade
        <<static>> bool kernel_upgrade
        <<static>> bool do_not_upgrade
        <<static>> string current_abi
        <<static>> string new_abi
        <<static>> bool close_session
        <<static>> TrayIcon system_tray
        <<static>> bool stop_pkg_refreshing
    }

    class UpdateNotifier {
        +NotifyNotification notification
        +string msg
        +int timeout
        +UpdateNotifier()
        +notify()
        +on_activated(notification, action_name)
    }

    class TrayIcon {
        +GtkStatusIcon status_icon
        +GtkMenu menu
        +TrayIcon()
        +GtkStatusIcon tray_icon()
        +GtkMenu nm_menu()
        +left_click(status_icon)
        +icon_clicked(status_icon, button, time)
    }

    class MajorUpgradeWindow {
        +MajorUpgradeWindow()
        +on_clicked(widget)
        +on_close(widget, event) bool
    }

    class GtkWindow

    UpdateNotifier --> Data : reads_flags_and_state
    UpdateNotifier --> MajorUpgradeWindow : opens_on_major_upgrade
    UpdateNotifier --> TrayIcon : hides_tray_icon

    TrayIcon --> Data : reads_and_updates_state
    TrayIcon --> MajorUpgradeWindow : opens_on_major_upgrade

    MajorUpgradeWindow --> Data : updates_upgrade_flags
    MajorUpgradeWindow --> UpdateNotifier : indirectly_triggers_update_check

    MajorUpgradeWindow ..|> GtkWindow
Loading

Flow diagram for single-instance process and tray mode startup

flowchart LR
    A["User runs update-station (tray or check-now)"] --> B["Set process title and scan for existing update-station instance"]
    B --> C{Existing instance found?}
    C -- Yes --> D["Send SIGUSR1 to running tray instance"]
    D --> E["Existing GTK loop handles signal and triggers check_for_update"]
    E --> F["Existing instance shows appropriate UI (update window, MajorUpgradeWindow, or no-update message)"]
    C -- No --> G["Start new GTK tray instance"]
    G --> H["Initialize TrayIcon and UpdateNotifier in frontend"]
    H --> I["Enter GTK main loop as single running instance"]
Loading

File-Level Changes

Change Details Files
Add major-upgrade detection and UI flow when no regular updates are found.
  • Extend frontend imports to include is_major_upgrade_available, get_current_abi, and get_abi_upgrade from the backend.
  • Update the update-check path to, when no regular updates are available, query for a major upgrade and, if allowed, set Data.major_upgrade/current_abi/new_abi and open MajorUpgradeWindow instead of the NoUpdateAvailable window.
  • Keep the existing no-update behavior when no major upgrade is available or upgrades are suppressed via Data.do_not_upgrade.
update_station/frontend.py
update_station/backend.py
Inline the notification and tray icon logic into the frontend and add a GTK window for confirming major upgrades.
  • Move the UpdateNotifier class from the removed notification.py into frontend.py and wire it to Data.major_upgrade and Data.kernel_upgrade to adjust the notification message and action.
  • Define a TrayIcon class in frontend.py that manages the Gtk.StatusIcon, left-click behavior (opening appropriate windows depending on updating/major_upgrade), and right-click menu for opening the updater or closing the app.
  • Introduce a MajorUpgradeWindow Gtk.Window that prompts the user to upgrade from Data.current_abi to Data.new_abi, drives Data.major_upgrade/Data.do_not_upgrade flags, and integrates with session close handling (Data.close_session) on window close.
update_station/frontend.py
update_station/notification.py
Simplify backend helpers by tightening boolean logic and return values.
  • Condense check_for_update into a single boolean expression based on pkg output contents instead of multiple if/elif branches.
  • Return values directly from get_default_repo_url and get_pkg_upgrade rather than via temporary variables.
  • Simplify is_major_upgrade_available and repository_is_syncing to return status_code == 200 from requests directly instead of ternary expressions.
  • Simplify kernel_version_change and updating to use direct membership / bool(os.path.exists(...)) checks rather than multi-branch conditionals.
update_station/backend.py
Adjust translations and templates to reflect frontend/notification changes.
  • Update localized .po files to account for new or changed strings related to major upgrades and tray/notification text.
  • Remove the update-station.pot template which is no longer used or regenerated in this change.
po/fr.po
po/fur.po
po/pt_BR.po
po/ru.po
po/zh_CN.po
po/update-station.pot
Remove the standalone notification module now that its logic has been consolidated into the frontend.
  • Delete update_station/notification.py and rely on frontend.py’s UpdateNotifier and TrayIcon classes for all notification and tray behavior.
update_station/notification.py

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

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

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

  • In MajorUpgradeWindow, building the prompt string with an f-string inside _() means gettext will never see the full sentence; consider using a format placeholder (e.g. _('Would you like to upgrade from {current} to {new}?').format(current=..., new=...)) so the full translatable string is extracted.
  • MajorUpgradeWindow.on_clicked relies on widget.get_label() == 'Yes', which will break if the button label is ever localized or changed; use separate callbacks or data attributes to distinguish the buttons instead of comparing the label text.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In MajorUpgradeWindow, building the prompt string with an f-string inside _() means gettext will never see the full sentence; consider using a format placeholder (e.g. _('Would you like to upgrade from {current} to {new}?').format(current=..., new=...)) so the full translatable string is extracted.
- MajorUpgradeWindow.on_clicked relies on widget.get_label() == 'Yes', which will break if the button label is ever localized or changed; use separate callbacks or data attributes to distinguish the buttons instead of comparing the label text.

## Individual Comments

### Comment 1
<location> `update_station/frontend.py:780-782` </location>
<code_context>
+        vbox.set_border_width(10)
+        self.add(vbox)
+
+        label = Gtk.Label(
+            label=_(f"Would you like to upgrade from {Data.current_abi} to {Data.new_abi}?"
+                    "\n\nIf you select No, the upgrade will be skipped until the next boot.")
+        )
</code_context>

<issue_to_address>
**suggestion:** Avoid using f-strings directly inside _() to keep the string properly translatable.

Using an f-string inside _() requires an exact match (including the dynamic values) in the translation catalog, which usually won’t exist and degrades i18n. Prefer a placeholder-based string, e.g.:

`_("Would you like to upgrade from {current} to {new}?").format(current=Data.current_abi, new=Data.new_abi)`

Then either append the second sentence separately or include it in the same translatable string.

```suggestion
        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
        vbox.set_border_width(10)
        self.add(vbox)

        label = Gtk.Label(
            label=_(
                "Would you like to upgrade from {current} to {new}?\n\n"
                "If you select No, the upgrade will be skipped until the next boot."
            ).format(current=Data.current_abi, new=Data.new_abi)
        )
        vbox.pack_start(label, True, True, 0)
```
</issue_to_address>

### Comment 2
<location> `update_station/frontend.py:791-795` </location>
<code_context>
+        vbox.pack_start(label, True, True, 5)
+        hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
+        vbox.pack_start(hbox, False, False, 0)
+        button1 = Gtk.Button(label="Yes")
+        button1.connect("clicked", self.on_clicked)
+        hbox.pack_end(button1, True, True, 0)
+
+        button2 = Gtk.Button(label="No")
+        button2.connect("clicked", self.on_clicked)
+        hbox.pack_end(button2, True, True, 0)
</code_context>

<issue_to_address>
**issue:** Using hardcoded "Yes"/"No" labels and comparing by label makes the logic brittle and non-localizable.

`on_clicked` currently checks `widget.get_label() == "Yes"`, coupling behavior to UI text and blocking localization. Prefer either distinct callbacks per button, attaching explicit identifiers (e.g. `widget.set_name("confirm_upgrade")`), or using response IDs. Also, wrap the button labels in `_()` to match the rest of the UI’s translation handling.
</issue_to_address>

### Comment 3
<location> `update_station/frontend.py:719` </location>
<code_context>
+        The constructor for the TrayIcon class.
+        """
+        self.status_icon = Gtk.StatusIcon()
+        self.status_icon.set_tooltip_text('Update Available')
+        self.menu = Gtk.Menu()
+        self.menu.show_all()
</code_context>

<issue_to_address>
**suggestion:** Tooltip text for the tray icon is not translated, unlike the rest of the UI.

This tooltip should also go through `_()` for consistency with the rest of the UI and to ensure it’s localizable.

Suggested implementation:

```python
        self.status_icon = Gtk.StatusIcon()
        self.status_icon.set_tooltip_text(_('Update Available'))
        self.menu = Gtk.Menu()

```

If `_` is not yet imported/defined in this module, you will also need to:
1. Import it at the top of the file, e.g. `from gettext import gettext as _` (or match the existing i18n pattern used elsewhere in this project).
</issue_to_address>

### Comment 4
<location> `update_station/backend.py:253` </location>
<code_context>
     """
     next_version = f'{get_default_repo_url()}/.next_version'
-    return True if requests.get(next_version).status_code == 200 else False
+    return requests.get(next_version).status_code == 200


</code_context>

<issue_to_address>
**suggestion (bug_risk):** Network call in is_major_upgrade_available lacks a timeout and may block indefinitely.

Because this may be called from UI-driven flows, an unbounded `requests.get` can cause the UI to hang if the server is slow or unreachable. Please add a reasonable timeout (e.g. `timeout=5`) and handle failures by treating them as "no upgrade available".

Suggested implementation:

```python
def get_pkg_upgrade_data() -> dict:
    """
    This function is used to get the upgrade data from pkg.
    :return: True if the major upgrade is ready else False.
    """
    next_version = f'{get_default_repo_url()}/.next_version'

    try:
        response = requests.get(next_version, timeout=5)
    except requests.RequestException:
        # If we cannot reach the server or the request fails for any reason,
        # treat it as "no upgrade available" to avoid blocking UI flows.
        return False

    return response.status_code == 200

```

1. Ensure `requests` is imported at the top of `update_station/backend.py`, e.g.:
   `import requests`
2. If the return type is intended to be a boolean, consider updating the type hint and docstring to `-> bool` for accuracy.
</issue_to_address>

### Comment 5
<location> `update_station/backend.py:327` </location>
<code_context>
     """
     syncing_url = f'{get_default_repo_url()}/.syncing'
-    return True if requests.get(syncing_url).status_code == 200 else False
+    return requests.get(syncing_url).status_code == 200


</code_context>

<issue_to_address>
**suggestion (bug_risk):** Similar to is_major_upgrade_available, repository_is_syncing should use a timeout on the HTTP request.

To prevent the call from hanging on slow or unreachable mirrors, pass a small timeout to `requests.get` (e.g., `timeout=5`) and handle the potential timeout/connection errors so the check remains responsive.

Suggested implementation:

```python
    syncing_url = f'{get_default_repo_url()}/.syncing'
    try:
        response = requests.get(syncing_url, timeout=5)
    except (requests.exceptions.Timeout,
            requests.exceptions.ConnectionError,
            requests.exceptions.RequestException):
        return False

    return response.status_code == 200

```

1. Ensure `requests` is imported at the top of `update_station/backend.py`:
   - `import requests`
2. If there is a shared timeout configuration or constant in this project, replace the hard-coded `timeout=5` with that shared value to stay consistent with existing conventions.
</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.

- Backend: Add try-except handling with timeouts for server request failures (`requests.get`), preventing UI blocking during unreachable servers.
- Frontend: Localize UI strings with `_()` for better internationalization support.
- UI: Refactor dialog buttons to invoke corresponding "Yes" and "No" click handlers, improving code clarity and maintainability.
@ericbsd ericbsd merged commit f2efcdb into master Feb 7, 2026
1 check passed
@ericbsd ericbsd deleted the add-noduplication branch February 7, 2026 23:43
@github-project-automation github-project-automation bot moved this from In Review to Done in Development Tracker Feb 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant