diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 57175ecd90..3247ae0173 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -393,6 +393,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", + "tracing", "zbus", ] diff --git a/rust/agama-manager/src/actions.rs b/rust/agama-manager/src/actions.rs index 3808a77dc6..be01f33b56 100644 --- a/rust/agama-manager/src/actions.rs +++ b/rust/agama-manager/src/actions.rs @@ -284,6 +284,12 @@ impl SetConfigAction { self.progress .call(progress::message::Next::new(Scope::Manager)) .await?; + // Ensure storage was already probed before configuring s390. Otherwise, probing could + // fail later if DASD is formatting in a background process (bsc#1259354). + let storage_system = self.storage.call(storage::message::GetSystem).await?; + if storage_system.is_none() { + self.storage.call(storage::message::Probe).await? + } s390.call(s390::message::SetConfig::new(config.s390.clone())) .await?; } diff --git a/rust/agama-storage-client/Cargo.toml b/rust/agama-storage-client/Cargo.toml index 81b5952fee..21a777982c 100644 --- a/rust/agama-storage-client/Cargo.toml +++ b/rust/agama-storage-client/Cargo.toml @@ -12,4 +12,5 @@ zbus = "5.7.1" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } serde = { version = "1.0.228" } serde_json = "1.0.140" +tracing = "0.1.41" diff --git a/rust/agama-storage-client/src/service.rs b/rust/agama-storage-client/src/service.rs index 0f009430f4..2696a2c128 100644 --- a/rust/agama-storage-client/src/service.rs +++ b/rust/agama-storage-client/src/service.rs @@ -223,7 +223,7 @@ impl MessageHandler for Service { message: message::SetStorageConfig, ) -> Result>, Error> { let proxy = self.storage_proxy.clone(); - let result = run_in_background(async move { + let response = run_in_background(async move { let product = message.product.read().await; let product_json = serde_json::to_string(&*product)?; let config = message.config.filter(|c| c.has_value()); @@ -232,7 +232,7 @@ impl MessageHandler for Service { Ok(()) }); Ok(Box::pin(async move { - result + response .await .map_err(|_| Error::Actor(actor::Error::Response(Self::name())))? })) @@ -419,6 +419,9 @@ where let (tx, rx) = oneshot::channel::>(); tokio::spawn(async move { let result = func.await; + if let Err(error) = &result { + tracing::error!("Failed to run background action: {error}"); + } _ = tx.send(result); }); rx diff --git a/rust/package/agama.changes b/rust/package/agama.changes index ecc7c329de..11c1be08b1 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Mon Mar 16 16:52:49 UTC 2026 - José Iván López González + +- Ensure storage system is probed before configuring DASD + (bsc#1259354). + ------------------------------------------------------------------- Mon Mar 16 13:17:33 UTC 2026 - Knut Anderssen diff --git a/service/lib/agama/dbus/storage/dasd.rb b/service/lib/agama/dbus/storage/dasd.rb index 37624801f1..cf1f530bdb 100644 --- a/service/lib/agama/dbus/storage/dasd.rb +++ b/service/lib/agama/dbus/storage/dasd.rb @@ -37,11 +37,13 @@ class DASD < BaseObject private_constant :PATH # @param manager [Agama::Storage::DASD::Manager] + # @param task_runner [Agama::TaskRunner] # @param logger [Logger, nil] - def initialize(manager, logger: nil) + def initialize(manager, task_runner, logger: nil) textdomain "agama" super(PATH, logger: logger) @manager = manager + @task_runner = task_runner @serialized_system = serialize_system @serialized_config = serialize_config register_callbacks @@ -72,6 +74,7 @@ def probe # Applies the given serialized DASD config. # # @todo Raise error if the config is not valid. + # @raise [Agama::TaskRunner::BusyError] If an async task is running, see {TaskRunner}. # # @param serialized_config [String] Serialized DASD config according to the JSON schema. def configure(serialized_config) @@ -83,7 +86,19 @@ def configure(serialized_config) # Do not configure if there is nothing to change. return if manager.configured?(config_json) - perform_configuration(config_json) + # The configuration could take long time (e.g., formatting devices). It is important to + # not block the service in order to make possible to attend other requests. + task_runner.async_run("Configure DASD") do + logger.info("Configuring DASD") + + start_progress(1, _("Configuring DASD")) + manager.configure(config_json) + + update_serialized_system + update_serialized_config + + finish_progress + end end private @@ -91,6 +106,9 @@ def configure(serialized_config) # @return [Agama::Storage::DASD::Manager] attr_reader :manager + # @return [Agama::TaskRunner] + attr_reader :task_runner + def register_callbacks on_progress_change { self.ProgressChanged(serialize_progress) } on_progress_finish { self.ProgressFinished } @@ -102,28 +120,6 @@ def register_callbacks manager.on_format_finish { |process_status| self.FormatFinished(process_status.to_s) } end - # Performs the configuration process in a separate thread. - # - # The configuration could take long time (e.g., formatting devices). It is important to not - # block the service in order to make possible to attend other requests. - # - # @raise if there is an unfinished configuration. - # - # @param config_json [Hash] - def perform_configuration(config_json) - raise "Previous configuration is not finished yet" if @configuration_thread&.alive? - - logger.info("Configuring DASD") - - @configuration_thread = Thread.new do - start_progress(1, _("Configuring DASD")) - manager.configure(config_json) - update_serialized_system - update_serialized_config - finish_progress - end - end - # Updates the system info if needed. def update_serialized_system serialized_system = serialize_system diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index bd287fbb19..22f335b966 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -45,12 +45,14 @@ class Manager < BaseObject PATH = "/org/opensuse/Agama/Storage1" private_constant :PATH - # @param backend [Agama::Storage::Manager] + # @param manager [Agama::Storage::Manager] + # @param task_runner [Agama::TaskRunner] # @param logger [Logger, nil] - def initialize(backend, logger: nil) + def initialize(manager, task_runner, logger: nil) textdomain "agama" super(PATH, logger: logger) - @backend = backend + @manager = manager + @task_runner = task_runner @serialized_system = serialize_system @serialized_config = serialize_config @serialized_config_model = serialize_config_model @@ -71,7 +73,7 @@ def initialize(backend, logger: nil) dbus_method(:Install) { install } dbus_method(:Finish) { finish } dbus_method(:Umount) { umount } - dbus_method(:SetLocale, "in locale:s") { |locale| backend.configure_locale(locale) } + dbus_method(:SetLocale, "in locale:s") { |locale| manager.configure_locale(locale) } dbus_method( :SetConfig, "in serialized_product_config:s, in serialized_config:s" ) { |p, c| configure(p, c) } @@ -88,36 +90,44 @@ def initialize(backend, logger: nil) end # Implementation for the API method #Activate. + # + # @raise [Agama::TaskRunner::BusyError] If an async task is running, see {TaskRunner}. def activate - logger.info("Activating storage") + task_runner.run("Activate storage") do + logger.info("Activating storage") - start_progress(3, ACTIVATING_STEP) - backend.reset_activation if backend.activated? - backend.activate + start_progress(3, ACTIVATING_STEP) + manager.reset_activation if manager.activated? + manager.activate - next_progress_step(PROBING_STEP) - perform_probe + next_progress_step(PROBING_STEP) + perform_probe - next_progress_step(CONFIGURING_STEP) - configure_with_current + next_progress_step(CONFIGURING_STEP) + configure_with_current - finish_progress + finish_progress + end end # Implementation for the API method #Probe. + # + # @raise [Agama::TaskRunner::BusyError] If an async task is running, see {TaskRunner}. def probe - logger.info("Probing storage") + task_runner.run("Probe storage") do + logger.info("Probing storage") - start_progress(3, ACTIVATING_STEP) - backend.activate unless backend.activated? + start_progress(3, ACTIVATING_STEP) + manager.activate unless manager.activated? - next_progress_step(PROBING_STEP) - perform_probe + next_progress_step(PROBING_STEP) + perform_probe - next_progress_step(CONFIGURING_STEP) - configure_with_current + next_progress_step(CONFIGURING_STEP) + configure_with_current - finish_progress + finish_progress + end end # Configures storage. @@ -126,6 +136,8 @@ def probe # { "storage": ... } or { "legacyAutoyastStorage": ... }. # # @raise If the config is not valid. + # @raise [Agama::TaskRunner::BusyError] If an async task is running and the system needs to + # be probed, see {TaskRunner}. # # @param serialized_product_config [String] Serialized product config. # @param serialized_config [String] Serialized storage config. @@ -134,24 +146,13 @@ def configure(serialized_product_config, serialized_config) config_json = JSON.parse(serialized_config, symbolize_names: true) # Do not configure if there is nothing to change. - return if backend.configured?(product_config_json, config_json) - - logger.info("Configuring storage") - product_config = Agama::Config.new(product_config_json) - backend.update_product_config(product_config) if backend.product_config != product_config + return if manager.configured?(product_config_json, config_json) - start_progress(3, ACTIVATING_STEP) - backend.activate unless backend.activated? + # It is safe to run the task if the system was already probed. + return configure_task(product_config_json, config_json) if manager.probed? - next_progress_step(PROBING_STEP) - backend.probe unless backend.probed? - - update_serialized_system - - next_progress_step(CONFIGURING_STEP) - calculate_proposal(config_json) - - finish_progress + # Prevent to probe the system if there is an async task running (e.g., formatting DASD). + task_runner.run("Configure storage") { configure_task(product_config_json, config_json) } end # Converts the given serialized config model to a config. @@ -184,13 +185,13 @@ def solve_config_model(serialized_model) # Implementation for the API method #Install. def install start_progress(3, _("Preparing bootloader proposal")) - backend.bootloader.configure + manager.bootloader.configure next_progress_step(_("Preparing the storage devices")) - backend.install + manager.install next_progress_step(_("Writing bootloader sysconfig")) - backend.bootloader.install + manager.bootloader.install finish_progress end @@ -198,14 +199,14 @@ def install # Implementation for the API method #Finish. def finish start_progress(1, _("Finishing installation")) - backend.finish + manager.finish finish_progress end # Implementation for the API method #Umount. def umount start_progress(1, _("Unmounting devices")) - backend.umount + manager.umount finish_progress end @@ -224,7 +225,7 @@ def umount # @return [Integer] 0 success; 1 error def configure_bootloader(serialized_config) logger.info("Setting bootloader config: #{serialized_config}") - backend.bootloader.config.load_json(serialized_config) + manager.bootloader.config.load_json(serialized_config) # after loading config try to apply it, so proper packages can be requested # TODO: generate also new issue from configuration calculate_bootloader @@ -243,18 +244,45 @@ def configure_bootloader(serialized_config) private_constant :CONFIGURING_STEP # @return [Agama::Storage::Manager] - attr_reader :backend + attr_reader :manager + + # @return [Agama::TaskRunner] + attr_reader :task_runner def register_progress_callbacks on_progress_change { self.ProgressChanged(serialize_progress) } on_progress_finish { self.ProgressFinished } end + # Performs the configuration task. + # + # @param product_config_json [Hash, nil] + # @param config_json [Hash, nil] + def configure_task(product_config_json, config_json) + logger.info("Configuring storage") + + product_config = Agama::Config.new(product_config_json) + manager.update_product_config(product_config) if manager.product_config != product_config + + start_progress(3, ACTIVATING_STEP) + manager.activate unless manager.activated? + + next_progress_step(PROBING_STEP) + manager.probe unless manager.probed? + + update_serialized_system + + next_progress_step(CONFIGURING_STEP) + calculate_proposal(config_json) + + finish_progress + end + # Probes storage and updates the associated info. # # @see #update_system_info def perform_probe - backend.probe + manager.probe update_serialized_system end @@ -264,7 +292,7 @@ def perform_probe def configure_with_current return unless proposal.storage_json - calculate_proposal(backend.config_json) + calculate_proposal(manager.config_json) # The storage proposal with the current settings is not explicitly requested. It is # automatically calculated as side effect of calling to probe or activate. All the # dependant steps has to be automatically done too, for example, reconfiguring bootloader. @@ -277,8 +305,8 @@ def configure_with_current # # @param config_json [Hash, nil] def calculate_proposal(config_json = nil) - backend.configure(config_json) - backend.add_packages if backend.proposal.success? + manager.configure(config_json) + manager.add_packages if manager.proposal.success? update_serialized_config update_serialized_config_model @@ -289,7 +317,7 @@ def calculate_proposal(config_json = nil) # Performs the bootloader configuration applying the current config. def calculate_bootloader logger.info("Configuring bootloader") - backend.bootloader.configure + manager.bootloader.configure update_serialized_bootloader_config end @@ -353,7 +381,7 @@ def update_serialized_bootloader_config # # @return [String] def serialize_system - return serialize_nil unless backend.probed? + return serialize_nil unless manager.probed? json = { devices: devices_json(:probed), @@ -390,7 +418,7 @@ def serialize_config_model # # @return [String] def serialize_proposal - return serialize_nil unless backend.proposal.success? + return serialize_nil unless manager.proposal.success? json = { devices: devices_json(:staging), @@ -403,14 +431,14 @@ def serialize_proposal # # @return [String] def serialize_issues - super(backend.issues) + super(manager.issues) end # Generates the serialized JSON of the bootloader config. # # @return [String] def serialize_bootloader_config - backend.bootloader.config.to_json + manager.bootloader.config.to_json end # Representation of the null JSON. @@ -438,7 +466,7 @@ def devices_json(meth) # * :delete [Boolean] # * :resize [Boolean] def actions_json - backend.actions.map do |action| + manager.actions.map do |action| { device: action.device_sid, text: action.text, @@ -455,7 +483,7 @@ def actions_json # # @return [Array] def system_issues_json - backend.system_issues.map { |i| issue_json(i) } + manager.system_issues.map { |i| issue_json(i) } end # @see Storage::System#available_drives @@ -515,12 +543,12 @@ def volume_templates # @return [Agama::Storage::Proposal] def proposal - backend.proposal + manager.proposal end # @return [Agama::Config] def product_config - backend.product_config + manager.product_config end # @return [Agama::VolumeTemplatesBuilder] diff --git a/service/lib/agama/dbus/storage_service.rb b/service/lib/agama/dbus/storage_service.rb index d7f08d6181..d0862a5fa3 100644 --- a/service/lib/agama/dbus/storage_service.rb +++ b/service/lib/agama/dbus/storage_service.rb @@ -25,6 +25,7 @@ require "agama/dbus/storage/manager" require "agama/storage/manager" require "agama/storage/iscsi/adapter" +require "agama/task_runner" require "yast" require "y2storage/inhibitors" @@ -112,7 +113,7 @@ def dbus_objects # @return [Agama::DBus::Storage::Manager] def manager_object - @manager_object ||= Agama::DBus::Storage::Manager.new(manager, logger: logger) + @manager_object ||= Agama::DBus::Storage::Manager.new(manager, task_runner, logger: logger) end # @return [Agama::DBus::Storage::ISCSI] @@ -129,7 +130,7 @@ def dasd_object require "agama/storage/dasd/manager" require "agama/dbus/storage/dasd" manager = Agama::Storage::DASD::Manager.new(logger: logger) - @dasd_object = Agama::DBus::Storage::DASD.new(manager, logger: logger) + @dasd_object = Agama::DBus::Storage::DASD.new(manager, task_runner, logger: logger) end # @return [Agama::DBus::Storage::ZFCP, nil] @@ -148,6 +149,11 @@ def zfcp_object def manager @manager ||= Agama::Storage::Manager.new(logger: logger) end + + # @return [Agama::TaskRunner] + def task_runner + @task_runner ||= Agama::TaskRunner.new + end end end end diff --git a/service/lib/agama/task_runner.rb b/service/lib/agama/task_runner.rb new file mode 100644 index 0000000000..da517be489 --- /dev/null +++ b/service/lib/agama/task_runner.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +# Copyright (c) [2026] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + # Class for running tasks in a sync or async way, preventing the execution of several tasks at the + # same time. + class TaskRunner + # Error when an async task is already running. + class BusyError < StandardError + def initialize(running_task = nil, requested_task = nil) + message = "Cannot start a new task while another is in progress: " \ + "requested: '#{requested_task || "unknown"}', " \ + "running: '#{running_task || "unknown"}'" + super(message) + end + end + + def initialize + @running_task = nil + @running_thread = nil + end + + # Runs the given block in a new thread. + # + # @raise [BusyError] If a previous async task is already running. + # + # @param task [String, nil] Description of the task to run. + # @param block [Proc] Code to run in a separate thread. + # @return [Thread] The new thread. + def async_run(task = nil, &block) + # Queue to safely communicate between threads. It is used to indicate to the main thread that + # the task has started. + ready = Queue.new + perform_run(task) do + @running_task = task + @running_thread = Thread.new do + ready.push(true) # Signaling to indicate the task has started. + block.call + end + end + ready.pop # Ensures the task has started. + @running_thread + end + + # Runs the given block in the main thread. + # + # @raise [BusyError] If a async task is running. + # + # @param task [String, nil] Description of the task to run. + # @param block [Proc] Code to run. + def run(task = nil, &block) + perform_run(task, &block) + end + + # Whether there is a task running in a separate thread. + # + # @return [Boolean] + def busy? + @running_thread&.alive? || false + end + + private + + # Runs the given block. + # + # @raise [BusyError] If a async task is running. + # + # @param task [String, nil] Description of the task to run. + # @param block [Proc] Code to run. + def perform_run(task = nil, &block) + raise BusyError.new(@running_task, task) if busy? + + block.call + end + end +end diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 012dd74833..99f5a18379 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Mon Mar 16 16:54:19 UTC 2026 - José Iván López González + +- Report an error if storage needs to be probed meanwhile DASD is + formatting (related to bsc#1259354). + ------------------------------------------------------------------- Fri Mar 13 15:50:31 UTC 2026 - Ancor Gonzalez Sosa diff --git a/service/test/agama/dbus/storage/dasd_test.rb b/service/test/agama/dbus/storage/dasd_test.rb index 463807015d..0873cffcef 100644 --- a/service/test/agama/dbus/storage/dasd_test.rb +++ b/service/test/agama/dbus/storage/dasd_test.rb @@ -22,15 +22,18 @@ require_relative "../../../test_helper" require "agama/dbus/storage/dasd" require "agama/storage/dasd/manager" +require "agama/task_runner" require "json" RSpec.describe Agama::DBus::Storage::DASD do - subject { described_class.new(manager) } + subject { described_class.new(manager, task_runner) } let(:manager) { instance_double(Agama::Storage::DASD::Manager) } + let(:task_runner) { Agama::TaskRunner.new } before do allow_any_instance_of(DBus::Object).to receive(:emit) + allow(Agama::TaskRunner).to receive(:new).and_return(task_runner) allow(manager).to receive(:on_format_change) allow(manager).to receive(:on_format_finish) allow(manager).to receive(:probe) @@ -160,8 +163,8 @@ allow(subject).to receive(:serialized_system).and_return("{}") end - it "performs configuration in a thread" do - expect(Thread).to receive(:new).and_yield + it "performs the configuration in an async task" do + expect(task_runner).to receive(:async_run).and_yield expect(subject).to receive(:SystemChanged) expect(subject).to receive(:ProgressChanged) expect(subject).to receive(:ProgressFinished) @@ -170,20 +173,6 @@ subject.configure(serialized_config) end end - - context "when already configured" do - let(:thread) { instance_double(Thread, alive?: true) } - - before do - allow(manager).to receive(:configured?).with(config_json).and_return(false) - subject.instance_variable_set(:@configuration_thread, thread) - end - - it "raises an error" do - expect { subject.configure(serialized_config) } - .to raise_error(RuntimeError, "Previous configuration is not finished yet") - end - end end describe "format callbacks" do diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index d0a46aa895..c098c53b6c 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -28,6 +28,7 @@ require "agama/storage/proposal" require "agama/storage/proposal_settings" require "agama/storage/volume" +require "agama/task_runner" require "y2storage" require "dbus" @@ -42,10 +43,12 @@ def parse(string) describe Agama::DBus::Storage::Manager do include Agama::RSpec::StorageHelpers - subject(:manager) { described_class.new(backend, logger: logger) } + subject(:manager) { described_class.new(backend, task_runner, logger: logger) } let(:backend) { Agama::Storage::Manager.new } + let(:task_runner) { Agama::TaskRunner.new } + let(:logger) { Logger.new($stdout, level: :warn) } let(:proposal) { Agama::Storage::Proposal.new(product_config) } @@ -58,6 +61,8 @@ def parse(string) before do allow_any_instance_of(DBus::Object).to receive(:emit) + allow(Agama::TaskRunner).to receive(:new).and_return(task_runner) + allow(task_runner).to receive(:run).and_yield # Speed up tests by avoiding real check of TPM presence. allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) # Speed up tests by avoiding looking up by name in the system @@ -656,7 +661,7 @@ def parse(string) context "when storage devices are not activated and probed" do before do allow(backend).to receive(:activated?).and_return(false, true) - allow(backend).to receive(:probed?).and_return(false, true) + allow(backend).to receive(:probed?).and_return(false, false, true) end context "if the product configuration has changed" do diff --git a/service/test/agama/dbus/storage_service_test.rb b/service/test/agama/dbus/storage_service_test.rb index 61dc648fd6..0d71c213ef 100644 --- a/service/test/agama/dbus/storage_service_test.rb +++ b/service/test/agama/dbus/storage_service_test.rb @@ -27,6 +27,7 @@ require "agama/storage/dasd/manager" require "agama/storage/iscsi/adapter" require "agama/storage/zfcp/manager" +require "agama/task_runner" require "yast" describe Agama::DBus::StorageService do @@ -58,6 +59,8 @@ let(:dasd) { instance_double(Agama::Storage::DASD::Manager) } let(:zfcp) { instance_double(Agama::Storage::ZFCP::Manager) } + let(:task_runner) { instance_double(Agama::TaskRunner) } + before do allow(Agama::DBus::Bus).to receive(:current).and_return(bus) allow(bus).to receive(:request_service).with("org.opensuse.Agama.Storage1") @@ -65,14 +68,16 @@ allow(Y2Storage::Inhibitors).to receive(:new).and_return inhibitors allow(Agama::Storage::Manager).to receive(:new).with(logger: logger) .and_return(manager) - allow(Agama::DBus::Storage::Manager).to receive(:new).with(manager, logger: logger) + allow(Agama::DBus::Storage::Manager).to receive(:new).with(manager, task_runner, logger: logger) .and_return(manager_obj) allow(Agama::DBus::Storage::ISCSI).to receive(:new).and_return(iscsi_obj) - allow(Agama::DBus::Storage::DASD).to receive(:new).and_return(dasd_obj) + allow(Agama::DBus::Storage::DASD).to receive(:new).with(dasd, task_runner, logger: logger) + .and_return(dasd_obj) allow(Agama::Storage::DASD::Manager).to receive(:new).and_return(dasd) allow(Agama::DBus::Storage::ZFCP).to receive(:new).and_return(zfcp_obj) allow(Agama::Storage::ZFCP::Manager).to receive(:new).and_return(zfcp) allow_any_instance_of(Agama::Storage::ISCSI::Adapter).to receive(:activate) + allow(Agama::TaskRunner).to receive(:new).and_return(task_runner) end describe "#start" do diff --git a/service/test/agama/task_runner_test.rb b/service/test/agama/task_runner_test.rb new file mode 100644 index 0000000000..bb972d1876 --- /dev/null +++ b/service/test/agama/task_runner_test.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +# Copyright (c) [2026] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../test_helper" +require "agama/task_runner" +describe Agama::TaskRunner do + subject { described_class.new } + + describe "#run" do + it "executes the given block" do + executed = false + subject.run { executed = true } + expect(executed).to eq(true) + end + + it "executes the given block in a sync way" do + sequence = [] + subject.run do + sleep 0.1 + sequence << :step1 + end + sequence << :step2 + expect(sequence).to eq([:step1, :step2]) + end + + it "raises an error if called while an async task is running" do + queue = Queue.new + subject.async_run do + queue.pop # wait until a signal is sent. + end + expect do + subject.run { raise("should not be executed") } + end.to raise_error(Agama::TaskRunner::BusyError) + end + end + + describe "#async_run" do + it "executes the given block in a separate thread" do + executed = false + subject.async_run { executed = true }.join + expect(executed).to eq(true) + end + + it "does not block" do + queue = Queue.new + sequence = [] + + thread = subject.async_run do + queue.pop # wait until a signal is sent. + sequence << :async_step + end + + sequence << :sync_step + queue.push(:start) # send signal to the thread, so the thread can continue. + thread.join + + expect(sequence).to eq([:sync_step, :async_step]) + end + + it "raises an error if called while another async task is running" do + queue = Queue.new + + subject.async_run do + queue.pop # wait until a signal is sent. + end + + expect do + subject.async_run { raise("should not be executed") } + end.to raise_error(Agama::TaskRunner::BusyError) + end + + it "does not fail in the previous async task is finished" do + executed = false + + subject.async_run { "async task" }.join + subject.async_run { executed = true }.join + expect(executed).to eq(true) + end + end + + describe "#busy?" do + it "returns false when no task is running" do + expect(subject.busy?).to eq(false) + end + + it "returns true when an async task is running" do + queue = Queue.new + subject.async_run { queue.pop } + expect(subject.busy?).to eq(true) + end + + it "returns false after an async task is finished" do + subject.async_run { "async task" }.join + expect(subject.busy?).to eq(false) + end + + it "returns false after a sync task is finished" do + subject.run { "sync task" } + expect(subject.busy?).to eq(false) + end + end +end diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index aa90a0d70b..091895d019 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Mon Mar 16 16:53:39 UTC 2026 - José Iván López González + +- Log actions failures (related to bsc#1259354). + ------------------------------------------------------------------- Mon Mar 16 12:56:47 UTC 2026 - David Diaz diff --git a/web/src/api.ts b/web/src/api.ts index 416d129670..6ffa4f9d9c 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -73,7 +73,8 @@ const patchQuestion = (question: Question): Response => { return patch(`/api/v2/questions`, { answer: { id, action, value } }); }; -const postAction = (action: Action) => post("/api/v2/action", action); +/** @todo Inform the user when the action fails. */ +const postAction = (action: Action) => post("/api/v2/action", action).catch(console.error); const configureL10nAction = (config: L10nSystemConfig) => postAction({ configureL10n: config });