Skip to content

kraken: allow cancelling install/update operations#3857

Open
nicoschmdt wants to merge 3 commits intobluerobotics:masterfrom
nicoschmdt:stop-install
Open

kraken: allow cancelling install/update operations#3857
nicoschmdt wants to merge 3 commits intobluerobotics:masterfrom
nicoschmdt:stop-install

Conversation

@nicoschmdt
Copy link
Copy Markdown
Contributor

@nicoschmdt nicoschmdt commented Mar 26, 2026

fix: #3631

since we intend to move away from FastAPI and into zenoh I focused the operation cancellation handling mostly on the frontend code.

Summary by Sourcery

Add support for cancelling in-progress Kraken extension install and update operations from the frontend while simplifying backend install/update behavior and streaming lifetime management.

New Features:

  • Allow users to cancel ongoing extension install, update, and install-from-file operations from the UI with a dedicated cancel action.
  • Expose API support for uninstalling a specific extension version by identifier and tag for rollback after cancelled operations.

Bug Fixes:

  • Ensure progress/error handlers and install-from-file flows do not emit spurious errors after a request has been cancelled.
  • Prevent stale install/update state in the UI by centralizing operation completion logic and aborting active controllers on component teardown.

Enhancements:

  • Track a single active install/update operation in the frontend using AbortController, tying progress tracking and cleanup to its lifecycle.
  • Refine extension install/update logic to register settings after image pull, optionally enable on install/update, and emit structured status messages during registration and cleanup.
  • Improve async streaming helpers to properly close generators and cancel background tasks when streams end or are aborted.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai bot commented Mar 26, 2026

Reviewer's Guide

Implements cancelable extension install/update workflows by wiring AbortController-based cancellation through the frontend, adding rollback via per-version uninstall APIs, improving streaming cleanup for aborted operations, and adjusting backend extension install/update semantics and status messaging.

Sequence diagram for cancelable extension install/update workflow

sequenceDiagram
    actor User
    participant ExtensionManagerView
    participant PullProgress
    participant KrakenManager
    participant Axios
    participant KrakenAPI as Kraken_API
    participant Extension as Extension_backend

    User->>ExtensionManagerView: click_install_or_update(extension, version)
    ExtensionManagerView->>ExtensionManagerView: beginInstallOperation()
    ExtensionManagerView->>PullProgress: show(cancelable=true)
    ExtensionManagerView->>KrakenManager: installExtension/updateExtensionToVersion/finalizeExtension(..., signal)
    KrakenManager->>Axios: back_axios(request_with_signal)
    Axios->>KrakenAPI: HTTP_request(stream)
    KrakenAPI->>Extension_backend: extension.install/update(...)
    Extension_backend->>Extension_backend: _pull_docker_image()
    Extension_backend-->>KrakenAPI: streaming_bytes(status_and_progress)
    KrakenAPI-->>Axios: streaming_response
    Axios-->>KrakenManager: progress_events
    KrakenManager-->>ExtensionManagerView: progress_events
    ExtensionManagerView-->>PullProgress: update_progress(download, extraction, statustext)

    User->>PullProgress: click_Cancel
    PullProgress-->>ExtensionManagerView: cancel_event
    ExtensionManagerView->>ExtensionManagerView: cancelInstallOperation()
    ExtensionManagerView->>Axios: AbortController.abort() via signal
    Axios-->>KrakenAPI: connection_terminated
    KrakenAPI->>Extension_backend: generator.aclose()
    Extension_backend->>Extension_backend: unlock_and_cleanup()

    Axios-->>KrakenManager: cancel_error(axios.isCancel)
    KrakenManager-->>ExtensionManagerView: cancel_error
    ExtensionManagerView->>KrakenManager: uninstallExtensionVersion(identifier, tag)
    KrakenManager->>KrakenAPI: DELETE /extension/{identifier}/{tag}
    KrakenAPI->>Extension_backend: uninstall()
    Extension_backend-->>KrakenAPI: uninstall_complete
    KrakenAPI-->>KrakenManager: 202 Accepted
    KrakenManager-->>ExtensionManagerView: uninstall_done

    ExtensionManagerView->>ExtensionManagerView: finishInstallOperation()
    ExtensionManagerView->>PullProgress: hide(cancelable=false)
    ExtensionManagerView->>ExtensionManagerView: fetchInstalledExtensions()
    ExtensionManagerView-->>User: show_info_notification(install_or_update_cancelled)
Loading

Class diagram for updated extension install/update and frontend cancellation handling

classDiagram
    class Extension {
      -identifier: str
      -tag: str
      -source: Any
      -digest: Optional[str]
      -unique_entry: str
      +_create_extension_settings(should_enable: bool = True) ExtensionSettings
      +_pull_docker_image(docker_auth: Optional[str]) AsyncGenerator[bytes, None]
      +_clear_remaining_tags() AsyncGenerator[None, None]
      +install(clear_remaining_tags: bool = True, should_enable: bool = True) AsyncGenerator[bytes, None]
      +update(clear_remaining_tags: bool, should_enable: bool = True) AsyncGenerator[bytes, None]
      +uninstall() None
      +_disable_running_extension() Optional[Extension]
      +_image_is_available_locally() bool
      +lock(entry: str) None
      +unlock(entry: str) None
      +reset_start_attempt(entry: str) None
      +_save_settings(settings: ExtensionSettings) None
      +finalize_temporary_extension(temp_extension: Extension, identifier: str, body: Any) Extension
      +from_manifest(identifier: str, tag: str) Extension
      <<static>> _status_message(message: str) bytes
    }

    class ExtensionSettings {
      +identifier: str
      +name: str
      +docker: str
      +tag: str
      +permissions: Any
      +enabled: bool
      +user_permissions: Any
    }

    Extension --> ExtensionSettings : creates

    class ExtensionRouterV2 {
      +install(body: ExtensionSource) StreamingResponse
      +install_from_identifier(identifier: str, body: ExtensionInstall) StreamingResponse
      +update_to_tag(identifier: str, tag: str, purge: bool = True, should_enable: bool = True) StreamingResponse
      +finalize_extension(temp_identifier: str, body: ExtensionFinalize) StreamingResponse
    }

    ExtensionRouterV2 --> Extension : uses

    class ExtensionRouterV1 {
      +install_extension(body: ExtensionSource) StreamingResponse
    }

    ExtensionRouterV1 --> Extension : uses

    class ExtensionManagerView {
      -active_abort_controller: AbortController
      -active_operation_identifier: string
      -active_operation_type: string
      +beginInstallOperation() AbortController
      +cancelInstallOperation() void
      +finishInstallOperation() void
      +showAlertError(error: unknown) void
      +update(extension: InstalledExtensionData, version: string) Promise~void~
      +install(extension: InstalledExtensionData) Promise~void~
      +finalizeExtension(extension: InstalledExtensionData) Promise~void~
      +getTracker(signal: AbortSignal) PullTracker
      +destroyed() void
    }

    class KrakenManager {
      +installExtension(extension: InstalledExtensionData, progressHandler: function, signal: AbortSignal) Promise~void~
      +updateExtensionToVersion(identifier: string, version: string, progressHandler: function, signal: AbortSignal) Promise~void~
      +finalizeExtension(extension: InstalledExtensionData, tempTag: string, progressHandler: function, signal: AbortSignal) Promise~void~
      +uninstallExtension(identifier: string) Promise~void~
      +uninstallExtensionVersion(identifier: string, tag: string) Promise~void~
    }

    class PullProgress {
      +download: number
      +extraction: number
      +statustext: string
      +show: boolean
      +cancelable: boolean
      +$emit_cancel()
    }

    ExtensionManagerView --> KrakenManager : calls
    ExtensionManagerView --> PullProgress : binds_props_and_listens_cancel

    class StreamingUtils {
      +generator_wrapper(gen: AsyncGenerator[str|bytes, None], queue: asyncio.Queue) AsyncGenerator[str|bytes, None]
      +_fetch_stream(gen: AsyncGenerator[bytes, None], queue: asyncio.Queue) None
    }

    StreamingUtils ..> Extension : wraps_install_update_generators
    ExtensionRouterV2 ..> StreamingUtils : uses_streamer_wrapper
    ExtensionRouterV1 ..> StreamingUtils : uses_streamer_wrapper
Loading

File-Level Changes

Change Details Files
Make extension install/update/finalize operations cancelable in the ExtensionManager UI using AbortController, with centralized completion/error handling and rollback on cancel.
  • Add active_abort_controller state, begin/cancel helpers, and lifecycle cleanup that aborts in‑flight operations when ExtensionManagerView is destroyed
  • Plumb AbortSignal into getTracker, install, update, and finalize flows so axios requests and pull tracking can be cancelled
  • Handle axios cancellation errors distinctly from real failures, rolling back by uninstalling the specific extension version and showing info notifications on cancel
  • Centralize alert display and post-operation cleanup via showAlertError and finishInstallOperation to ensure consistent UI state and extension refresh
core/frontend/src/views/ExtensionManagerView.vue
Expose cancellable extension operations and per-version uninstall on the frontend KrakenManager API.
  • Extend installExtension, updateExtensionToVersion, and finalizeExtension helpers to accept an optional AbortSignal and pass it to axios
  • Add uninstallExtensionVersion helper that calls the new DELETE /extension/{identifier}/{tag} endpoint
  • Export uninstallExtensionVersion from the default KrakenManager export
core/frontend/src/components/kraken/KrakenManager.ts
Add optional cancel controls to the pull progress dialog to surface cancellation in the UI.
  • Add a cancelable prop to PullProgress.vue and conditionally render a Cancel button that emits a cancel event
  • Wire the ExtensionManagerView pull progress usage to pass cancelable and listen for cancel events
core/frontend/src/components/utils/PullProgress.vue
core/frontend/src/views/ExtensionManagerView.vue
Adjust backend extension install/update behavior to support non-atomic installs, configurable enabling, richer status messages, and per-version uninstall endpoint; improve streaming resource cleanup for cancelled requests.
  • Change Extension._create_extension_settings to accept a should_enable flag and use it when creating settings
  • Refactor Extension.install to drop the atomic parameter, move settings creation after image pull, disable currently running extension, and yield JSON status messages for registration and clearing old versions
  • Update Extension.update to take a should_enable flag and pass it through to install, and wire should_enable through the v2 update_to_tag endpoint
  • Update v1/v2 install and finalize endpoints to call install() without atomic semantics
  • Introduce Extension._status_message helper for consistent JSON status lines
  • Improve generator_wrapper and _fetch_stream to always aclose the underlying async generators and to cancel the wrapper task when the outer consumer stops, avoiding leaks on cancellation
  • Add v2 DELETE /extension/{identifier}/{tag} router that uninstalls a specific extension version
core/services/kraken/extension/extension.py
core/libs/commonwealth/src/commonwealth/utils/streaming.py
core/services/kraken/api/v2/routers/extension.py
core/services/kraken/api/v1/routers/extension.py

Assessment against linked issues

Issue Objective Addressed Explanation
#3631 Add a visible cancel button in the UI during extension installation (and similar operations) so the user can request cancellation.
#3631 Wire the cancel action so that an in-progress extension install/update/finalize operation is actually aborted and the system is left in a consistent state.

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

Copy link
Copy Markdown

@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 left some high level feedback:

  • The new uninstallExtensionVersion helper calls DELETE /extension/${identifier}/${tag}, but there is no corresponding v2 route added in extension.py; if this endpoint doesn’t already exist elsewhere, this will consistently 404 and should either be implemented or the URL adjusted to an existing route.
  • In finalizeExtensionUpload, the axios.isCancel branch always resets install_from_file_phase to 'ready' and clears install_from_file_status_text before checking whether controller === this.active_abort_controller; this can cause a stale controller’s cancellation to reset the UI during a new operation, so consider moving the UI reset inside the controller === this.active_abort_controller check.
  • The PullProgress dialog is always rendered as cancelable from ExtensionManagerView even when there is no active abortable operation; tying cancelable to !!active_abort_controller would prevent showing a cancel button that can’t actually affect any in-flight request.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `uninstallExtensionVersion` helper calls `DELETE /extension/${identifier}/${tag}`, but there is no corresponding v2 route added in `extension.py`; if this endpoint doesn’t already exist elsewhere, this will consistently 404 and should either be implemented or the URL adjusted to an existing route.
- In `finalizeExtensionUpload`, the `axios.isCancel` branch always resets `install_from_file_phase` to `'ready'` and clears `install_from_file_status_text` before checking whether `controller === this.active_abort_controller`; this can cause a stale controller’s cancellation to reset the UI during a new operation, so consider moving the UI reset inside the `controller === this.active_abort_controller` check.
- The `PullProgress` dialog is always rendered as `cancelable` from `ExtensionManagerView` even when there is no active abortable operation; tying `cancelable` to `!!active_abort_controller` would prevent showing a cancel button that can’t actually affect any in-flight request.

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.

Copy link
Copy Markdown
Collaborator

@joaomariolago joaomariolago left a comment

Choose a reason for hiding this comment

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

There is one edge case that seem to be happening with the PR that is the following:

  1. Have extension A V1 currently installed
  2. Go to extension store and open extension A popup
  3. Select extension A V2 and start the update
  4. Before the update download be complete, cancel it using the new cancel button
  5. Check Kraken settings file (Both extension A V1 and V2 are gone)
  6. Check docker images docker image ls -a, extension A image is left there

Seems that this behavior breaks the atomic behavior of the Kraken install operation and could lead to images leaking on the docker side. Since we can't just delete images that are not associated with extensions given that it could be a user custom image used to other function.

@patrickelectric
Copy link
Copy Markdown
Member

@nicoschmdt can you take a look in the comments and CI ?

@nicoschmdt nicoschmdt marked this pull request as draft April 6, 2026 19:04
@nicoschmdt nicoschmdt force-pushed the stop-install branch 3 times, most recently from ec8b927 to a757ee0 Compare April 7, 2026 19:20
@nicoschmdt nicoschmdt marked this pull request as ready for review April 7, 2026 19:41
@nicoschmdt nicoschmdt requested a review from joaomariolago April 7, 2026 19:41
Copy link
Copy Markdown

@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 1 issue, and left some high level feedback:

  • The new uninstallExtensionVersion frontend helper targets DELETE /extension/{identifier}/{tag}, but there is no corresponding v2 route added in api/v2/routers/extension.py, so calls to this function will likely 404 until a backend handler is implemented.
  • In ExtensionManagerView.vue, beginInstallOperation aborts any existing active_abort_controller before starting a new one, but the previous operation’s finally blocks only reset state when their controller matches active_abort_controller, which can leave stale installing state if a second operation is started while a first is still cleaning up; consider centralizing cleanup so aborted-but-superseded operations still clear their UI state.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `uninstallExtensionVersion` frontend helper targets `DELETE /extension/{identifier}/{tag}`, but there is no corresponding v2 route added in `api/v2/routers/extension.py`, so calls to this function will likely 404 until a backend handler is implemented.
- In `ExtensionManagerView.vue`, `beginInstallOperation` aborts any existing `active_abort_controller` before starting a new one, but the previous operation’s `finally` blocks only reset state when their controller matches `active_abort_controller`, which can leave stale installing state if a second operation is started while a first is still cleaning up; consider centralizing cleanup so aborted-but-superseded operations still clear their UI state.

## Individual Comments

### Comment 1
<location path="core/libs/commonwealth/src/commonwealth/utils/streaming.py" line_range="73-76" />
<code_context>
         finally:
             if heartbeat_task:
                 heartbeat_task.cancel()
+            await gen.aclose()
             await queue.put(None)

</code_context>
<issue_to_address>
**issue (bug_risk):** Guard `gen.aclose()` against errors so the sentinel is still queued and consumers can't hang.

If `gen.aclose()` raises (e.g. during generator finalization), it will skip `await queue.put(None)` and terminate the wrapper task without signaling completion, potentially leaving consumers blocked on `queue.get()`. Please wrap `gen.aclose()` in a try/except (with optional logging) so that `queue.put(None)` is reliably executed.
</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.

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.

Add cancel button when installing extensions

3 participants