Skip to content

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

Open
mlevkov wants to merge 12 commits 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 12 commits 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.

@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 95.09804% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.27%. Comparing base (e002750) to head (446f5ac).

Files with missing lines Patch % Lines
core/connectors/runtime/src/source.rs 93.49% 10 Missing and 1 partial ⚠️
core/connectors/runtime/src/manager/source.rs 96.74% 3 Missing and 1 partial ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             master    #4064       +/-   ##
=============================================
- Coverage     86.39%   57.27%   -29.13%     
+ Complexity     1455     1449        -6     
=============================================
  Files          1259     1255        -4     
  Lines        205513   166443    -39070     
  Branches     170714   131740    -38974     
=============================================
- Hits         177550    95326    -82224     
- Misses        23535    66780    +43245     
+ Partials       4428     4337       -91     
Components Coverage Δ
Rust Core 50.18% <95.09%> (-37.15%) ⬇️
Java SDK 67.54% <ø> (-0.05%) ⬇️
C# SDK 77.05% <ø> (+0.08%) ⬆️
Python SDK 91.33% <ø> (-0.02%) ⬇️
PHP SDK 85.65% <ø> (-0.01%) ⬇️
Node SDK 96.17% <ø> (-0.07%) ⬇️
Go SDK 69.40% <ø> (-0.01%) ⬇️
Files with missing lines Coverage Δ
core/connectors/runtime/src/main.rs 87.79% <100.00%> (+1.17%) ⬆️
core/connectors/runtime/src/sink.rs 76.33% <100.00%> (-2.95%) ⬇️
core/connectors/runtime/src/manager/source.rs 92.71% <96.74%> (+0.74%) ⬆️
core/connectors/runtime/src/source.rs 82.71% <93.49%> (-1.82%) ⬇️

... and 466 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.

Comment thread core/connectors/runtime/src/source.rs Outdated
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.

Comment thread core/connectors/runtime/src/source.rs Outdated
/// 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.

Comment thread core/connectors/runtime/src/source.rs Outdated
}
}

/// 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.

Comment thread core/connectors/runtime/src/source.rs Outdated
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
@mlevkov
mlevkov force-pushed the runtime-source-start-cleanup branch from 1d20ef5 to e88133e Compare September 8, 2026 01:54
@mlevkov
mlevkov force-pushed the runtime-source-start-cleanup branch from e88133e to eed068f Compare September 8, 2026 18:26
@mlevkov

mlevkov commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

All 18 inline findings are in, at 898c6a9a1. Ten commits, one per finding
where that made sense. Gate green: fmt, sort, clippy at both feature sets,
rustdoc -D warnings, --locked, 212 unit tests (was 206) over three clean
runs, plus 64 connectors integration tests across runtime, api, random,
stdout, http_config_provider and postgres.

batch findings commit
A3 close pointer tied to its library 1f2c2a490
A2 and its follow-up teardown off the worker 20d03bc7d
A1 cancelled start leaking both tasks a1722c5bf
A4 the sources_running ratchet abacd38d2
B1-B4 the guard tests cbc4a31e0
C1, C5, C8 one close for a plugin instance 0dcf36957
C2, C3, C4, C6 naming, must_use, docs 9c7bb0d21
A4 follow-up a gauge test, and what it showed d90b72fa5
A1 follow-up an await in the start window is now a build error 0c4b859f8
@spetz the status transition and the gauge move together 898c6a9a1

You were right that the fix was incomplete both ways. A1 is a bit worse than
"the tasks are left behind": dropping the JoinHandles detaches them, so the
forwarding loop keeps running against a channel nothing feeds. The lock now
comes before the spawn, so nothing can await between registering the tasks and
recording the id that reaches them.

Rebased onto master twice since this was posted, so the shas above are the
current ones. A tenth commit answers @spetz's review; see the reply below.

Five places I did not do exactly what you asked

A3, the Arc. Held as Arc<dyn Fn(u32) -> i32 + Send + Sync>, built by a
for_container constructor that captures the Arc<Container<SourceApi>>,
rather than as an Arc<Container<SourceApi>> field. A Container only exists
via dlopen, so a guard carrying that field cannot be built in a unit test at
all, and B1 and B2 ask for more guard tests, not fewer. The single production
constructor takes the container, so the ownership stays enforced by the API.
Same lifetime tie, still testable.

A2, both of your options rather than one. drop defers to the blocking
pool, and the error arms await an explicit close(). Deferring on its own costs
the ordering you named, and that ordering matters here: a restart retried inside
the teardown window would collide with the instance being torn down, which is
the failure #4062 exists to prevent. drop stays the net for a cancellation and
for any ? added in the window later.

A4, reported rather than deleted. Dropping the status write outright leaves a
connector that just started answering Stopped to GET /sources until the
forwarding loop takes the lock. start_connector now reports through
update_status, so the gauge still has exactly one owner.

C7, the key field. The lifetime param and the impl<'a> are gone, which
was the objection. key stays, as an owned String: now that the close can be
deferred, its warn! is emitted outside the caller's log context, so the key is
the only thing tying that line to a connector. Happy to drop it for plugin_id
alone if you would rather.

C1, not the stop path. You noted the helper would also suit
manager/source.rs:167. That path drops the iggy_source_close result and logs
"Closed" regardless, which is your own out-of-diff finding, so wiring it in here
would fix a separate bug inside this PR.

What the integration tests do and do not reach

They do reach more than I first credited them with. source_with_invalid_config
in error_isolation fails inside setup_source_producer, which is the arm the
guard now sits on in init, and random_source_produces_messages is what would
break if the disarm() in the other arm were wrong. For the restart path,
given_restart_when_state_exists_should_resume_from_served_state and the two
CDC restart tests POST /sources/{key}/restart and then wait for the source to
reload state and resume, so A1's restructure is exercised end to end.

Two things are still not pinned by any test:

  • A1's cancellation window. Nothing can drop the start_connector future at
    that one await, so it still has no test. It is no longer held by a comment
    either: SourceDetails::record_started takes the spawn as a closure and is
    deliberately not async, so an await added between the spawn and the id
    record does not compile. Checked by adding one, error[E0728]. Same argument
    you made for #[must_use], applied to the window rather than the guard. It
    also made the step testable, which nothing in that region was.

  • A4's arithmetic. There is now a test for it, and it is worth telling you
    what it showed. sources_running_does_not_climb_across_restarts restarts the
    random source three times and requires the gauge back at 1. It passes, but it
    does not isolate the fix: revert the report through update_status and it
    still passes 3 of 3, because taking the lock before the spawn already makes
    start_connector win the race to that lock. Revert that too, so the code is
    what you reviewed, and it still passes 6 of 6. start_connector takes an
    uncontended mutex immediately, while the spawned loop has to be scheduled and
    walk a DashMap to reach the same lock, so it does not get there first.

    So the double count is real in the code and, as far as I can make it behave,
    latent. The fix stands on not wanting correctness to rest on that lock
    ordering, not on a ratchet I could reproduce. Said plainly rather than let the
    commit imply I had seen it climb.

Everything else was mutation-checked, with each mutant confirmed to compile
first: closing inline instead of deferring, close() not awaiting (10 rounds of
10), disarm() moved ahead of the fallible step, a guard that never arms, the
transition guard removed from update_status, and a bare
SourceInstanceGuard::new(..); statement, which #[must_use] now rejects at
build time.

Out of diff

Filed the multi-stream producer bug as #4097. setup_source_producer init()s a
producer per [[streams]] entry and keeps only the last, so two configured
streams silently produce to one. Both auto-create flags default on, so every
configured stream and topic really is created and all but the last stay empty,
which is what makes it hard to spot. The other six are not filed yet.

@mlevkov

mlevkov commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

/ready

@mlevkov

mlevkov commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

/request-review @hubcio

// the forwarding loop reports `Running` too, for the boot path that
// never comes through here, and a stop decrements once. Reported twice
// and taken back once, the gauge climbed with every restart.
self.update_status(key, ConnectorStatus::Running, Some(metrics))

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.

This update_status(Running) runs after releasing the startup lock, so the forwarding loop can report Running and then call set_error for its first batch before this call executes. The delayed update overwrites Error, clears last_error, and increments sources_running again because set_error does not decrement it.

A controlled interleaving reproduced Running, no last_error, and sources_running = 2 for one instance. Performing the initial transition under the startup lock preserved Error, its details, and a count of 1.

Please apply the initial status transition under the existing details lock, sharing the transition logic with update_status, and remove this trailing unconditional update. Add a regression test with an error between the two Running reports. The current consecutive-Running test cannot catch this ordering

@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 9, 2026
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.
The guard held a bare `extern "C" fn` read out of a dlopened `.so`. Nothing
tied it to the `Container` that owns the mapping. It worked only because
`container` is declared before the guard and therefore drops after it. A
declaration order dependency is thin support for an FFI call into a loaded
library, and it stops being enough at all once the call is deferred off the
calling thread.

`for_container` now captures the `Arc<Container<SourceApi>>` inside the closure
the guard calls, so the mapping is owned for as long as the close is reachable.

The field is `Arc<dyn Fn(u32) -> i32 + Send + Sync>` rather than an
`Arc<Container<SourceApi>>`. A `Container` only exists via dlopen, so a guard
carrying that field cannot be constructed in a unit test at all, and this guard
needs more tests rather than fewer. The single production constructor takes the
container, so the ownership stays enforced by the API and not by convention.

`close_failed_source` now takes one callable shape. An `extern "C" fn` does not
implement `Fn`, so the boot path wraps its pointer at the call site.

The doc comment claimed the plugin id was "durably recorded". `SourceDetails` is
memory only and the id never reaches the state store, so it now says recorded on
`SourceDetails`.
`SourceContainer::close` drives the plugin's own `close()` under `block_on`, and
`block_on(handle)` before it. Calling that from the guard's `drop` put an
unbounded plugin teardown on a tokio worker, in the one place no timeout can
ever be added, because drop glue cannot await.

`drop` now hands the close to the blocking pool. The closure it carries owns the
container, so the library stays mapped until the call returns. That is what
makes deferring safe.

Deferring costs the ordering between teardown and the returned error, so the
known failure arm no longer relies on `drop`. `setup_source_producer`'s error arm
awaits `close()`: off the worker and ordered, so by the time the error reaches an
operator the instance is gone and a restart retried straight away cannot collide
with it. `drop` stays the net for a cancellation and for any `?` added in the
window later.

`Handle::try_current` picks between them. A guard dropped outside a runtime has
no worker to protect and nothing to hand the work to, so it closes inline. That
is also the path the plain `#[test]` cases take.

The deferred test asserts the close lands on a different thread from the one that
dropped the guard, which is the only observable difference between handing it
over and running it inline. The ordered test asserts exactly one call after
`close()` returns, so the teardown was awaited and the following drop did not
repeat it.

`instance` became `instance_guard` while these lines were being rewritten. It
holds a guard, and `instance.disarm()` read as disarming the instance.
The guard closed the plugin instance but could not reach the runtime half of the
same leak. `spawn_source_handler` registers the `SOURCE_SENDERS` entry and spawns
both tasks, and the id that reaches them was recorded only after the next
`details.lock().await`. A cancellation in that gap, which is what a client
disconnect dropping the axum handler future does, left the entry and both tasks
behind with nothing naming them, so the forwarding loop ran for the life of the
process.

The lock is now taken before the spawn, so the spawn and the id record sit in one
block with no await between them. `spawn_source_handler` is synchronous, so
holding the lock across it costs a spawn and nothing else. The forwarding loop's
own first act is to take the same lock, so it waits for the block to end instead
of racing it.

No test covers this. Reaching it means cancelling the future at one specific
await with a dlopened container and a live broker in place, and both integration
routes were already rejected for this PR for reasons that still hold. What
enforces it is that the block contains no await, and an await added inside it
would reopen the window silently.
A start wrote `Running` onto `SourceDetails` and called
`increment_sources_running` by hand. The forwarding loop reports `Running`
through `update_status` as well, because the boot path spawns it without coming
through `start_connector`, and a stop decrements once. Two reports against one
decrement, so the gauge climbed with every restart.

Whether it climbed depended on which of the two reached the lock first. Reported
by hand first, the loop's `update_status` saw `Running` already set and did
nothing, so the ratchet needed the loop to win the race.

`start_connector` now reports through `update_status` like everything else. That
moves the gauge only on a real transition and clears `last_error` on the way.
The loop's own report becomes a no-op here and stays the only report on the boot
path.

Reported rather than deleted outright, so a connector that just started does not
answer `Stopped` to `GET /sources` until the loop takes the lock.

The new test pins what single ownership rests on: a second report of a status the
connector already holds does not move the gauge. The deletion itself is not
covered, because reaching `start_connector` needs a dlopened container.
The disarmed test asserted a counter that started at 0 and that nothing else
touched, so it held whether or not a guard had ever been built. It now closes an
armed guard through the same recorder first, so the recorder is proven able to
move before it is required not to.

None of the three covered the shape the guard exists for. Dropping or disarming
inline is something the compiler already guarantees; what the guard is for is a
`?` returning with it still armed. `start_with_fallible_step` mirrors that
control flow and takes an injected failure, so both halves are covered: the early
return closes the instance, and reaching `disarm` does not. The error arm in
`start_connector` calls `close()` directly now, so this is the net under a `?`
added inside the window later.

The three `extern "C" fn` stubs and their four statics are gone. A guard takes
its close as a closure, so each test owns its recorder and there was never
anything to share. That also removes the reason the stubs were split up, which
was a race `next_plugin_id` had already made unreachable.

Names lost their articles to match `given_serialized_batch_...` in the same
module, and the refused-close test now says what it proves rather than that it
does not panic, which the harness gives for free.

`SourceClose` names the close type because clippy's `type_complexity` asked for
a name once the test helper returned one.
The guard's doc comment argued against a cleanup branch that `init` still had,
so one of the two had to go. `init` now uses the guard as well, which retires
the duplicated close this PR deliberately left behind.

That needed `SourceConnector.container` to be an `Arc`, which costs nothing:
`main.rs` wrapped the container in one the moment `init` returned, so the
allocation moved earlier rather than being added. `Arc<Container<SourceApi>>`
still coerces for `get_plugin_version` and `init_source`.

In `init` the instance is not unreachable in the same way it is on the restart
path, since its id is in the map. What makes it unreachable is that `handle`
skips a plugin with `error` set, so nothing ever reaches the instance
`init_source` created. The error arm awaits `close()` for the same ordering the
restart path takes.

`close_failed_source` closed a plugin instance rather than a connector, and
`sink.rs` had the same body inline one word apart. Both now call
`close_plugin_instance`, which takes "source" or "sink". The two log lines are
unchanged, character for character.

Not taken from that finding: it also suggests the helper for the stop path at
`manager/source.rs:167`. That path drops the close result and logs "Closed"
regardless, which is a separate bug and out of this diff.
`SourceInstanceGuard::new(..);` as a bare statement builds an armed guard and
drops it on the spot, closing the instance it was meant to protect. `#[must_use]`
plus `warnings = "deny"` turns that into a build error rather than a puzzle.
Checked by writing one: `error: unused source::SourceInstanceGuard that must be
used`.

The call site in `start_connector` repeated the type's own second paragraph
almost word for word. It now says only what it adds, which is where arming
starts and where it stops.

The type's third paragraph argued against a cleanup branch that `init` no longer
has, so it says why a guard rather than why not a branch.
Nothing asserted `sources_running` with a source actually configured.
`stats_endpoint_returns_runtime_stats` reads it with none, so it checks 0.

This restarts the random source three times through the API and requires the
gauge back at 1 each time, then reads it once more after a settle window so a
late report is not missed.

What it does not do is isolate the fix it was written for. Reverting the report
through `update_status`, putting the direct write and the hand increment back,
leaves it passing 3 of 3, because taking the lock before the spawn already makes
`start_connector` win the race to that lock. Reverting that too, so the code is
what it was before either change, still leaves it passing 6 of 6:
`start_connector` acquires an uncontended mutex immediately, while the spawned
loop has to be scheduled and then walk a DashMap before it reaches the same
lock, so in practice it does not get there first.

So the double count was real in the code and latent in behaviour. This guards the
invariant rather than proving the defect, which is worth having anyway: it is the
only assertion on the gauge with a source running, and it covers the source
restart path that only the state and CDC tests touched.
Holding the spawn and the id record in one lock block was correct but rested on
a comment. Nothing stopped an await being added between them later, and that
reopens the cancellation window silently: the `SOURCE_SENDERS` entry and both
spawned tasks are left with no id naming them, and no guard can reach either.

`SourceDetails::record_started` takes the spawn as a closure and is deliberately
not `async`, so the two happen together and the compiler refuses an await
between them. Checked by adding one: `error[E0728]: await is only allowed inside
async functions and blocks`.

It also makes the step testable, which nothing in that region was. The test
asserts both halves land, because a stop reaches the instance through the id and
drains it through the tasks, so losing either leaves something behind. Dropping
the tasks in the recorder fails it.

The cancellation itself still has no test and cannot have one: it needs the
future dropped at one await, with a dlopened container live. The difference is
that the invariant no longer depends on anyone reading the comment.
@mlevkov
mlevkov force-pushed the runtime-source-start-cleanup branch from efb9ab0 to 8e3e929 Compare September 10, 2026 19:25
@mlevkov

mlevkov commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Fixed at 898c6a9a1, rebased onto master first. 215 unit tests and 9 connectors
integration tests green.

You were right, and the second half of your sentence is the part I had missed.
set_error moved the status out of Running without moving the gauge, so the
increment was never given back. That is why the count reached 2 rather than
merely being set late, and it also means the loop's later Stopped could not
correct it: by then the old status was Error, and neither branch of the gauge
rule fires.

SourceDetails::apply_status now owns the transition and the gauge move
together. update_status takes the lock and delegates, set_error delegates and
then sets its message, and start_connector calls it in the same hold as the id
record. The trailing unconditional report is gone.

The regression test puts the error between the two Running reports. Removing
the gauge move from set_error leaves the gauge reading 1 where it should read
0, and the test fails on that. You were right that the consecutive-Running
test cannot reach this: there the second report finds the status already
Running, so nothing crosses and no arithmetic runs.

One thing I did not change and one I could not test.

SinkManager::set_error has the same gauge asymmetry. It is outside this PR's
two files, and the sink consume loop never reports status at all, so the shape of
the fix differs. Happy to fold it in if you would rather it went here.

The ordering itself has no test. start_connector needs a dlopened container, so
what holds it is that the transition sits in the same lock hold as the id record
with no await between them. The second test pins set_error's observable
contract and its comment says plainly what it does not pin, since the message is
assigned after the transition and a mutant widening the clear survives.

The rebase moved every sha, so the ones in my earlier comment on this PR are
stale; I have refreshed them there.

The initial `Running` was reported after the startup lock was released, so it
raced the forwarding loop. The loop reports `Running` itself and can fail its
first batch immediately, and the delayed report then overwrote `Error`, cleared
`last_error`, and crossed into `Running` a second time. spetz measured the
result: `Running`, no `last_error`, and `sources_running = 2` for one instance.

Two halves, and the second is why the count reached 2. `set_error` moved the
status out of `Running` without moving the gauge, so the increment was never
given back, and the loop's later `Stopped` could not correct it either: by then
the old status was `Error` and neither branch of the gauge rule fires.

`SourceDetails::apply_status` now owns the transition and the gauge move
together. `update_status` takes the lock and delegates, `set_error` delegates and
then sets its message, and `start_connector` calls it in the same hold as the id
record, so nothing can land between the two. The trailing unconditional report is
gone.

The regression test puts the error between the two `Running` reports, which is
what the consecutive-`Running` test cannot reach: there the second report finds
the status already `Running`, so no crossing happens and no arithmetic runs.
Removing the gauge move from `set_error` leaves the gauge at 1 where it should
read 0, and the test fails on it.

The second test pins `set_error`'s observable contract, status `Error` with the
message intact, and its comment says what it does not pin: the message is
assigned after the transition, so widening the clear to include `Error` leaves it
green. Checked, and that mutant survives. The ordering is the mechanism, and no
unit test can observe an ordering inside a single lock hold.

Not changed here: `SinkManager::set_error` has the same gauge asymmetry, but the
sink path is outside this PR's two files and its consume loop never reports
status at all, so it needs its own change.
@mlevkov
mlevkov force-pushed the runtime-source-start-cleanup branch from 8e3e929 to 898c6a9 Compare September 10, 2026 19:26
@mlevkov

mlevkov commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

/ready

@github-actions github-actions Bot added S-waiting-on-review PR is waiting on a reviewer and removed S-waiting-on-author PR is waiting on author response labels Sep 10, 2026
@mlevkov

mlevkov commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

/request-review @spetz

@github-actions
github-actions Bot requested a review from spetz September 10, 2026 22:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-waiting-on-review PR is waiting on a reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants