@@ -494,4 +494,105 @@ TEST_F_CORO(evil_link_test, test_evil_link_start_stop) {
494494 << " Link should be removed after reconciler loop" ;
495495}
496496
497+ namespace {
498+
499+ // / A task that refreshes metadata from the source cluster, mirroring the
500+ // / first step of source_topic_syncer::run_impl. request_metadata_update()
501+ // / takes no abort source: its waits (metadata update lock, seed reconnect
502+ // / backoff) answer only to the kafka::client::cluster's internal abort
503+ // / source.
504+ class metadata_refresh_task : public task {
505+ public:
506+ static constexpr auto task_name = " metadata_refresh_task" ;
507+ explicit metadata_refresh_task (link* link)
508+ : task(link, 100ms, task_name) {}
509+
510+ bool should_start_impl (ss::shard_id, ::model::node_id) const override {
511+ return true ;
512+ }
513+ bool should_stop_impl (ss::shard_id, ::model::node_id) const override {
514+ return false ;
515+ }
516+ void update_config (const model::metadata&) override {}
517+ model::enabled_t is_enabled () const final { return model::enabled_t ::yes; }
518+
519+ ss::future<state_transition> run_impl (ss::abort_source&) override {
520+ try {
521+ co_await get_link ()
522+ ->get_cluster_connection ()
523+ .request_metadata_update ();
524+ } catch (const std::exception& e) {
525+ co_return state_transition{
526+ .desired_state = model::task_state::link_unavailable,
527+ .reason = ssx::sformat (
528+ " Failed to update metadata: {}" , e.what ())};
529+ }
530+ co_return state_transition{
531+ .desired_state = model::task_state::active, .reason = " ok" };
532+ }
533+ };
534+
535+ class metadata_refresh_task_factory : public task_factory {
536+ public:
537+ std::string_view created_task_name () const noexcept override {
538+ return metadata_refresh_task::task_name;
539+ }
540+ std::unique_ptr<task> create_task (link* link) override {
541+ return std::make_unique<metadata_refresh_task>(link);
542+ }
543+ };
544+
545+ } // namespace
546+
547+ // Regression test for deadlock between `link::stop()` and `cluster::stop()`.
548+ TEST_F_CORO (
549+ link_test_manager_started,
550+ stop_completes_with_task_wedged_in_source_cluster) {
551+ auto link_uuid = model::uuid_t (::uuid_t::create ());
552+ auto md = ss::make_lw_shared<const model::metadata>(model::metadata{
553+ .name = model::name_t (" wedged_link" ),
554+ .uuid = link_uuid,
555+ .connection = model::connection_config{}});
556+
557+ // A source cluster connection with no seed brokers: start() succeeds
558+ // vacuously (nothing to connect to).
559+ auto wedged_link = std::make_unique<link>(
560+ ::model::node_id (0 ),
561+ model::id_t(1 ),
562+ _manager.get(),
563+ 1s,
564+ md,
565+ std::make_unique<kafka::client::cluster>(
566+ kafka::client::connection_configuration{
567+ .client_id = " wedge-test" ,
568+ }),
569+ std::make_unique<default_config_provider>(),
570+ std::make_unique<data_src_factory>(),
571+ std::make_unique<data_sink_factory>());
572+ co_await wedged_link->start ();
573+
574+ // Now point it at a seed broker that refuses connections, with a large
575+ // connection_timeout so the reconnect loop parks in long backoff
576+ // sleeps — the production wedge for a link whose source cluster is
577+ // unreachable.
578+ wedged_link->get_cluster_connection ().update_configuration (
579+ kafka::client::connection_configuration{
580+ .initial_brokers = {net::unresolved_address{" 127.0.0.1" , 1 }},
581+ .client_id = " wedge-test" ,
582+ .connection_timeout = 10min,
583+ });
584+
585+ metadata_refresh_task_factory tf;
586+ auto res = co_await wedged_link->register_task (&tf);
587+ ASSERT_TRUE_CORO (res.has_value ());
588+
589+ // Let the task run into the wedge: connect to the dead seed, fail,
590+ // and park in the reconnect backoff.
591+ co_await ss::sleep (1s);
592+
593+ // Stopping the link must complete even with the task fiber wedged in
594+ // the source cluster's reconnect backoff.
595+ co_await wedged_link->stop ();
596+ }
597+
497598} // namespace cluster_link::tests
0 commit comments