Skip to content

Commit c3140ba

Browse files
author
LeoLan Digital
committed
fix: persist daemon watches across client disconnects
Signed-off-by: LeoLan Digital <deine-email@example.com>
1 parent 46ae198 commit c3140ba

2 files changed

Lines changed: 150 additions & 17 deletions

File tree

‎src/daemon/application.c‎

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,11 @@ struct cbm_daemon_application_job {
149149
cbm_daemon_application_job_t *next;
150150
};
151151

152-
/* A watcher-triggered physical job is owned by the exact live sessions that
153-
* currently subscribe to its project/root watch. The callback waiting for the
154-
* job is only a storage waiter; it is deliberately not an ownership
155-
* subscription, so the worker is cancelled when the last matching session
156-
* disconnects even while unrelated daemon sessions remain alive. */
152+
/* A watcher-triggered physical job is normally owned by the exact live
153+
* sessions subscribed to its project/root watch. In permanent daemon mode the
154+
* watcher itself additionally owns the physical job so background indexing can
155+
* continue while no client session is connected. The callback remains a
156+
* storage waiter in both modes and releases daemon ownership on completion. */
157157
struct cbm_daemon_application_watch_job_subscription {
158158
cbm_daemon_application_session_t *session;
159159
cbm_daemon_application_job_t *job;
@@ -424,7 +424,19 @@ static void application_release_session_watch_locked(cbm_daemon_application_sess
424424
if (watch->subscribers > 0) {
425425
watch->subscribers--;
426426
}
427-
if (watch->subscribers == 0) {
427+
428+
/*
429+
* A permanent daemon owns the physical project watch independently of
430+
* individual client sessions. Session disconnect releases only that
431+
* session's logical subscription; the watcher remains available for
432+
* background change detection while the daemon generation is alive.
433+
*
434+
* Non-permanent applications retain the historical session-scoped
435+
* lifecycle and remove the physical watch after the final subscriber.
436+
* Project deletion/pruning and daemon shutdown remove persistent watches
437+
* through their dedicated lifecycle paths.
438+
*/
439+
if (!session->application->permanent && watch->subscribers == 0) {
428440
application_remove_watch_locked(session->application, watch);
429441
}
430442
}
@@ -3375,27 +3387,42 @@ static int application_background_index(cbm_daemon_application_t *application,
33753387
cbm_mutex_lock(&application->mutex);
33763388
cbm_daemon_application_watch_t *watch =
33773389
require_live_watch ? application_find_watch_locked(application, project_name) : NULL;
3378-
bool watch_live = !require_live_watch ||
3379-
(watch && watch->subscribers > 0 && strcmp(watch->root, canonical_root) == 0);
3390+
bool watch_live =
3391+
!require_live_watch ||
3392+
(watch && strcmp(watch->root, canonical_root) == 0 &&
3393+
(watch->subscribers > 0 || application->permanent));
33803394
size_t watch_owner_count = 0;
33813395
bool watch_subscriptions_ok = true;
3396+
bool watcher_owns_job = false;
33823397
cbm_daemon_application_job_t *job =
33833398
watch_live ? application_job_subscribe_locked(application, project_key, canonical_root,
33843399
args, &subscribe_status)
33853400
: NULL;
33863401
if (job && require_live_watch) {
33873402
watch_subscriptions_ok = application_watch_job_subscribe_sessions_locked(
33883403
application, watch, job, &watch_owner_count);
3389-
if (watch_subscriptions_ok && watch_owner_count > 0) {
3390-
job->watcher_waiters++;
3391-
} else if (!watch_subscriptions_ok) {
3404+
3405+
if (!watch_subscriptions_ok) {
33923406
subscribe_status = APPLICATION_JOB_SUBSCRIBE_ALLOCATION_FAILED;
3393-
}
3394-
/* application_job_subscribe_locked() lends the caller one ordinary
3395-
* subscriber. A watcher callback is only a storage waiter: exact live
3396-
* session subscriptions above own the physical work. */
3397-
application_job_unsubscribe_locked(job);
3398-
if (!watch_subscriptions_ok || watch_owner_count == 0) {
3407+
application_job_unsubscribe_locked(job);
3408+
job = NULL;
3409+
} else if (application->permanent) {
3410+
/*
3411+
* The ordinary subscription acquired above is retained as daemon
3412+
* ownership. This allows the physical watcher to run indexing even
3413+
* while no client session currently owns the project watch.
3414+
*/
3415+
watcher_owns_job = true;
3416+
job->watcher_waiters++;
3417+
} else if (watch_owner_count > 0) {
3418+
/*
3419+
* Historical non-permanent behavior: exact live sessions own the
3420+
* physical work; the watcher callback itself is only a waiter.
3421+
*/
3422+
job->watcher_waiters++;
3423+
application_job_unsubscribe_locked(job);
3424+
} else {
3425+
application_job_unsubscribe_locked(job);
33993426
job = NULL;
34003427
}
34013428
}
@@ -3428,6 +3455,10 @@ static int application_background_index(cbm_daemon_application_t *application,
34283455
if (job->watcher_waiters > 0) {
34293456
job->watcher_waiters--;
34303457
}
3458+
if (watcher_owns_job) {
3459+
application_job_unsubscribe_locked(job);
3460+
watcher_owns_job = false;
3461+
}
34313462
} else {
34323463
application_job_unsubscribe_locked(job);
34333464
}

‎tests/test_daemon_application.c‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3621,6 +3621,107 @@ TEST(daemon_application_watcher_job_follows_exact_live_watch_owners) {
36213621
PASS();
36223622
}
36233623

3624+
3625+
TEST(daemon_application_permanent_watch_survives_disconnect_and_indexes_without_session) {
3626+
app_watch_race_fixture_t fixture;
3627+
bool fixture_ready = app_watch_race_fixture_init(&fixture, 47);
3628+
3629+
if (fixture_ready) {
3630+
cbm_daemon_application_set_permanent(fixture.application, true);
3631+
}
3632+
3633+
int watch_before_disconnect =
3634+
fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
3635+
3636+
if (fixture.session) {
3637+
fixture.callbacks.session_cancel(fixture.callbacks.context, fixture.session);
3638+
}
3639+
3640+
int watch_after_cancel =
3641+
fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
3642+
3643+
if (fixture.session) {
3644+
fixture.callbacks.session_close(fixture.callbacks.context, fixture.session);
3645+
fixture.session = NULL;
3646+
}
3647+
3648+
int watch_after_close =
3649+
fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
3650+
3651+
/*
3652+
* No client session remains at this point. A permanent daemon-owned
3653+
* physical watch must nevertheless be allowed to launch its background
3654+
* indexing job.
3655+
*/
3656+
app_watcher_index_thread_t request = {
3657+
.application = fixture.application,
3658+
.project = fixture.project,
3659+
.root = fixture.root,
3660+
.pause_before_subscribe = false,
3661+
.result = -1,
3662+
};
3663+
atomic_init(&request.ready, false);
3664+
atomic_init(&request.proceed, true);
3665+
atomic_init(&request.done, false);
3666+
3667+
cbm_thread_t thread;
3668+
bool thread_started =
3669+
fixture_ready &&
3670+
watch_after_close == 1 &&
3671+
cbm_thread_create(&thread, 0, app_watcher_index_thread, &request) == 0;
3672+
3673+
bool worker_started =
3674+
thread_started && app_wait_for_atomic_int(&fixture.fake.starts, 1);
3675+
3676+
int watch_during_job =
3677+
fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
3678+
3679+
/*
3680+
* The fake worker deliberately waits until the test releases it.
3681+
* Let the daemon-owned watcher job finish normally.
3682+
*/
3683+
if (worker_started) {
3684+
atomic_store(&fixture.fake.allow_completion, true);
3685+
}
3686+
3687+
bool request_done =
3688+
thread_started && app_wait_for_atomic_bool(&request.done, true);
3689+
3690+
bool thread_joined =
3691+
request_done && cbm_thread_join(&thread) == 0;
3692+
3693+
int watch_after_job =
3694+
fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
3695+
3696+
int destroys_before_cleanup =
3697+
fixture_ready ? atomic_load(&fixture.fake.destroys) : -1;
3698+
3699+
bool cleaned = app_watch_race_fixture_finish(&fixture);
3700+
3701+
ASSERT_TRUE(fixture_ready);
3702+
ASSERT_EQ(watch_before_disconnect, 1);
3703+
3704+
/* Permanent mode: disconnect must not unwatch the project. */
3705+
ASSERT_EQ(watch_after_cancel, 1);
3706+
ASSERT_EQ(watch_after_close, 1);
3707+
3708+
/* No session exists, yet the watcher still owns background indexing. */
3709+
ASSERT_TRUE(thread_started);
3710+
ASSERT_TRUE(worker_started);
3711+
ASSERT_EQ(watch_during_job, 1);
3712+
3713+
ASSERT_TRUE(request_done);
3714+
ASSERT_TRUE(thread_joined);
3715+
ASSERT_EQ(request.result, 0);
3716+
ASSERT_EQ(destroys_before_cleanup, 1);
3717+
3718+
/* Completing the background job must not consume the persistent watch. */
3719+
ASSERT_EQ(watch_after_job, 1);
3720+
3721+
ASSERT_TRUE(cleaned);
3722+
PASS();
3723+
}
3724+
36243725
TEST(daemon_application_late_watcher_session_owns_active_watcher_job) {
36253726
app_watch_race_fixture_t fixture;
36263727
bool fixture_ready = app_watch_race_fixture_init(&fixture, 45);
@@ -5116,6 +5217,7 @@ SUITE(daemon_application) {
51165217
RUN_TEST(daemon_application_stale_watcher_callback_is_rejected_at_job_admission);
51175218
RUN_TEST(daemon_application_final_cancel_drains_admitted_watcher_job);
51185219
RUN_TEST(daemon_application_watcher_job_follows_exact_live_watch_owners);
5220+
RUN_TEST(daemon_application_permanent_watch_survives_disconnect_and_indexes_without_session);
51195221
RUN_TEST(daemon_application_late_watcher_session_owns_active_watcher_job);
51205222
RUN_TEST(daemon_application_serializes_adr_mutation_with_index_job);
51215223
RUN_TEST(daemon_application_reserved_mutation_delays_worker_start);

0 commit comments

Comments
 (0)