fortrangoingonforty/fgof-watch / fdbbd25

Browse files

Add watch debounce

Authored by espadonne
SHA
fdbbd253c81d265d6c6d4feec44064852f9221a8
Parents
857b583
Tree
3a3ff09

3 changed files

StatusFile+-
M src/fgof_watch.f90 285 0
M src/fgof_watch_types.f90 3 0
A test/test_watch_debounce.f90 103 0
src/fgof_watch.f90modified
@@ -50,10 +50,14 @@ contains
5050
 
5151
     if (.not. session%active) then
5252
       allocate(session%entries(0))
53
+      allocate(session%pending_events(0))
54
+      allocate(session%pending_remaining(0))
5355
       return
5456
     end if
5557
 
5658
     call collect_snapshot(root, session%options, session%entries)
59
+    allocate(session%pending_events(0))
60
+    allocate(session%pending_remaining(0))
5761
   end subroutine init_watch
5862
 
5963
   function poll_watch(session) result(events)
@@ -68,6 +72,9 @@ contains
6872
 
6973
     call collect_snapshot(session%root, session%options, current_entries)
7074
     events = diff_snapshots(session%entries, current_entries, session%options)
75
+    if (session%options%debounce_polls > 0) then
76
+      events = debounce_event_batch(session, events)
77
+    end if
7178
     call move_alloc(current_entries, session%entries)
7279
   end function poll_watch
7380
 
@@ -82,6 +89,14 @@ contains
8289
       deallocate(session%entries)
8390
     end if
8491
 
92
+    if (allocated(session%pending_events)) then
93
+      deallocate(session%pending_events)
94
+    end if
95
+
96
+    if (allocated(session%pending_remaining)) then
97
+      deallocate(session%pending_remaining)
98
+    end if
99
+
85100
     session%options = watch_options()
86101
     session%active = .false.
87102
   end subroutine reset_watch
@@ -362,6 +377,263 @@ contains
362377
     end do
363378
   end function build_event_batch
364379
 
380
+  function debounce_event_batch(session, raw_events) result(events)
381
+    type(watch_session), intent(inout) :: session
382
+    type(watch_event), intent(in) :: raw_events(:)
383
+    type(watch_event), allocatable :: events(:)
384
+    logical, allocatable :: touched(:)
385
+    integer :: i
386
+    integer :: index
387
+
388
+    if (.not. allocated(session%pending_events)) allocate(session%pending_events(0))
389
+    if (.not. allocated(session%pending_remaining)) allocate(session%pending_remaining(0))
390
+
391
+    allocate(touched(size(session%pending_events)))
392
+    touched = .false.
393
+
394
+    do i = 1, size(raw_events)
395
+      index = find_related_pending_event(session%pending_events, raw_events(i))
396
+      if (index > 0) then
397
+        call merge_pending_event(session, index, raw_events(i))
398
+        if (index <= size(session%pending_events)) then
399
+          touched = resize_logical_flags(touched, size(session%pending_events))
400
+          touched(index) = .true.
401
+        end if
402
+      else
403
+        call append_pending_event(session, raw_events(i), session%options%debounce_polls)
404
+        touched = resize_logical_flags(touched, size(session%pending_events))
405
+        touched(size(touched)) = .true.
406
+      end if
407
+    end do
408
+
409
+    do i = 1, size(session%pending_remaining)
410
+      if (touched(i)) cycle
411
+      session%pending_remaining(i) = session%pending_remaining(i) - 1
412
+    end do
413
+
414
+    call emit_ready_events(session, events)
415
+  end function debounce_event_batch
416
+
417
+  subroutine merge_pending_event(session, index, incoming)
418
+    type(watch_session), intent(inout) :: session
419
+    integer, intent(in) :: index
420
+    type(watch_event), intent(in) :: incoming
421
+    type(watch_event) :: merged
422
+    logical :: drop_pending
423
+
424
+    call merge_event_pair(session%pending_events(index), incoming, merged, drop_pending)
425
+    if (drop_pending) then
426
+      call remove_pending_event(session, index)
427
+      return
428
+    end if
429
+
430
+    session%pending_events(index) = merged
431
+    session%pending_remaining(index) = session%options%debounce_polls
432
+  end subroutine merge_pending_event
433
+
434
+  subroutine merge_event_pair(existing, incoming, merged, drop_pending)
435
+    type(watch_event), intent(in) :: existing
436
+    type(watch_event), intent(in) :: incoming
437
+    type(watch_event), intent(out) :: merged
438
+    logical, intent(out) :: drop_pending
439
+
440
+    drop_pending = .false.
441
+    merged = incoming
442
+
443
+    select case (existing%kind)
444
+    case (FGOF_WATCH_EVT_CREATED)
445
+      select case (incoming%kind)
446
+      case (FGOF_WATCH_EVT_CREATED)
447
+        merged = incoming
448
+      case (FGOF_WATCH_EVT_MODIFIED)
449
+        merged = existing
450
+      case (FGOF_WATCH_EVT_REMOVED)
451
+        if (incoming%path == existing%path) then
452
+          drop_pending = .true.
453
+        else
454
+          merged = incoming
455
+        end if
456
+      case (FGOF_WATCH_EVT_MOVED)
457
+        if (incoming%previous_path == existing%path) then
458
+          merged = existing
459
+          merged%path = incoming%path
460
+        else
461
+          merged = incoming
462
+        end if
463
+      end select
464
+
465
+    case (FGOF_WATCH_EVT_MODIFIED)
466
+      select case (incoming%kind)
467
+      case (FGOF_WATCH_EVT_CREATED)
468
+        merged = incoming
469
+      case (FGOF_WATCH_EVT_MODIFIED)
470
+        merged = incoming
471
+      case (FGOF_WATCH_EVT_REMOVED)
472
+        merged = incoming
473
+      case (FGOF_WATCH_EVT_MOVED)
474
+        merged = incoming
475
+      end select
476
+
477
+    case (FGOF_WATCH_EVT_REMOVED)
478
+      select case (incoming%kind)
479
+      case (FGOF_WATCH_EVT_CREATED)
480
+        if (incoming%path == existing%path) then
481
+          merged%kind = FGOF_WATCH_EVT_MODIFIED
482
+          merged%path = incoming%path
483
+          merged%previous_path = ""
484
+          merged%is_directory = incoming%is_directory
485
+        else
486
+          merged = incoming
487
+        end if
488
+      case default
489
+        merged = incoming
490
+      end select
491
+
492
+    case (FGOF_WATCH_EVT_MOVED)
493
+      select case (incoming%kind)
494
+      case (FGOF_WATCH_EVT_MODIFIED)
495
+        if (incoming%path == existing%path) then
496
+          merged = existing
497
+        else
498
+          merged = incoming
499
+        end if
500
+      case (FGOF_WATCH_EVT_REMOVED)
501
+        if (incoming%path == existing%path) then
502
+          merged = incoming
503
+        else
504
+          merged = incoming
505
+        end if
506
+      case (FGOF_WATCH_EVT_MOVED)
507
+        if (incoming%previous_path == existing%path) then
508
+          merged = existing
509
+          merged%path = incoming%path
510
+        else
511
+          merged = incoming
512
+        end if
513
+      case (FGOF_WATCH_EVT_CREATED)
514
+        merged = incoming
515
+      end select
516
+    end select
517
+  end subroutine merge_event_pair
518
+
519
+  subroutine emit_ready_events(session, events)
520
+    type(watch_session), intent(inout) :: session
521
+    type(watch_event), allocatable, intent(out) :: events(:)
522
+    type(watch_event), allocatable :: ready(:)
523
+    integer :: i
524
+
525
+    allocate(ready(0))
526
+    i = 1
527
+    do while (i <= size(session%pending_events))
528
+      if (session%pending_remaining(i) > 0) then
529
+        i = i + 1
530
+        cycle
531
+      end if
532
+
533
+      call append_event_object(ready, session%pending_events(i))
534
+      call remove_pending_event(session, i)
535
+    end do
536
+
537
+    call move_alloc(ready, events)
538
+  end subroutine emit_ready_events
539
+
540
+  subroutine append_pending_event(session, event, remaining)
541
+    type(watch_session), intent(inout) :: session
542
+    type(watch_event), intent(in) :: event
543
+    integer, intent(in) :: remaining
544
+    type(watch_event), allocatable :: grown_events(:)
545
+    integer, allocatable :: grown_remaining(:)
546
+    integer :: n
547
+
548
+    n = size(session%pending_events)
549
+    allocate(grown_events(n + 1))
550
+    allocate(grown_remaining(n + 1))
551
+
552
+    if (n > 0) then
553
+      grown_events(1:n) = session%pending_events
554
+      grown_remaining(1:n) = session%pending_remaining
555
+    end if
556
+
557
+    grown_events(n + 1) = event
558
+    grown_remaining(n + 1) = remaining
559
+
560
+    call move_alloc(grown_events, session%pending_events)
561
+    call move_alloc(grown_remaining, session%pending_remaining)
562
+  end subroutine append_pending_event
563
+
564
+  subroutine remove_pending_event(session, index)
565
+    type(watch_session), intent(inout) :: session
566
+    integer, intent(in) :: index
567
+    type(watch_event), allocatable :: kept_events(:)
568
+    integer, allocatable :: kept_remaining(:)
569
+    integer :: n
570
+
571
+    n = size(session%pending_events)
572
+    if (index < 1 .or. index > n) return
573
+
574
+    allocate(kept_events(n - 1))
575
+    allocate(kept_remaining(n - 1))
576
+
577
+    if (index > 1) then
578
+      kept_events(1:index - 1) = session%pending_events(1:index - 1)
579
+      kept_remaining(1:index - 1) = session%pending_remaining(1:index - 1)
580
+    end if
581
+
582
+    if (index < n) then
583
+      kept_events(index:n - 1) = session%pending_events(index + 1:n)
584
+      kept_remaining(index:n - 1) = session%pending_remaining(index + 1:n)
585
+    end if
586
+
587
+    call move_alloc(kept_events, session%pending_events)
588
+    call move_alloc(kept_remaining, session%pending_remaining)
589
+  end subroutine remove_pending_event
590
+
591
+  integer function find_related_pending_event(pending_events, incoming) result(index_found)
592
+    type(watch_event), intent(in) :: pending_events(:)
593
+    type(watch_event), intent(in) :: incoming
594
+    integer :: i
595
+
596
+    index_found = 0
597
+    do i = 1, size(pending_events)
598
+      if (events_related(pending_events(i), incoming)) then
599
+        index_found = i
600
+        return
601
+      end if
602
+    end do
603
+  end function find_related_pending_event
604
+
605
+  logical function events_related(left, right) result(related)
606
+    type(watch_event), intent(in) :: left
607
+    type(watch_event), intent(in) :: right
608
+
609
+    related = .false.
610
+    if (same_nonempty_text(left%path, right%path)) related = .true.
611
+    if (same_nonempty_text(left%path, right%previous_path)) related = .true.
612
+    if (same_nonempty_text(left%previous_path, right%path)) related = .true.
613
+    if (same_nonempty_text(left%previous_path, right%previous_path)) related = .true.
614
+  end function events_related
615
+
616
+  logical function same_nonempty_text(left, right) result(matches)
617
+    character(len=*), intent(in) :: left
618
+    character(len=*), intent(in) :: right
619
+
620
+    matches = .false.
621
+    if (len(left) == 0 .or. len(right) == 0) return
622
+    matches = (left == right)
623
+  end function same_nonempty_text
624
+
625
+  function resize_logical_flags(flags, new_size) result(resized)
626
+    logical, intent(in) :: flags(:)
627
+    integer, intent(in) :: new_size
628
+    logical, allocatable :: resized(:)
629
+    integer :: copy_count
630
+
631
+    allocate(resized(new_size))
632
+    resized = .false.
633
+    copy_count = min(size(flags), new_size)
634
+    if (copy_count > 0) resized(1:copy_count) = flags(1:copy_count)
635
+  end function resize_logical_flags
636
+
365637
   logical function entry_changed(previous_entry, current_entry) result(changed)
366638
     type(watch_entry), intent(in) :: previous_entry
367639
     type(watch_entry), intent(in) :: current_entry
@@ -524,6 +796,19 @@ contains
524796
     call move_alloc(grown, events)
525797
   end subroutine append_event
526798
 
799
+  subroutine append_event_object(events, event)
800
+    type(watch_event), allocatable, intent(inout) :: events(:)
801
+    type(watch_event), intent(in) :: event
802
+    type(watch_event), allocatable :: grown(:)
803
+    integer :: n
804
+
805
+    n = size(events)
806
+    allocate(grown(n + 1))
807
+    if (n > 0) grown(1:n) = events
808
+    grown(n + 1) = event
809
+    call move_alloc(grown, events)
810
+  end subroutine append_event_object
811
+
527812
   subroutine sort_entries(entries)
528813
     type(watch_entry), intent(inout) :: entries(:)
529814
     type(watch_entry) :: temp
src/fgof_watch_types.f90modified
@@ -23,6 +23,7 @@ module fgof_watch_types
2323
 
2424
   type :: watch_options
2525
     integer :: poll_interval_ms = 250
26
+    integer :: debounce_polls = 0
2627
     logical :: recursive = .true.
2728
     logical :: ignore_hidden = .false.
2829
     logical :: emit_directory_events = .true.
@@ -43,5 +44,7 @@ module fgof_watch_types
4344
     type(watch_options) :: options
4445
     logical :: active = .false.
4546
     type(watch_entry), allocatable :: entries(:)
47
+    type(watch_event), allocatable :: pending_events(:)
48
+    integer, allocatable :: pending_remaining(:)
4649
   end type watch_session
4750
 end module fgof_watch_types
test/test_watch_debounce.f90added
@@ -0,0 +1,103 @@
1
+program test_watch_debounce
2
+  use fgof_watch, only : init_watch, poll_watch, reset_watch
3
+  use fgof_watch_types, only : &
4
+    FGOF_WATCH_EVT_CREATED, &
5
+    FGOF_WATCH_EVT_MODIFIED, &
6
+    watch_event, &
7
+    watch_options, &
8
+    watch_session
9
+  use watch_test_support, only : &
10
+    append_text, &
11
+    ensure_clean_dir, &
12
+    expect_no_events, &
13
+    expect_single_event, &
14
+    remove_path, &
15
+    remove_tree, &
16
+    write_text
17
+  implicit none
18
+
19
+  call test_created_event_debounces()
20
+  call test_modify_burst_coalesces()
21
+  call test_create_remove_cancels()
22
+
23
+contains
24
+
25
+  subroutine test_created_event_debounces()
26
+    character(len=*), parameter :: root = "build/watch-tests-debounce-created"
27
+    character(len=*), parameter :: file_path = "build/watch-tests-debounce-created/file.txt"
28
+    type(watch_event), allocatable :: events(:)
29
+    type(watch_options) :: options
30
+    type(watch_session) :: session
31
+
32
+    call ensure_clean_dir(root)
33
+
34
+    options = watch_options(debounce_polls=1)
35
+    call init_watch(session, root, options)
36
+
37
+    call write_text(file_path, "alpha")
38
+    events = poll_watch(session)
39
+    call expect_no_events(events, "created event should wait for a quiet poll")
40
+
41
+    events = poll_watch(session)
42
+    call expect_single_event(events, FGOF_WATCH_EVT_CREATED, file_path, "", .false., "created event should emit after debounce settles")
43
+
44
+    call reset_watch(session)
45
+    call remove_tree(root)
46
+  end subroutine test_created_event_debounces
47
+
48
+  subroutine test_modify_burst_coalesces()
49
+    character(len=*), parameter :: root = "build/watch-tests-debounce-modify"
50
+    character(len=*), parameter :: file_path = "build/watch-tests-debounce-modify/file.txt"
51
+    type(watch_event), allocatable :: events(:)
52
+    type(watch_options) :: options
53
+    type(watch_session) :: session
54
+
55
+    call ensure_clean_dir(root)
56
+    call write_text(file_path, "seed")
57
+
58
+    options = watch_options(debounce_polls=1)
59
+    call init_watch(session, root, options)
60
+
61
+    call append_text(file_path, "-a")
62
+    events = poll_watch(session)
63
+    call expect_no_events(events, "first modify should debounce")
64
+
65
+    call append_text(file_path, "-b")
66
+    events = poll_watch(session)
67
+    call expect_no_events(events, "modify burst should coalesce while debounce is active")
68
+
69
+    events = poll_watch(session)
70
+    call expect_single_event(events, FGOF_WATCH_EVT_MODIFIED, file_path, "", .false., "modify burst should collapse into one event")
71
+
72
+    call reset_watch(session)
73
+    call remove_tree(root)
74
+  end subroutine test_modify_burst_coalesces
75
+
76
+  subroutine test_create_remove_cancels()
77
+    character(len=*), parameter :: root = "build/watch-tests-debounce-cancel"
78
+    character(len=*), parameter :: file_path = "build/watch-tests-debounce-cancel/file.txt"
79
+    type(watch_event), allocatable :: events(:)
80
+    type(watch_options) :: options
81
+    type(watch_session) :: session
82
+
83
+    call ensure_clean_dir(root)
84
+
85
+    options = watch_options(debounce_polls=1)
86
+    call init_watch(session, root, options)
87
+
88
+    call write_text(file_path, "alpha")
89
+    events = poll_watch(session)
90
+    call expect_no_events(events, "created event should enter debounce queue")
91
+
92
+    call remove_path(file_path)
93
+    events = poll_watch(session)
94
+    call expect_no_events(events, "create then remove inside debounce window should cancel")
95
+
96
+    events = poll_watch(session)
97
+    call expect_no_events(events, "cancelled debounce sequence should stay quiet")
98
+
99
+    call reset_watch(session)
100
+    call remove_tree(root)
101
+  end subroutine test_create_remove_cancels
102
+
103
+end program test_watch_debounce