This is an automated email from the ASF dual-hosted git repository.

spetz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 67e14c26b fix(connectors): close the source instance a failed start 
leaves behind (#4064)
67e14c26b is described below

commit 67e14c26b534afd1fb22ec3e5496936d33b37cde
Author: Maxim Levkov <[email protected]>
AuthorDate: Sat Sep 12 13:02:18 2026 -0700

    fix(connectors): close the source instance a failed start leaves behind 
(#4064)
    
    Closes #4062.
---
 core/connectors/runtime/src/main.rs                |  27 +-
 core/connectors/runtime/src/manager/source.rs      | 226 ++++++++++++---
 core/connectors/runtime/src/metrics.rs             |   2 +-
 core/connectors/runtime/src/sink.rs                |  13 +-
 core/connectors/runtime/src/source.rs              | 309 ++++++++++++++++++++-
 core/connectors/sdk/src/sink.rs                    |   7 +
 core/connectors/sdk/src/source.rs                  |  10 +
 .../tests/connectors/random/random_source.rs       | 120 ++++++--
 8 files changed, 637 insertions(+), 77 deletions(-)

diff --git a/core/connectors/runtime/src/main.rs 
b/core/connectors/runtime/src/main.rs
index 12d627c84..8890c1d43 100644
--- a/core/connectors/runtime/src/main.rs
+++ b/core/connectors/runtime/src/main.rs
@@ -18,6 +18,7 @@
 use crate::configs::connectors::{
     ConnectorKey, ConnectorsConfig, ConnectorsConfigProvider, 
create_connectors_config_provider,
 };
+use crate::metrics::ConnectorType;
 use ::configs::ConfigProvider;
 use clap::Parser;
 use configs::connectors::ConfigFormat;
@@ -186,7 +187,7 @@ async fn main() -> Result<(), RuntimeError> {
     let mut source_wrappers = vec![];
     let mut source_containers_by_key: HashMap<String, 
Arc<Container<SourceApi>>> = HashMap::new();
     for (_path, source) in sources {
-        let container = Arc::new(source.container);
+        let container = source.container;
         let handle_callback = container.iggy_source_handle_v2;
         let batch_result_callback = container.iggy_source_batch_result;
         for plugin in &source.plugins {
@@ -456,8 +457,30 @@ struct SinkConnectorWrapper {
     plugins: Vec<SinkConnectorPlugin>,
 }
 
+/// Closes a plugin instance whose setup did not finish, reporting a refusal
+/// rather than returning it: every caller is already on a failure path with an
+/// error of its own to surface.
+///
+/// The two sides had this body inline, one word apart. That word is the label
+/// [`ConnectorType`] already defines, so it is taken as the enum rather than a
+/// string nothing constrains.
+pub(crate) fn close_plugin_instance(
+    close: &dyn Fn(u32) -> i32,
+    kind: ConnectorType,
+    plugin_id: u32,
+    key: &str,
+) {
+    let close_result = close(plugin_id);
+    if close_result != 0 {
+        let kind = kind.as_label();
+        warn!(
+            "iggy_{kind}_close returned {close_result} while cleaning up 
failed {kind} connector with ID: {plugin_id} ({key})"
+        );
+    }
+}
+
 struct SourceConnector {
-    container: Container<SourceApi>,
+    container: Arc<Container<SourceApi>>,
     plugins: Vec<SourceConnectorPlugin>,
 }
 
diff --git a/core/connectors/runtime/src/manager/source.rs 
b/core/connectors/runtime/src/manager/source.rs
index 8ef68e8b1..dcdd85b93 100644
--- a/core/connectors/runtime/src/manager/source.rs
+++ b/core/connectors/runtime/src/manager/source.rs
@@ -82,28 +82,23 @@ impl SourceManager {
         metrics: Option<&Arc<Metrics>>,
     ) {
         if let Some(source) = self.sources.get(key) {
-            let mut source = source.lock().await;
-            let old_status = source.info.status;
-            source.info.status = status;
-            if matches!(status, ConnectorStatus::Running | 
ConnectorStatus::Stopped) {
-                source.info.last_error = None;
-            }
-            if let Some(metrics) = metrics {
-                if old_status != ConnectorStatus::Running && status == 
ConnectorStatus::Running {
-                    metrics.increment_sources_running();
-                } else if old_status == ConnectorStatus::Running
-                    && status != ConnectorStatus::Running
-                {
-                    metrics.decrement_sources_running();
-                }
-            }
+            source.lock().await.apply_status(status, metrics);
         }
     }
 
-    pub async fn set_error(&self, key: &str, error_message: &str) {
+    pub async fn set_error(&self, key: &str, error_message: &str, metrics: 
Option<&Arc<Metrics>>) {
         if let Some(source) = self.sources.get(key) {
             let mut source = source.lock().await;
-            source.info.status = ConnectorStatus::Error;
+            // Through the shared transition, so leaving `Running` moves the
+            // gauge. Skipping it left an errored instance counted as running,
+            // and the loop's later `Stopped` could not correct that either,
+            // because by then the old status was `Error` and neither branch
+            // fires.
+            //
+            // The message is assigned after the transition, and that ordering 
is
+            // what preserves it. `Error` being outside the set that clears
+            // `last_error` is belt and braces here, not the mechanism.
+            source.apply_status(ConnectorStatus::Error, metrics);
             source.info.last_error = Some(ConnectorError::new(error_message));
         }
     }
@@ -236,35 +231,64 @@ impl SourceManager {
             state,
         )?;
         info!("Source connector with ID: {plugin_id} for plugin: {key} 
initialized successfully.");
+        // Armed from here until the id is recorded below. 
`SourceInstanceGuard`
+        // carries why that window strands the instance.
+        let instance_guard =
+            source::SourceInstanceGuard::for_container(container.clone(), 
plugin_id, key);
 
         let (producer, encoder, transforms) =
-            source::setup_source_producer(key, config, iggy_client).await?;
+            match source::setup_source_producer(key, config, 
iggy_client).await {
+                Ok(parts) => parts,
+                Err(error) => {
+                    // Awaited rather than left to `drop`, so this error 
reaches
+                    // the caller after teardown. `drop` stays the net for a
+                    // cancellation and for a `?` added here later.
+                    instance_guard.close().await;
+                    return Err(error);
+                }
+            };
 
         let handle_callback = container.iggy_source_handle_v2;
         let batch_result_callback = container.iggy_source_batch_result;
-        let handler_tasks = source::spawn_source_handler(
-            plugin_id,
-            key,
-            config.verbose,
-            config.benchmark,
-            producer,
-            encoder,
-            transforms,
-            state_storage,
-            handle_callback,
-            batch_result_callback,
-            context.clone(),
-        );
 
+        // The lock is taken before the spawn so nothing can await between
+        // registering the tasks and recording the id that reaches them. A
+        // cancellation in that gap left the `SOURCE_SENDERS` entry and both
+        // spawned tasks behind with no id naming them, and the forwarding loop
+        // then ran for the life of the process. The guard closes the plugin
+        // instance on that path but cannot reach either of those.
+        //
+        // The forwarding loop's own first act is to take this lock, so it 
waits
+        // for this block to end rather than racing it.
         {
             let mut details = details.lock().await;
+            // Nothing between these three statements may await. The spawn used
+            // to be passed in as a closure so the compiler refused one; 
inlined
+            // here that is a rule rather than a check, so keep it: an await
+            // between the spawn and the id strands the `SOURCE_SENDERS` entry
+            // and both tasks with nothing naming them.
+            details.handler_tasks = source::spawn_source_handler(
+                plugin_id,
+                key,
+                config.verbose,
+                config.benchmark,
+                producer,
+                encoder,
+                transforms,
+                state_storage,
+                handle_callback,
+                batch_result_callback,
+                context.clone(),
+            );
             details.info.id = plugin_id;
-            details.info.status = ConnectorStatus::Running;
-            details.info.last_error = None;
             details.config = config.clone();
-            details.handler_tasks = handler_tasks;
-            metrics.increment_sources_running();
+            // In the same hold as the id record, not after it. Released first,
+            // this transition raced the forwarding loop's own report and could
+            // overwrite an `Error` the loop had already set.
+            details.apply_status(ConnectorStatus::Running, Some(metrics));
         }
+        // `details.info.id` now names this instance, so a later stop reaches 
it.
+        instance_guard.disarm();
 
         Ok(())
     }
@@ -328,6 +352,36 @@ pub struct SourceDetails {
     pub restart_guard: Arc<Mutex<()>>,
 }
 
+impl SourceDetails {
+    /// Applies a status transition and the gauge move that belongs with it.
+    ///
+    /// On `&mut self` rather than behind a key, so it can run inside a lock 
the
+    /// caller already holds. `update_status` takes the lock and delegates;
+    /// `start_connector` calls it in the same hold as the id record.
+    ///
+    /// That matters: applied after releasing that lock, the initial `Running`
+    /// could land after the forwarding loop had already reported `Running` and
+    /// then failed its first batch, overwriting `Error`, clearing 
`last_error`,
+    /// and counting the instance a second time.
+    fn apply_status(&mut self, status: ConnectorStatus, metrics: 
Option<&Arc<Metrics>>) {
+        let old_status = self.info.status;
+        self.info.status = status;
+        if matches!(status, ConnectorStatus::Running | 
ConnectorStatus::Stopped) {
+            self.info.last_error = None;
+        }
+        let Some(metrics) = metrics else {
+            return;
+        };
+        // Only a real crossing of `Running` moves the gauge, so repeated
+        // reports of a status the connector already holds cost nothing.
+        if old_status != ConnectorStatus::Running && status == 
ConnectorStatus::Running {
+            metrics.increment_sources_running();
+        } else if old_status == ConnectorStatus::Running && status != 
ConnectorStatus::Running {
+            metrics.decrement_sources_running();
+        }
+    }
+}
+
 impl fmt::Debug for SourceDetails {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         f.debug_struct("SourceDetails")
@@ -463,6 +517,100 @@ mod tests {
         assert_eq!(metrics.get_sources_running(), 1);
     }
 
+    #[tokio::test]
+    async fn 
should_not_double_count_when_an_error_falls_between_two_running_reports() {
+        // The interleaving that double counts: the forwarding loop reports
+        // `Running`, fails its first batch, and a second `Running` report 
lands
+        // afterwards. That second report crosses into `Running` again, so it
+        // increments a gauge the error never gave back, and the instance is
+        // counted twice.
+        //
+        // What the consecutive-`Running` test cannot see: there the second
+        // report finds the status already `Running`, so no crossing happens 
and
+        // no arithmetic is exercised. The error in the middle is the whole 
point.
+        let metrics = Arc::new(Metrics::init());
+        let mut details = create_test_source_details("pg", 1);
+        details.info.status = ConnectorStatus::Stopped;
+        let manager = SourceManager::new(vec![details]);
+
+        manager
+            .update_status("pg", ConnectorStatus::Running, Some(&metrics))
+            .await;
+        manager
+            .set_error("pg", "first batch failed", Some(&metrics))
+            .await;
+        assert_eq!(
+            metrics.get_sources_running(),
+            0,
+            "an instance that has failed is not running, and the gauge has to 
say so \
+             or nothing later can correct it"
+        );
+
+        manager
+            .update_status("pg", ConnectorStatus::Running, Some(&metrics))
+            .await;
+
+        assert_eq!(
+            metrics.get_sources_running(),
+            1,
+            "one instance, however many times its status crossed Running"
+        );
+    }
+
+    #[tokio::test]
+    async fn should_keep_the_error_message_when_the_status_becomes_error() {
+        // `set_error` routes through a transition that clears `last_error` for
+        // some statuses, so its observable contract is worth pinning: the
+        // status ends `Error` and the message survives.
+        //
+        // Worth knowing what this does NOT pin. The message is assigned after
+        // the transition, so widening the clear to include `Error` leaves this
+        // green; checked, and the mutant survives. The ordering is the
+        // mechanism, and no unit test can see an ordering inside one lock 
hold.
+        let metrics = Arc::new(Metrics::init());
+        let manager = SourceManager::new(vec![create_test_source_details("pg", 
1)]);
+
+        manager
+            .set_error("pg", "producer setup failed", Some(&metrics))
+            .await;
+
+        let source = manager.get("pg").await.expect("source must exist");
+        let source = source.lock().await;
+        assert_eq!(source.info.status, ConnectorStatus::Error);
+        assert_eq!(
+            source
+                .info
+                .last_error
+                .as_ref()
+                .map(|error| error.message.as_str()),
+            Some("producer setup failed"),
+            "the transition must not clear the message set right after it"
+        );
+    }
+
+    #[tokio::test]
+    async fn should_increment_metrics_once_when_running_is_reported_twice() {
+        // Both a start and the forwarding loop report `Running` for the same
+        // instance, so the gauge has to count instances rather than reports.
+        let metrics = Arc::new(Metrics::init());
+        let mut details = create_test_source_details("pg", 1);
+        details.info.status = ConnectorStatus::Stopped;
+        let manager = SourceManager::new(vec![details]);
+
+        manager
+            .update_status("pg", ConnectorStatus::Running, Some(&metrics))
+            .await;
+        manager
+            .update_status("pg", ConnectorStatus::Running, Some(&metrics))
+            .await;
+
+        assert_eq!(
+            metrics.get_sources_running(),
+            1,
+            "a second report of a status the connector already has must not 
move the gauge"
+        );
+    }
+
     #[tokio::test]
     async fn should_decrement_metrics_when_leaving_running() {
         let metrics = Arc::new(Metrics::init());
@@ -479,7 +627,7 @@ mod tests {
     #[tokio::test]
     async fn should_clear_error_when_status_becomes_running() {
         let manager = SourceManager::new(vec![create_test_source_details("pg", 
1)]);
-        manager.set_error("pg", "some error").await;
+        manager.set_error("pg", "some error", None).await;
 
         manager
             .update_status("pg", ConnectorStatus::Running, None)
@@ -494,7 +642,7 @@ mod tests {
     async fn should_set_error_status_and_message() {
         let manager = SourceManager::new(vec![create_test_source_details("pg", 
1)]);
 
-        manager.set_error("pg", "connection failed").await;
+        manager.set_error("pg", "connection failed", None).await;
 
         let source = manager.get("pg").await.unwrap();
         let details = source.lock().await;
@@ -557,7 +705,7 @@ mod tests {
     #[tokio::test]
     async fn should_clear_error_when_status_becomes_stopped() {
         let manager = SourceManager::new(vec![create_test_source_details("pg", 
1)]);
-        manager.set_error("pg", "some error").await;
+        manager.set_error("pg", "some error", None).await;
 
         manager
             .update_status("pg", ConnectorStatus::Stopped, None)
@@ -609,6 +757,6 @@ mod tests {
     async fn set_error_should_be_noop_for_unknown_key() {
         let manager = SourceManager::new(vec![]);
 
-        manager.set_error("nonexistent", "some error").await;
+        manager.set_error("nonexistent", "some error", None).await;
     }
 }
diff --git a/core/connectors/runtime/src/metrics.rs 
b/core/connectors/runtime/src/metrics.rs
index 71b5f9309..5cc105859 100644
--- a/core/connectors/runtime/src/metrics.rs
+++ b/core/connectors/runtime/src/metrics.rs
@@ -39,7 +39,7 @@ pub enum ConnectorType {
 }
 
 impl ConnectorType {
-    fn as_label(&self) -> &'static str {
+    pub(crate) fn as_label(&self) -> &'static str {
         match self {
             ConnectorType::Source => "source",
             ConnectorType::Sink => "sink",
diff --git a/core/connectors/runtime/src/sink.rs 
b/core/connectors/runtime/src/sink.rs
index 7a1772451..88bc43a65 100644
--- a/core/connectors/runtime/src/sink.rs
+++ b/core/connectors/runtime/src/sink.rs
@@ -19,10 +19,11 @@ use crate::benchmark;
 use crate::configs::connectors::SinkConfig;
 use crate::context::RuntimeContext;
 use crate::log::LOG_CALLBACK;
-use crate::metrics::{Metrics, SinkLabels};
+use crate::metrics::{ConnectorType, Metrics, SinkLabels};
 use crate::{
     FailedPlugin, PLUGIN_ID, RuntimeError, SinkApi, SinkConnector, 
SinkConnectorConsumer,
-    SinkConnectorPlugin, SinkConnectorWrapper, resolve_plugin_path, transform,
+    SinkConnectorPlugin, SinkConnectorWrapper, close_plugin_instance, 
resolve_plugin_path,
+    transform,
 };
 use dlopen2::wrapper::Container;
 use futures::StreamExt;
@@ -182,12 +183,8 @@ pub async fn init(
                 let connector = sink_connectors
                     .get_mut(&path)
                     .expect("sink connector was inserted above");
-                let close_result = 
(connector.container.iggy_sink_close)(plugin_id);
-                if close_result != 0 {
-                    warn!(
-                        "iggy_sink_close returned {close_result} while 
cleaning up failed sink connector with ID: {plugin_id} ({key})"
-                    );
-                }
+                let close = connector.container.iggy_sink_close;
+                close_plugin_instance(&|id| close(id), ConnectorType::Sink, 
plugin_id, &key);
                 if let Some(plugin) = connector
                     .plugins
                     .iter_mut()
diff --git a/core/connectors/runtime/src/source.rs 
b/core/connectors/runtime/src/source.rs
index c58d6352c..7c54108d8 100644
--- a/core/connectors/runtime/src/source.rs
+++ b/core/connectors/runtime/src/source.rs
@@ -42,15 +42,17 @@ use crate::benchmark;
 use crate::configs::connectors::SourceConfig;
 use crate::context::RuntimeContext;
 use crate::log::LOG_CALLBACK;
+use crate::metrics::ConnectorType;
 use crate::metrics::SourceLabels;
 use crate::{
     FailedPlugin, PLUGIN_ID, RuntimeError, SourceApi, SourceConnector, 
SourceConnectorPlugin,
-    SourceConnectorProducer, SourceConnectorWrapper, resolve_plugin_path,
+    SourceConnectorProducer, SourceConnectorWrapper, close_plugin_instance, 
resolve_plugin_path,
     state::{StateStorage, StateStorageFactory},
     transform,
 };
 use iggy_connector_sdk::api::ConnectorStatus;
 use prometheus_client::metrics::counter::Counter;
+use tokio::runtime::Handle;
 use tokio::task::JoinHandle;
 
 const MAX_FAILED_TAIL_RETRIES: u32 = 3;
@@ -185,7 +187,7 @@ pub async fn init(
             source_connectors.insert(
                 path.clone(),
                 SourceConnector {
-                    container,
+                    container: Arc::new(container),
                     plugins: Vec::new(),
                 },
             );
@@ -226,6 +228,15 @@ pub async fn init(
             continue;
         }
 
+        // A plugin left with `error` set is skipped by `handle`, so nothing
+        // would ever reach the instance `init_source` just created.
+        let instance_guard = {
+            let connector = source_connectors
+                .get_mut(&path)
+                .expect("source connector was inserted above");
+            SourceInstanceGuard::for_container(connector.container.clone(), 
plugin_id, &key)
+        };
+
         match setup_source_producer(&key, &config, iggy_client).await {
             Ok((producer, encoder, transforms)) => {
                 let connector = source_connectors
@@ -238,6 +249,7 @@ pub async fn init(
                     .expect("source plugin was pushed above");
                 plugin.producer = Some(SourceConnectorProducer { producer, 
encoder });
                 plugin.transforms = transforms;
+                instance_guard.disarm();
                 info!(
                     "Source container with name: {name} ({key}) initialized 
successfully with ID: {plugin_id}."
                 );
@@ -245,15 +257,10 @@ pub async fn init(
             Err(error) => {
                 let message = format!("Failed to set up source producer: 
{error}");
                 error!("Source: {name} ({key}) - {message}");
+                instance_guard.close().await;
                 let connector = source_connectors
                     .get_mut(&path)
                     .expect("source connector was inserted above");
-                let close_result = 
(connector.container.iggy_source_close)(plugin_id);
-                if close_result != 0 {
-                    warn!(
-                        "iggy_source_close returned {close_result} while 
cleaning up failed source connector with ID: {plugin_id} ({key})"
-                    );
-                }
                 if let Some(plugin) = connector
                     .plugins
                     .iter_mut()
@@ -305,6 +312,115 @@ pub(crate) fn init_source(
     }
 }
 
+/// A plugin's `iggy_source_close` together with whatever keeps the library 
that
+/// exports it mapped, so the call stays valid once it is deferred off the
+/// calling thread.
+pub(crate) type SourceClose = Arc<dyn Fn(u32) -> i32 + Send + Sync>;
+
+/// Closes a source instance that `iggy_source_open` created and nothing else
+/// will ever reach.
+///
+/// Between `init_source` succeeding and the plugin id reaching 
`SourceDetails`,
+/// the instance exists inside the plugin and nothing outside it knows the id:
+/// `stop_connector` closes whatever `details.info.id` holds, which is still 
the
+/// previous instance. An early return there stranded the new one for the life
+/// of the process. A guard rather than a cleanup branch per fallible call,
+/// because the window is those two statements rather than whichever call
+/// between them is fallible today, so a `?` added inside it stays correct.
+/// Startup and restart both hand off through it.
+///
+/// Teardown runs two ways and they are not interchangeable. [`Self::close`]
+/// awaits, so an error returned after it means the instance is already gone
+/// and an immediate retry has nothing to collide with. `Drop` cannot await, so
+/// it hands the work to the blocking pool; the closure carries the container,
+/// which is what keeps the library mapped until the call returns.
+#[must_use = "dropping an armed guard closes the source instance"]
+pub(crate) struct SourceInstanceGuard {
+    /// `Some` while this guard owns the instance, `None` once something else
+    /// does. One representation rather than a close plus a flag that had to
+    /// agree with it, and taking it is what lets both teardown paths run
+    /// without cloning the callback.
+    close: Option<SourceClose>,
+    plugin_id: u32,
+    key: String,
+}
+
+impl SourceInstanceGuard {
+    /// Arms a guard over an instance the caller has just opened through
+    /// `container`, which it captures rather than borrows for the reason the
+    /// type documents.
+    pub(crate) fn for_container(
+        container: Arc<Container<SourceApi>>,
+        plugin_id: u32,
+        key: &str,
+    ) -> Self {
+        Self::new(
+            Arc::new(move |id| (container.iggy_source_close)(id)),
+            plugin_id,
+            key,
+        )
+    }
+
+    /// Kept behind `for_container` so no production caller can build a guard
+    /// that holds a close pointer without its library. Tests pass a closure.
+    fn new(close: SourceClose, plugin_id: u32, key: &str) -> Self {
+        Self {
+            close: Some(close),
+            plugin_id,
+            key: key.to_owned(),
+        }
+    }
+
+    /// Hands ownership of the instance to the caller, once something else can
+    /// close it. Call only after the plugin id is recorded on `SourceDetails`.
+    pub(crate) fn disarm(mut self) {
+        self.close = None;
+    }
+
+    /// The awaited half of the teardown the type documents. Error arms call it
+    /// rather than leaving the work to `Drop`, which cannot offer the 
ordering.
+    pub(crate) async fn close(mut self) {
+        let Some(close) = self.close.take() else {
+            return;
+        };
+        let plugin_id = self.plugin_id;
+        let key = std::mem::take(&mut self.key);
+        if tokio::task::spawn_blocking(move || {
+            close_plugin_instance(close.as_ref(), ConnectorType::Source, 
plugin_id, &key)
+        })
+        .await
+        .is_err()
+        {
+            warn!(
+                "Teardown of failed source connector with ID: {plugin_id} did 
not run to completion."
+            );
+        }
+    }
+}
+
+impl Drop for SourceInstanceGuard {
+    fn drop(&mut self) {
+        let Some(close) = self.close.take() else {
+            return;
+        };
+
+        let plugin_id = self.plugin_id;
+        let key = std::mem::take(&mut self.key);
+        // `SourceContainer::close` drives the plugin's own `close()` under
+        // `block_on` and runs for as long as the plugin takes, so it goes to
+        // the blocking pool where blocking is what the thread is for.
+        match Handle::try_current() {
+            Ok(handle) => {
+                handle.spawn_blocking(move || {
+                    close_plugin_instance(close.as_ref(), 
ConnectorType::Source, plugin_id, &key)
+                });
+            }
+            // No runtime to hand it to, and no worker to protect either.
+            Err(_) => close_plugin_instance(close.as_ref(), 
ConnectorType::Source, plugin_id, &key),
+        }
+    }
+}
+
 pub(crate) async fn setup_source_producer(
     key: &str,
     config: &SourceConfig,
@@ -532,7 +648,10 @@ pub(crate) async fn source_forwarding_loop(
                 matches!(pending_state_error.as_ref(), 
Some(SdkError::StateLatched))
                     || (pending_state_error.is_none() && state_latched);
             if !preserve_original_error {
-                context.sources.set_error(&plugin_key, &error_msg).await;
+                context
+                    .sources
+                    .set_error(&plugin_key, &error_msg, Some(&context.metrics))
+                    .await;
             }
         } else {
             context
@@ -573,7 +692,10 @@ pub(crate) async fn source_forwarding_loop(
                         );
                         error!("{error_msg}");
                         
context.metrics.inc_errors_with_labels(&labels.counter);
-                        context.sources.set_error(&plugin_key, 
&error_msg).await;
+                        context
+                            .sources
+                            .set_error(&plugin_key, &error_msg, 
Some(&context.metrics))
+                            .await;
                     }
                 }
             } else {
@@ -602,7 +724,10 @@ pub(crate) async fn source_forwarding_loop(
                 );
                 error!("{error_msg}");
                 context.metrics.inc_errors_with_labels(&labels.counter);
-                context.sources.set_error(&plugin_key, &error_msg).await;
+                context
+                    .sources
+                    .set_error(&plugin_key, &error_msg, Some(&context.metrics))
+                    .await;
             }
         }
 
@@ -936,7 +1061,9 @@ mod tests {
     use super::*;
     use std::collections::VecDeque;
     use std::future::ready;
+    use std::sync::Mutex;
     use std::sync::atomic::{AtomicU32, Ordering};
+    use std::time::Duration;
 
     static TEST_PLUGIN_ID: AtomicU32 = AtomicU32::new(u32::MAX / 2);
 
@@ -961,6 +1088,166 @@ mod tests {
         }
     }
 
+    /// A close that records the ids it was handed and answers `result`.
+    ///
+    /// The guard takes its close as a closure, so each test owns its recorder
+    /// and nothing is shared between tests. An `extern "C" fn` cannot capture,
+    /// which is what used to force this through statics.
+    fn recording_close(result: i32) -> (SourceClose, Arc<Mutex<Vec<u32>>>) {
+        let closed = Arc::new(Mutex::new(Vec::new()));
+        let recorded = closed.clone();
+        (
+            Arc::new(move |id| {
+                recorded.lock().expect("close recorder").push(id);
+                result
+            }),
+            closed,
+        )
+    }
+
+    /// The shape `start_connector` has: a guard armed over an instance nothing
+    /// else knows about, then a fallible step whose `?` returns before 
anything
+    /// records the id.
+    fn start_with_fallible_step(
+        close: SourceClose,
+        plugin_id: u32,
+        step: Result<(), RuntimeError>,
+    ) -> Result<(), RuntimeError> {
+        let instance_guard = SourceInstanceGuard::new(close, plugin_id, 
"random");
+        step?;
+        instance_guard.disarm();
+        Ok(())
+    }
+
+    #[test]
+    fn given_armed_guard_when_dropped_should_close_the_instance() {
+        // The leak this exists for: `init_source` has created the instance and
+        // nothing outside the plugin knows its id yet, so an early return here
+        // would strand it for the life of the process.
+        let plugin_id = next_plugin_id();
+        let (close, closed) = recording_close(0);
+
+        drop(SourceInstanceGuard::new(close, plugin_id, "random"));
+
+        assert_eq!(
+            *closed.lock().expect("close recorder"),
+            vec![plugin_id],
+            "a guard still armed owns the instance and must close exactly it"
+        );
+    }
+
+    #[test]
+    fn given_fallible_step_when_it_returns_early_should_close_the_instance() {
+        // The shape dropping or disarming inline cannot show, and the one the
+        // guard is there for: the `?` leaves with the guard still armed and
+        // never reaches `disarm`. The error arms call `close()` directly now,
+        // so this is the net under a `?` added inside the window later.
+        let plugin_id = next_plugin_id();
+        let (close, closed) = recording_close(0);
+
+        let result = start_with_fallible_step(
+            close,
+            plugin_id,
+            Err(RuntimeError::InvalidConfiguration("injected".to_string())),
+        );
+
+        assert!(result.is_err(), "the injected failure has to propagate");
+        assert_eq!(
+            *closed.lock().expect("close recorder"),
+            vec![plugin_id],
+            "a `?` must not strand the instance it left behind"
+        );
+    }
+
+    #[test]
+    fn given_fallible_step_when_it_succeeds_should_leave_the_instance_open() {
+        // The other half of the same helper: reaching `disarm` hands the
+        // instance on rather than closing it.
+        let plugin_id = next_plugin_id();
+        let (close, closed) = recording_close(0);
+
+        let result = start_with_fallible_step(close, plugin_id, Ok(()));
+
+        assert!(result.is_ok());
+        assert!(
+            closed.lock().expect("close recorder").is_empty(),
+            "a step that succeeded leaves the instance for the manager to 
close"
+        );
+    }
+
+    #[tokio::test]
+    async fn 
given_armed_guard_when_dropped_in_runtime_should_close_off_the_worker() {
+        // `drop` cannot await, and `SourceContainer::close` drives the 
plugin's
+        // own teardown under `block_on`, so closing here would hold a worker 
for
+        // however long the plugin takes. It goes to the blocking pool instead,
+        // which is what the differing thread asserts. The close still has to
+        // happen.
+        let plugin_id = next_plugin_id();
+        let dropping_thread = std::thread::current().id();
+        let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
+
+        drop(SourceInstanceGuard::new(
+            Arc::new(move |id| {
+                let _ = sender.send((id, std::thread::current().id()));
+                0
+            }),
+            plugin_id,
+            "random",
+        ));
+
+        let (closed_id, closing_thread) =
+            tokio::time::timeout(Duration::from_secs(5), receiver.recv())
+                .await
+                .expect("the deferred close should run")
+                .expect("the deferred close should report the instance");
+        assert_eq!(
+            closed_id, plugin_id,
+            "the deferred close must reach the instance the guard was armed 
over"
+        );
+        assert_ne!(
+            closing_thread, dropping_thread,
+            "closing on the dropping thread holds it for the plugin's teardown"
+        );
+    }
+
+    #[tokio::test]
+    async fn given_armed_guard_when_closed_should_finish_before_returning() {
+        // What the error arms rely on: once `close()` has returned the 
instance
+        // is gone, so the error they return cannot reach an operator who then
+        // retries into a collision with it.
+        let plugin_id = next_plugin_id();
+        let (close, closed) = recording_close(0);
+
+        SourceInstanceGuard::new(close, plugin_id, "random")
+            .close()
+            .await;
+
+        assert_eq!(
+            *closed.lock().expect("close recorder"),
+            vec![plugin_id],
+            "close() has to await the teardown, and the drop after it must not 
repeat it"
+        );
+    }
+
+    #[test]
+    fn 
given_refused_close_when_guard_drops_should_close_once_and_swallow_refusal() {
+        // The plugin answers -1 for an id it does not know. Both callers are
+        // already returning an error of their own, so the refusal is reported
+        // and not propagated: unwinding out of `drop` would be worse than the
+        // leak it is cleaning up after. The harness gives no-panic for free, 
so
+        // what this asserts is the single call.
+        let plugin_id = next_plugin_id();
+        let (close, closed) = recording_close(-1);
+
+        drop(SourceInstanceGuard::new(close, plugin_id, "random"));
+
+        assert_eq!(
+            *closed.lock().expect("close recorder"),
+            vec![plugin_id],
+            "a refusal must not become a retry or a second close"
+        );
+    }
+
     #[test]
     fn given_serialized_batch_when_callback_runs_should_forward_batch_id() {
         let plugin_id = next_plugin_id();
diff --git a/core/connectors/sdk/src/sink.rs b/core/connectors/sdk/src/sink.rs
index 332f73c4e..cbf8974f8 100644
--- a/core/connectors/sdk/src/sink.rs
+++ b/core/connectors/sdk/src/sink.rs
@@ -267,6 +267,13 @@ macro_rules! sink_connector {
 
             let mut container = SinkContainer::new(id);
             let result = container.open(id, config_ptr, config_len, 
log_callback, <$type>::new);
+            if result != 0 {
+                // Rolled back rather than registered, for the reason the
+                // source macro gives: a failed open is still stored on the
+                // container, and registering it strands an instance nothing
+                // outside can name to close.
+                return result;
+            }
             INSTANCES.insert(id, container);
             result
         }
diff --git a/core/connectors/sdk/src/source.rs 
b/core/connectors/sdk/src/source.rs
index 532d2643f..a264a56df 100644
--- a/core/connectors/sdk/src/source.rs
+++ b/core/connectors/sdk/src/source.rs
@@ -594,6 +594,16 @@ macro_rules! source_connector {
                 log_callback,
                 <$type>::new,
             );
+            if result != 0 {
+                // Rolled back rather than registered. `open` stores the
+                // instance on the container whatever it returns, so a failed
+                // one would sit here for the life of the process: the runtime
+                // gets an error back before it has recorded the id, so nothing
+                // outside can name it to close it. Dropping the container is
+                // the rollback, and it releases whatever the plugin took
+                // before it failed.
+                return result;
+            }
             INSTANCES.insert(id, container);
             result
         }
diff --git a/core/integration/tests/connectors/random/random_source.rs 
b/core/integration/tests/connectors/random/random_source.rs
index 772a2a225..42a3f6c51 100644
--- a/core/integration/tests/connectors/random/random_source.rs
+++ b/core/integration/tests/connectors/random/random_source.rs
@@ -27,7 +27,10 @@ use tokio::time::{sleep, timeout};
 const API_KEY: &str = "test-api-key";
 const SOURCE_KEY: &str = "random";
 const RETRY_INTERVAL: Duration = Duration::from_millis(100);
-const STATE_STABILITY_WINDOW: Duration = Duration::from_secs(1);
+/// How long a counter is given to settle after the change that moves it.
+/// Shared by the state-file and gauge waits: both are waiting on the same
+/// thing, a report that may land just after the poll that preceded it.
+const SETTLE_WINDOW: Duration = Duration::from_secs(1);
 const WAIT_TIMEOUT: Duration = Duration::from_secs(10);
 
 #[iggy_harness(
@@ -54,7 +57,7 @@ async fn 
state_save_failure_preserves_state_and_source_recovers(harness: &TestHa
         .connectors_runtime()
         .expect("connectors runtime")
         .http_url();
-    let http = Client::new();
+    let http = client();
     let errors_before_failure = source_errors(&http, &api_url).await;
     let state_dir = state_path.parent().expect("source state directory");
     let unavailable_state_dir = state_dir.with_extension("unavailable");
@@ -68,7 +71,7 @@ async fn 
state_save_failure_preserves_state_and_source_recovers(harness: &TestHa
         .expect("source state should remain readable");
     wait_for_source_error_after(&http, &api_url, errors_before_failure).await;
 
-    sleep(STATE_STABILITY_WINDOW).await;
+    sleep(SETTLE_WINDOW).await;
     assert_eq!(
         tokio::fs::read(&unavailable_state_path)
             .await
@@ -97,6 +100,102 @@ async fn 
state_save_failure_preserves_state_and_source_recovers(harness: &TestHa
     random_source_liveness::assert_produces_messages(harness).await;
 }
 
+#[iggy_harness(
+    server(connectors_runtime(config_path = 
"tests/connectors/random/source.toml")),
+    seed = seeds::connector_stream
+)]
+async fn sources_running_does_not_climb_across_restarts(harness: &TestHarness) 
{
+    // The gauge counts running instances, and a restart takes one down before
+    // bringing one up, so one configured source stays at one however often it
+    // is restarted. It used to be reported by two mechanisms and taken back by
+    // one.
+    let api_url = harness
+        .connectors_runtime()
+        .expect("connectors runtime")
+        .http_url();
+    let http = client();
+
+    wait_for_sources_running(&http, &api_url, 1).await;
+
+    for round in 1..=3 {
+        let response = http
+            .post(format!("{api_url}/sources/{SOURCE_KEY}/restart"))
+            .header("api-key", API_KEY)
+            .send()
+            .await
+            .expect("restart request should be sent");
+        assert_eq!(
+            response.status().as_u16(),
+            204,
+            "restart {round} should be accepted"
+        );
+
+        wait_for_sources_running(&http, &api_url, 1).await;
+    }
+
+    // Read it once more after the gauge has settled. A report that lands after
+    // the poll above would otherwise go unseen.
+    sleep(SETTLE_WINDOW).await;
+    assert_eq!(
+        sources_running(&http, &api_url).await,
+        1,
+        "one running source must stay counted once, whatever it took to 
restart it"
+    );
+}
+
+/// Every request carries [`WAIT_TIMEOUT`], so none of them can outlive the
+/// wait they belong to. Without it a stalled runtime hangs the test rather
+/// than failing it, and a hung test reports nothing at all.
+fn client() -> Client {
+    Client::builder()
+        .timeout(WAIT_TIMEOUT)
+        .build()
+        .expect("the test client must build")
+}
+
+/// The one request the stats helpers share. Handing back the `Result` rather
+/// than unwrapping it is what lets the retry loops keep treating a failed read
+/// as "not yet" while the direct readers keep failing on it.
+async fn fetch_stats(http: &Client, api_url: &str) -> 
reqwest::Result<ConnectorRuntimeStats> {
+    http.get(format!("{api_url}/stats"))
+        .header("api-key", API_KEY)
+        .send()
+        .await?
+        .json::<ConnectorRuntimeStats>()
+        .await
+}
+
+async fn sources_running(http: &Client, api_url: &str) -> u32 {
+    fetch_stats(http, api_url)
+        .await
+        .expect("runtime stats should be valid")
+        .sources_running
+}
+
+async fn wait_for_sources_running(http: &Client, api_url: &str, expected: u32) 
{
+    // The last value the loop actually saw, rather than a fresh read in the
+    // failure message. That read was the one request with no budget over it:
+    // it only runs once the wait has already timed out, which is exactly when
+    // the runtime is stalled, so the test hung instead of failing and reported
+    // nothing at all.
+    let mut last = None;
+    let reached = timeout(WAIT_TIMEOUT, async {
+        loop {
+            let running = sources_running(http, api_url).await;
+            last = Some(running);
+            if running == expected {
+                return;
+            }
+            sleep(RETRY_INTERVAL).await;
+        }
+    })
+    .await;
+    assert!(
+        reached.is_ok(),
+        "sources_running never reached {expected}; last read {last:?}"
+    );
+}
+
 async fn wait_for_state_file(state_path: &Path) {
     timeout(Duration::from_secs(5), async {
         while !state_path.exists() {
@@ -108,13 +207,7 @@ async fn wait_for_state_file(state_path: &Path) {
 }
 
 async fn source_errors(http: &Client, api_url: &str) -> u64 {
-    let stats = http
-        .get(format!("{api_url}/stats"))
-        .header("api-key", API_KEY)
-        .send()
-        .await
-        .expect("runtime stats should be available")
-        .json::<ConnectorRuntimeStats>()
+    let stats = fetch_stats(http, api_url)
         .await
         .expect("runtime stats should be valid");
     stats
@@ -128,12 +221,7 @@ async fn source_errors(http: &Client, api_url: &str) -> 
u64 {
 async fn wait_for_source_error_after(http: &Client, api_url: &str, 
previous_errors: u64) {
     timeout(WAIT_TIMEOUT, async {
         loop {
-            if let Ok(response) = http
-                .get(format!("{api_url}/stats"))
-                .header("api-key", API_KEY)
-                .send()
-                .await
-                && let Ok(stats) = 
response.json::<ConnectorRuntimeStats>().await
+            if let Ok(stats) = fetch_stats(http, api_url).await
                 && let Some(source) = stats
                     .connectors
                     .iter()

Reply via email to