99import os
1010import sys
1111import timeit
12+ import concurrent .futures
13+ from concurrent .futures import ThreadPoolExecutor
1214
1315from knack .cli import CLI
1416from knack .commands import CLICommandsLoader
3234ALWAYS_LOADED_MODULES = []
3335# Extensions that will always be loaded if installed. They don't expose commands but hook into CLI core.
3436ALWAYS_LOADED_EXTENSIONS = ['azext_ai_examples' , 'azext_next' ]
37+ # Timeout (in seconds) for loading a single module. Acts as a safety valve to prevent indefinite hangs
38+ MODULE_LOAD_TIMEOUT_SECONDS = 30
39+ # Maximum number of worker threads for parallel module loading.
40+ MAX_WORKER_THREAD_COUNT = 4
3541
3642
3743def _configure_knack ():
@@ -195,6 +201,17 @@ def _configure_style(self):
195201 format_styled_text .theme = theme
196202
197203
204+ class ModuleLoadResult : # pylint: disable=too-few-public-methods
205+ def __init__ (self , module_name , command_table , group_table , elapsed_time , error = None , traceback_str = None , command_loader = None ):
206+ self .module_name = module_name
207+ self .command_table = command_table
208+ self .group_table = group_table
209+ self .elapsed_time = elapsed_time
210+ self .error = error
211+ self .traceback_str = traceback_str
212+ self .command_loader = command_loader
213+
214+
198215class MainCommandsLoader (CLICommandsLoader ):
199216
200217 # Format string for pretty-print the command module table
@@ -221,11 +238,11 @@ def load_command_table(self, args):
221238 import pkgutil
222239 import traceback
223240 from azure .cli .core .commands import (
224- _load_module_command_loader , _load_extension_command_loader , BLOCKED_MODS , ExtensionCommandSource )
241+ _load_extension_command_loader , ExtensionCommandSource )
225242 from azure .cli .core .extension import (
226243 get_extensions , get_extension_path , get_extension_modname )
227244 from azure .cli .core .breaking_change import (
228- import_core_breaking_changes , import_module_breaking_changes , import_extension_breaking_changes )
245+ import_core_breaking_changes , import_extension_breaking_changes )
229246
230247 def _update_command_table_from_modules (args , command_modules = None ):
231248 """Loads command tables from modules and merge into the main command table.
@@ -253,38 +270,10 @@ def _update_command_table_from_modules(args, command_modules=None):
253270 except ImportError as e :
254271 logger .warning (e )
255272
256- count = 0
257- cumulative_elapsed_time = 0
258- cumulative_group_count = 0
259- cumulative_command_count = 0
260- logger .debug ("Loading command modules:" )
261- logger .debug (self .header_mod )
273+ results = self ._load_modules (args , command_modules )
262274
263- for mod in [m for m in command_modules if m not in BLOCKED_MODS ]:
264- try :
265- start_time = timeit .default_timer ()
266- module_command_table , module_group_table = _load_module_command_loader (self , args , mod )
267- import_module_breaking_changes (mod )
268- for cmd in module_command_table .values ():
269- cmd .command_source = mod
270- self .command_table .update (module_command_table )
271- self .command_group_table .update (module_group_table )
272-
273- elapsed_time = timeit .default_timer () - start_time
274- logger .debug (self .item_format_string , mod , elapsed_time ,
275- len (module_group_table ), len (module_command_table ))
276- count += 1
277- cumulative_elapsed_time += elapsed_time
278- cumulative_group_count += len (module_group_table )
279- cumulative_command_count += len (module_command_table )
280- except Exception as ex : # pylint: disable=broad-except
281- # Changing this error message requires updating CI script that checks for failed
282- # module loading.
283- from azure .cli .core import telemetry
284- logger .error ("Error loading command module '%s': %s" , mod , ex )
285- telemetry .set_exception (exception = ex , fault_type = 'module-load-error-' + mod ,
286- summary = 'Error loading module: {}' .format (mod ))
287- logger .debug (traceback .format_exc ())
275+ count , cumulative_elapsed_time , cumulative_group_count , cumulative_command_count = \
276+ self ._process_results_with_timing (results )
288277 # Summary line
289278 logger .debug (self .item_format_string ,
290279 "Total ({})" .format (count ), cumulative_elapsed_time ,
@@ -358,7 +347,7 @@ def _filter_modname(extensions):
358347 # from an extension requires this map to be up-to-date.
359348 # self._mod_to_ext_map[ext_mod] = ext_name
360349 start_time = timeit .default_timer ()
361- extension_command_table , extension_group_table = \
350+ extension_command_table , extension_group_table , _ = \
362351 _load_extension_command_loader (self , args , ext_mod )
363352 import_extension_breaking_changes (ext_mod )
364353
@@ -561,6 +550,99 @@ def load_arguments(self, command=None):
561550 self .extra_argument_registry .update (loader .extra_argument_registry )
562551 loader ._update_command_definitions () # pylint: disable=protected-access
563552
553+ def _load_modules (self , args , command_modules ):
554+ """Load command modules using ThreadPoolExecutor with timeout protection."""
555+ from azure .cli .core .commands import BLOCKED_MODS
556+
557+ results = []
558+ with ThreadPoolExecutor (max_workers = MAX_WORKER_THREAD_COUNT ) as executor :
559+ future_to_module = {executor .submit (self ._load_single_module , mod , args ): mod
560+ for mod in command_modules if mod not in BLOCKED_MODS }
561+
562+ for future in concurrent .futures .as_completed (future_to_module ):
563+ try :
564+ result = future .result (timeout = MODULE_LOAD_TIMEOUT_SECONDS )
565+ results .append (result )
566+ except concurrent .futures .TimeoutError :
567+ mod = future_to_module [future ]
568+ logger .warning ("Module '%s' load timeout after %s seconds" , mod , MODULE_LOAD_TIMEOUT_SECONDS )
569+ results .append (ModuleLoadResult (mod , {}, {}, 0 ,
570+ Exception (f"Module '{ mod } ' load timeout" )))
571+ except (ImportError , AttributeError , TypeError , ValueError ) as ex :
572+ mod = future_to_module [future ]
573+ logger .warning ("Module '%s' load failed: %s" , mod , ex )
574+ results .append (ModuleLoadResult (mod , {}, {}, 0 , ex ))
575+ except Exception as ex : # pylint: disable=broad-exception-caught
576+ mod = future_to_module [future ]
577+ logger .warning ("Module '%s' load failed with unexpected exception: %s" , mod , ex )
578+ results .append (ModuleLoadResult (mod , {}, {}, 0 , ex ))
579+
580+ return results
581+
582+ def _load_single_module (self , mod , args ):
583+ from azure .cli .core .breaking_change import import_module_breaking_changes
584+ from azure .cli .core .commands import _load_module_command_loader
585+ import traceback
586+ try :
587+ start_time = timeit .default_timer ()
588+ module_command_table , module_group_table , command_loader = _load_module_command_loader (self , args , mod )
589+ import_module_breaking_changes (mod )
590+ elapsed_time = timeit .default_timer () - start_time
591+ return ModuleLoadResult (mod , module_command_table , module_group_table , elapsed_time , command_loader = command_loader )
592+ except Exception as ex : # pylint: disable=broad-except
593+ tb_str = traceback .format_exc ()
594+ return ModuleLoadResult (mod , {}, {}, 0 , ex , tb_str )
595+
596+ def _handle_module_load_error (self , result ):
597+ """Handle errors that occurred during module loading."""
598+ from azure .cli .core import telemetry
599+
600+ logger .error ("Error loading command module '%s': %s" , result .module_name , result .error )
601+ telemetry .set_exception (exception = result .error ,
602+ fault_type = 'module-load-error-' + result .module_name ,
603+ summary = 'Error loading module: {}' .format (result .module_name ))
604+ if result .traceback_str :
605+ logger .debug (result .traceback_str )
606+
607+ def _process_successful_load (self , result ):
608+ """Process successfully loaded module results."""
609+ if result .command_loader :
610+ self .loaders .append (result .command_loader )
611+
612+ for cmd in result .command_table :
613+ self .cmd_to_loader_map [cmd ] = [result .command_loader ]
614+
615+ for cmd in result .command_table .values ():
616+ cmd .command_source = result .module_name
617+
618+ self .command_table .update (result .command_table )
619+ self .command_group_table .update (result .group_table )
620+
621+ logger .debug (self .item_format_string , result .module_name , result .elapsed_time ,
622+ len (result .group_table ), len (result .command_table ))
623+
624+ def _process_results_with_timing (self , results ):
625+ """Process pre-loaded module results with timing and progress reporting."""
626+ logger .debug ("Loading command modules:" )
627+ logger .debug (self .header_mod )
628+
629+ count = 0
630+ cumulative_elapsed_time = 0
631+ cumulative_group_count = 0
632+ cumulative_command_count = 0
633+
634+ for result in results :
635+ if result .error :
636+ self ._handle_module_load_error (result )
637+ else :
638+ self ._process_successful_load (result )
639+ count += 1
640+ cumulative_elapsed_time += result .elapsed_time
641+ cumulative_group_count += len (result .group_table )
642+ cumulative_command_count += len (result .command_table )
643+
644+ return count , cumulative_elapsed_time , cumulative_group_count , cumulative_command_count
645+
564646
565647class CommandIndex :
566648
0 commit comments