1+ import contextlib
2+ import dataclasses
3+ import enum
14import os
5+ import threading
26import time
7+ from pathlib import Path
38from threading import Thread , Lock
4- from typing import List , NamedTuple , Dict , Iterable , Optional
9+ from typing import List , Dict , Iterable , Optional , Set
510
611from mcdreforged .api .rtext import *
12+ from mcdreforged .api .types import PluginType
713
814from auto_plugin_reloader import common
915from auto_plugin_reloader .common import tr , metadata , server_inst
1016
11- ModifyTimeMapping = Dict [str , int ]
1217PLUGIN_FILE_SUFFIXES = ['.py' , '.mcdr' , '.pyz' ]
1318
1419
15- class Difference (NamedTuple ):
16- file_path : str
17- reason : RTextBase
20+ @dataclasses .dataclass (frozen = True )
21+ class PluginFileInfo :
22+ plugin_id : Optional [str ]
23+ path : Path
24+ mtime : Optional [int ]
25+
26+
27+ @dataclasses .dataclass (frozen = True )
28+ class ScanResult :
29+ files : Dict [Path , PluginFileInfo ] = dataclasses .field (default_factory = dict )
30+ plugin_files : Dict [str , PluginFileInfo ] = dataclasses .field (default_factory = dict )
31+
32+
33+ class DiffReason (enum .Enum ):
34+ file_added = enum .auto ()
35+ file_modified = enum .auto ()
36+ file_deleted = enum .auto ()
37+
38+
39+ @dataclasses .dataclass (frozen = True )
40+ class Difference :
41+ file_path : Path
42+ reason : DiffReason
43+ plugin_id : Optional [str ]
1844
1945
2046class PluginReloader :
2147 def __init__ (self ):
22- self .__stop_flag = False
48+ self .__stop_flag = threading . Event ()
2349 self .last_detection_time = 0
2450 self .logger = server_inst .logger
25- self .scan_result : ModifyTimeMapping = self .scan_files ()
26- self .__thread = None # type : Optional[Thread]
51+ self .scan_result : ScanResult = self .__scan_files ()
52+ self .__thread : Optional [Thread ] = None
2753 self .__start_stop_lock = Lock ()
2854
2955 def on_config_changed (self ):
@@ -46,8 +72,8 @@ def is_running(self):
4672 def start (self ):
4773 with self .__start_stop_lock :
4874 if not self .is_running ():
49- self .__stop_flag = False
50- self .reset_detection_time ()
75+ self .__stop_flag . clear ()
76+ self .__reset_detection_time ()
5177 self .__thread = Thread (name = 'APR@{}' .format (self .unique_hex ), target = self .thread_loop )
5278 self .__thread .start ()
5379
@@ -58,14 +84,13 @@ def join_thread(self):
5884 thread .join ()
5985
6086 def stop (self ):
61- with self .__start_stop_lock :
62- self .__stop_flag = True
87+ self .__stop_flag .set ()
6388
6489 # ------------------
6590 # Implementation
6691 # ------------------
6792
68- def reset_detection_time (self ):
93+ def __reset_detection_time (self ):
6994 self .last_detection_time = time .time ()
7095
7196 def get_pretty_next_detection_time (self ) -> RTextBase :
@@ -75,59 +100,149 @@ def get_pretty_next_detection_time(self) -> RTextBase:
75100
76101 def thread_loop (self ):
77102 self .logger .info ('{} started' .format (self .unique_name ))
78- while not self .__stop_flag :
79- while not self .__stop_flag and time .time () - self .last_detection_time < common .config .detection_interval_sec :
80- time .sleep (0.005 )
81- if self .__stop_flag :
103+
104+ while True :
105+ next_detection_time = self .last_detection_time + common .config .detection_interval_sec
106+ time_to_wait = max (0.0 , next_detection_time - time .time ())
107+ if self .__stop_flag .wait (time_to_wait ):
82108 break
109+
83110 try :
84- self .check ()
85- except :
111+ self .__check_and_reload_once ()
112+ except Exception :
86113 self .logger .exception ('Error ticking {}' .format (metadata .name ))
87114 self .stop ()
88115 finally :
89- self .reset_detection_time ()
116+ self .__reset_detection_time ()
117+
90118 self .logger .info ('{} stopped' .format (self .unique_name ))
91119
92- @staticmethod
93- def scan_files () -> ModifyTimeMapping :
120+ def __scan_files (self ) -> ScanResult :
121+ self .logger .debug ('Scan file start' )
122+ start_time = time .time ()
123+
94124 def has_suffix (text : str , suffixes : Iterable [str ]) -> bool :
95125 return any (map (lambda sfx : text .endswith (sfx ), suffixes ))
96126
127+ def try_get_mtime (p : Path ) -> Optional [int ]:
128+ try :
129+ return p .stat ().st_mtime_ns
130+ except OSError :
131+ return None
132+
133+ result = ScanResult ()
134+ plugin_paths : Set [Path ] = set ()
135+ for pid in server_inst .get_plugin_list ():
136+ path = server_inst .get_plugin_file_path (pid )
137+ pfmt = server_inst .get_plugin_type (pid )
138+ if path is not None and pfmt in [PluginType .solo , PluginType .packed ]:
139+ path = Path (path ).absolute ()
140+ pfi = PluginFileInfo (plugin_id = pid , path = path , mtime = try_get_mtime (path ))
141+ result .files [path ] = pfi
142+ result .plugin_files [pid ] = pfi
143+ plugin_paths .add (path )
144+
97145 plugin_directories : List [str ] = server_inst .get_mcdr_config ()['plugin_directories' ]
98- ret = {}
99- for plugin_directory in plugin_directories :
100- if os .path .isdir (plugin_directory ):
101- for file_name in os .listdir (plugin_directory ):
102- file_path = os .path .join (plugin_directory , file_name )
103- if os .path .isfile (file_path ) and has_suffix (file_path , PLUGIN_FILE_SUFFIXES ) and file_name not in common .config .blacklist :
104- ret [file_path ] = os .stat (file_path ).st_mtime_ns
105- return ret
106-
107- def check (self ):
108- new_scan_result = self .scan_files ()
109- diffs : List [Difference ] = []
110- for file_path , current_mtime in new_scan_result .items ():
111- prev_mtime = self .scan_result .get (file_path , None )
112- if prev_mtime is None :
113- diffs .append (Difference (file_path , tr ('file_added' )))
114- elif prev_mtime != current_mtime :
115- diffs .append (Difference (file_path , tr ('file_modified' )))
116- for file_path in self .scan_result .keys ():
117- if file_path not in new_scan_result :
118- diffs .append (Difference (file_path , tr ('file_deleted' )))
119-
120- if len (diffs ) > 0 :
121- time .sleep (common .config .reload_delay_sec )
122- if self .__stop_flag : # just in case
123- return
124- self .logger .info (tr ('triggered.header' ))
125- for diff in diffs :
126- self .logger .info ('- {}: {}' .format (diff .file_path , diff .reason ))
127- self .logger .info (tr ('triggered.footer' ))
128- self .scan_result = self .scan_files ()
129-
130- holder = Thread (name = 'OperationHolder' , daemon = True , target = lambda : server_inst .schedule_task (server_inst .refresh_changed_plugins , block = True ))
131- holder .start ()
132- while holder .is_alive () and not self .__stop_flag : # stop waiting after being stopped
133- holder .join (timeout = 0.01 )
146+ for plugin_directory in map (Path , plugin_directories ):
147+ try :
148+ files = os .listdir (plugin_directory )
149+ except OSError as e :
150+ if not isinstance (e , FileNotFoundError ):
151+ self .logger .warning ('Skipping invalid plugin directory {!r}: {}' .format (plugin_directory , e ))
152+ continue
153+
154+ for file_name in files :
155+ path = (plugin_directory / file_name ).absolute ()
156+ if path in plugin_paths :
157+ continue
158+ try :
159+ if path .is_file () and has_suffix (file_name , PLUGIN_FILE_SUFFIXES ) and file_name not in common .config .blacklist :
160+ mtime = path .stat ().st_mtime_ns
161+ result .files [path ] = PluginFileInfo (plugin_id = None , path = path , mtime = mtime )
162+ except OSError as e :
163+ self .logger .warning ('Check file {!r} failed: {}' .format (plugin_directory , e ))
164+
165+ self .logger .debug ('Scan file end, cost {:.2f}s' .format (time .time () - start_time ))
166+ return result
167+
168+ @dataclasses .dataclass (frozen = True )
169+ class _CheckOnceResult :
170+ scan_result : ScanResult
171+ diffs : List [Difference ] = dataclasses .field (default_factory = list )
172+ to_load : List [Path ] = dataclasses .field (default_factory = list ) # paths
173+ to_reload : List [str ] = dataclasses .field (default_factory = list ) # plugin id
174+ to_unload : List [str ] = dataclasses .field (default_factory = list ) # plugin id
175+
176+ def __scan_and_check (self ) -> _CheckOnceResult :
177+ new_scan_result = self .__scan_files ()
178+ cor = self ._CheckOnceResult (scan_result = new_scan_result )
179+
180+ for pid , pfi in self .scan_result .plugin_files .items ():
181+ if (new_pfi := new_scan_result .plugin_files .get (pid )) is None :
182+ continue
183+ # the plugin exists in both 2 scans
184+ # reload if mtime changes, and mcdr also says that it has changes
185+ # if pid == 'advanced_calculator':
186+ # self.logger.info('OLD {}'.format(pfi))
187+ # self.logger.info('NEW {}'.format(new_pfi))
188+ if pfi .mtime != new_pfi .mtime and server_inst .is_plugin_file_changed (pid ) is True :
189+ if new_pfi .mtime is None :
190+ cor .diffs .append (Difference (pfi .path , DiffReason .file_deleted , pid ))
191+ cor .to_unload .append (pid )
192+ else :
193+ cor .diffs .append (Difference (pfi .path , DiffReason .file_modified , pid ))
194+ cor .to_reload .append (pid )
195+
196+ for path , new_pfi in new_scan_result .files .items ():
197+ # newly found, not loaded, plugin file
198+ if new_pfi .plugin_id is None and ((old_pfi := self .scan_result .files .get (path )) is None or new_pfi .mtime != old_pfi .mtime ):
199+ cor .diffs .append (Difference (path , DiffReason .file_added , None ))
200+ cor .to_load .append (path )
201+
202+ if (cd_len := len (cor .diffs )) > 0 :
203+ self .logger .info ('Found {} plugin file changes' .format (cd_len ))
204+ return cor
205+
206+ def __check_and_reload_once (self ):
207+ # first check
208+ check_result = self .__scan_and_check ()
209+ if len (check_result .diffs ) == 0 :
210+ self .scan_result = check_result .scan_result
211+ return
212+
213+ # wait for a while before the double check
214+ if self .__stop_flag .wait (common .config .reload_delay_sec ):
215+ return
216+
217+ # second check
218+ check_result = self .__scan_and_check ()
219+ self .scan_result = check_result .scan_result
220+ if len (check_result .diffs ) == 0 :
221+ self .logger .info ('no diff in second check' )
222+ return
223+
224+ self .logger .info (tr ('triggered.header' ))
225+ for diff in check_result .diffs :
226+ path = diff .file_path
227+ with contextlib .suppress (ValueError ):
228+ path = path .relative_to (Path ('.' ).absolute ())
229+ msg = '- {}: {}' .format (path , tr (diff .reason .name ))
230+ if diff .plugin_id :
231+ msg += ' (id={})' .format (diff .plugin_id )
232+ self .logger .info (msg )
233+ self .logger .info (tr ('triggered.footer' ))
234+
235+ def do_auto_reload ():
236+ try :
237+ server_inst .manipulate_plugins (
238+ load = [str (p ) for p in check_result .to_load ],
239+ reload = check_result .to_reload ,
240+ unload = check_result .to_unload ,
241+ )
242+ except Exception :
243+ self .logger .exception ('Auto plugin reload failed' )
244+
245+ holder = Thread (name = 'OperationHolder' , daemon = True , target = lambda : server_inst .schedule_task (do_auto_reload , block = True ))
246+ holder .start ()
247+ while holder .is_alive () and not self .__stop_flag .is_set (): # stop waiting after being stopped
248+ holder .join (timeout = 0.01 )
0 commit comments