Skip to content

fix(connectors): close the source instance a failed start leaves behind - #4064

Open
mlevkov wants to merge 1 commit into
apache:masterfrom
mlevkov:runtime-source-start-cleanup
Open

fix(connectors): close the source instance a failed start leaves behind#4064
mlevkov wants to merge 1 commit into
apache:masterfrom
mlevkov:runtime-source-start-cleanup

Conversation

@mlevkov

@mlevkov mlevkov commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Closes #4062.

The leak

SourceManager::start_connector takes a fresh plugin_id, calls init_source, and records that id on SourceDetails only after the handler tasks are spawned. In between, 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. setup_source_producer returning early through ? therefore stranded the new one for the life of the process. source::init already cleaned up on the identical failure, which is the asymmetry @hubcio pointed at.

For a plugin whose open only allocates, the orphan is wasted memory. For one that takes a process-global resource it is a live fault: a shared listener stays bound and answering into a queue nothing drains, and every retried restart then fails on the identity the orphan never released.

A guard, not a cleanup branch

This deviates from the fix in the review, which was to mirror source::init's error arm at the call site, so it is worth saying why rather than leaving it to be found.

The window is defined by the two statements that open the instance and record its id, not by which call between them happens to be fallible today. A cleanup branch is correct only for the one ? that exists now, and silently wrong for the next one somebody adds. SourceInstanceGuard is armed at init_source and disarmed once the id is recorded, so every path out of that window closes the instance, including a panic.

It also made the behaviour testable. Container<SourceApi> only comes from dlopen, so start_connector cannot be exercised in a unit test at all, while a guard holding the bare extern "C" fn can be driven directly.

The close-and-report itself is now one function shared with source::init, so the two sites cannot drift. source::init keeps its existing control flow; only the duplicated body moved.

No cleanup_sender on this path: spawn_source_handler is what registers the sender, and it has not run yet.

Tests

Three, each mutation-checked, each mutant confirmed to compile first:

  • an armed guard closes, and closes its own id, since closing another would leave this instance open and tear down a live one
  • a disarmed guard does not close, or a source that just started successfully would be torn down
  • a refused close (-1, the code the SDK returns for an unknown id) is reported and not propagated, because unwinding out of drop would be worse than the leak it is cleaning up after

Each test owns its stub and statics rather than sharing a pair, which would have made two of them race in the same process.

What is not covered, and why

The guard's placement in start_connector has no test. I verified that rather than assuming it: disarming the guard immediately after construction restores the original leak, compiles, and the suite still passes.

Reaching that path needs a real Container, so it cannot be a unit test, and the only route into start_connector is POST /sources/{key}/restart. Making setup_source_producer fail there means either a config the local provider will serve on restart but not at boot, which today works only because of the version selection in #3848 and would break when that is fixed, or stopping the broker mid-test. Both couple this regression test to something unrelated to it, so I left it out rather than write a test that fails for the wrong reason later. Happy to add either if you would rather have the coverage than the independence.

source::init's cleanup remains covered only by error_isolation.rs asserting the connector reports Error, which it did before this change too.

Verification

cargo fmt, cargo sort --no-format, clippy at both feature sets, rustdoc under -D warnings, 198 unit tests in iggy-connectors, and stdout_sink + random_source still build.

Closes apache#4062.

`start_connector` allocates a fresh plugin id, calls `init_source`, and only
records that id on `SourceDetails` once the handler tasks are spawned. In
between, 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. `setup_source_producer` returning early through
`?` therefore stranded the new one for the life of the process, while the
boot path in `source::init` cleaned up on the identical failure.

For a plugin whose open only allocates, the orphan is wasted memory. For one
that takes a process-global resource, it is a live fault: a shared listener
stays bound and answering into a queue nothing drains, and every retried
restart then fails on the identity the orphan never released.

A guard rather than a cleanup branch at the one call that can fail today,
because the window is defined by the two statements that open and record the
instance, not by which call between them happens to be fallible. Adding a `?`
inside it stays correct. The close-and-report itself is shared with the boot
path so the two cannot drift.
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Thanks for the PR. It is labeled S-waiting-on-review and queued for review.

Slash commands (own line, regular comment) move it around the queue:

  • /ready - back to S-waiting-on-review after addressing feedback
  • /author - flip to S-waiting-on-author while you finish changes
  • /request-review @user-or-team - request a reviewer
  • /pin - exempt the PR from the stale bot, /unpin to undo

See CONTRIBUTING.md for details.

@github-actions github-actions Bot added the S-waiting-on-review PR is waiting on a reviewer label Sep 5, 2026
@mlevkov

mlevkov commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

/ready

@mlevkov

mlevkov commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

/request-review @hubcio

@github-actions
github-actions Bot requested a review from hubcio September 5, 2026 06:33
@codecov

codecov Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.24590% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 21.89%. Comparing base (63d3273) to head (1d20ef5).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
core/connectors/runtime/src/source.rs 89.65% 5 Missing and 1 partial ⚠️
core/connectors/runtime/src/manager/source.rs 0.00% 3 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             master    #4064       +/-   ##
=============================================
- Coverage     85.51%   21.89%   -63.63%     
  Complexity     1402     1402               
=============================================
  Files          1240     1239        -1     
  Lines        186294   154271    -32023     
  Branches     152598   120575    -32023     
=============================================
- Hits         159316    33774   -125542     
- Misses        22923   119717    +96794     
+ Partials       4055      780     -3275     
Components Coverage Δ
Rust Core 3.76% <85.24%> (-82.70%) ⬇️
Java SDK 67.29% <ø> (ø)
C# SDK 76.33% <ø> (ø)
Python SDK 90.06% <ø> (ø)
PHP SDK 85.65% <ø> (ø)
Node SDK 96.15% <ø> (ø)
Go SDK 69.31% <ø> (ø)
Files with missing lines Coverage Δ
core/connectors/runtime/src/manager/source.rs 67.63% <0.00%> (-24.34%) ⬇️
core/connectors/runtime/src/source.rs 30.62% <89.65%> (-51.99%) ⬇️

... and 662 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@hubcio hubcio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

a few things outside the diff, none of them blocking this PR:

  • core/connectors/sdk/src/source.rs:597 - iggy_source_open inserts into INSTANCES even when open() failed, and SourceContainer::open stores the source before returning 1. a plugin whose open() bound a listener and then errored keeps it for the life of the process. same leak class as this PR, one statement earlier. the fix is to skip the insert and drop the container, not to close from the runtime side - the SDK already stored the source, so that would run Source::close() on an instance that never opened.
  • core/connectors/runtime/src/source.rs:71 - SOURCE_SENDERS being a process global is why an orphaned forwarding loop can't die. the sink owns its watch::Sender in SinkDetails, so its identical window self-heals. an RAII registration stored next to handler_tasks would fix the source side properly.
  • core/connectors/runtime/src/manager/sink.rs:200-223 - same unrecorded-instance window on the sink path, details.info.id only set at 223. narrower than the source case, since the consume tasks exit when the watch::Sender drops, so the same guard alone is enough there.
  • core/connectors/runtime/src/manager/source.rs:186-190 - stop_connector never clears details.info.id, so after a failed start every later stop re-closes a dead id and line 168 logs "Closed" for it. the shutdown sweep hits this too.
  • core/connectors/runtime/src/manager/source.rs:167 - the stop path drops the iggy_source_close result and logs "Closed" unconditionally. a -1 there means teardown was skipped while the INSTANCES entry is already gone, so nothing can retry.
  • core/connectors/runtime/src/manager/source.rs:311 - a failed restart leaves the connector Stopped with last_error cleared, so GET /sources shows nothing wrong. one set_error on start_connector(..).await? covers all five fallible steps.
  • core/connectors/runtime/src/source.rs:384-427 - setup_source_producer builds and init()s a producer per configured stream but keeps only the last, so two configured streams silently produce to one.

metrics.increment_sources_running();
}
// `details.info.id` now names this instance, so a later stop reaches it.
instance.disarm();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

warning: cancel at the details.lock().await above (client disconnect drops the axum handler future) and the guard closes the instance but leaves the SOURCE_SENDERS entry and both spawned tasks behind, so the forwarding loop runs forever. take the lock before spawn_source_handler and set info.id there.

@@ -265,6 +271,8 @@ impl SourceManager {
details.handler_tasks = handler_tasks;
metrics.increment_sources_running();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

warning: the forwarding loop's first update_status(Running) already bumped this gauge, and stop only decrements once, so sources_running ratchets up per restart. drop the direct status write and this increment, let update_status own it.

impl Drop for SourceInstanceGuard<'_> {
fn drop(&mut self) {
if self.armed {
close_failed_source(self.close, self.plugin_id, self.key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

warning: this can fire after spawn_source_handler ran, so iggy_source_close hits block_on(handle) and block_on(source.close()) - unbounded plugin teardown on a tokio worker inside drop glue, where no timeout fits. either document that contract on the type or move cleanup to an explicit finish() on the error arms.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

one more option, if you take the Arc suggestion on line 317: move that Arc<Container<SourceApi>> into a spawn_blocking and read iggy_source_close there. Container is Send + Sync, so the task keeps the library mapped and the close stops parking a worker. tradeoff is the teardown becomes unordered against the Err return.

// outside the plugin knows this instance exists, so any early return
// would strand it: `stop_connector` closes `details.info.id`, which
// still names the previous one.
let instance =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: instance holds a guard, so instance.disarm() below reads as disarming the instance. instance_guard matches shutdown_guard and tmp_guard elsewhere in the repo.

/// record the instance, not by which call between them happens to be fallible.
/// Adding a `?` inside it stays correct.
pub(crate) struct SourceInstanceGuard<'a> {
close: extern "C" fn(u32) -> i32,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: the fn pointer has no lifetime tie to the Container that owns the .so - it works only because container is declared before the guard and so drops after it. hold an Arc<Container<SourceApi>> and read iggy_source_close inside drop, rather than leaning on declaration order.

-1
}

#[test]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: all three tests drop or disarm inline, which the compiler already guarantees. none covers the shape the guard exists for - a ? returning early with the guard still armed.

/// Closes a source instance that `iggy_source_open` created and nothing else
/// will ever reach.
///
/// Between `init_source` succeeding and the plugin id being recorded on

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

simplification: this paragraph is repeated almost word for word at manager/source.rs:239-242. keep it here and cut the call-site copy to the one fact it adds.

}
}

/// Records what `SourceInstanceGuard` passed to the FFI. A stub has to be a

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

simplification: next_plugin_id() already hands each test a unique id, so a shared recorder can't race - the caveat guards against a design nobody's using. one id-keyed map plus ok_close and refusing_close replaces three stubs and four statics.

"iggy_source_close returned {close_result} while cleaning up failed source connector with ID: {plugin_id} ({key})"
);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

simplification: sink.rs:185-190 still has this body inline, one word apart. both close pointers are extern "C" fn(u32) -> i32, so one helper taking a "source"/"sink" label covers both.

pub(crate) struct SourceInstanceGuard<'a> {
close: extern "C" fn(u32) -> i32,
plugin_id: u32,
key: &'a str,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

simplification: key exists to label one warn!, and it drags in the lifetime param and the impl<'a>. both call sites already log the key on the same failure, and plugin_id identifies the instance.

@github-actions github-actions Bot added S-waiting-on-author PR is waiting on author response and removed S-waiting-on-review PR is waiting on a reviewer labels Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-waiting-on-author PR is waiting on author response

Projects

None yet

Development

Successfully merging this pull request may close these issues.

connectors: source restart leaks the plugin instance when producer setup fails

2 participants