From 62e93722cccb50a45a0f138b49f189f453e06205 Mon Sep 17 00:00:00 2001 From: Kiro Date: Fri, 20 Mar 2026 18:21:40 +0000 Subject: [PATCH] feat: add --no-reload flag to disable hot reload for local commands Fixes #8782. Adds a --no-reload flag to sam local start-api and sam local start-lambda commands that disables file watching and hot reload functionality. This addresses severe performance issues on Windows where Microsoft Defender causes multi-minute delays during recursive directory observation. Changes: - Added --no-reload CLI option to warm_containers_common_options in options.py - Wired flag through start_api/cli.py and start_lambda/cli.py - Propagated flag through InvokeContext to WarmLambdaRuntime and RefreshableSamFunctionProvider - WarmLambdaRuntime skips observer.watch/start when --no-reload is set - RefreshableSamFunctionProvider skips template file watching when --no-reload is set - Added unit tests for both components --- .../local/cli_common/invoke_context.py | 7 ++- samcli/commands/local/cli_common/options.py | 7 +++ samcli/commands/local/start_api/cli.py | 4 ++ samcli/commands/local/start_lambda/cli.py | 4 ++ samcli/lib/providers/sam_function_provider.py | 6 +- samcli/local/lambdafn/runtime.py | 10 +++- .../local/lib/test_sam_function_provider.py | 45 +++++++++++++++ tests/unit/local/lambdafn/test_runtime.py | 57 +++++++++++++++++++ 8 files changed, 135 insertions(+), 5 deletions(-) diff --git a/samcli/commands/local/cli_common/invoke_context.py b/samcli/commands/local/cli_common/invoke_context.py index fbc7e7d060f..8d0e83116c7 100644 --- a/samcli/commands/local/cli_common/invoke_context.py +++ b/samcli/commands/local/cli_common/invoke_context.py @@ -102,6 +102,7 @@ def __init__( mount_symlinks: Optional[bool] = False, no_mem_limit: Optional[bool] = False, function_logical_ids: Optional[Tuple[str, ...]] = None, + no_reload: bool = False, ) -> None: """ Initialize the context @@ -220,6 +221,7 @@ def __init__( self._mount_symlinks: Optional[bool] = mount_symlinks self._no_mem_limit = no_mem_limit + self._no_reload = no_reload # Note(xinhol): despite self._function_provider and self._stacks are initialized as None # they will be assigned with a non-None value in __enter__() and @@ -256,7 +258,6 @@ def __enter__(self) -> "InvokeContext": ContainersMode.WARM: [self._stacks, self._parameter_overrides, self._global_parameter_overrides], ContainersMode.COLD: [self._stacks], } - # don't resolve the code URI immediately if we passed in docker vol by passing True for use_raw_codeuri # this way at the end the code URI will get resolved against the basedir option if self._docker_volume_basedir: @@ -267,6 +268,9 @@ def __enter__(self) -> "InvokeContext": if self._function_logical_ids: _function_providers_kwargs["function_logical_ids"] = self._function_logical_ids + if self._containers_mode == ContainersMode.WARM: + _function_providers_kwargs["no_reload"] = self._no_reload + self._function_provider = _function_providers_class[self._containers_mode]( *_function_providers_args[self._containers_mode], **_function_providers_kwargs ) @@ -573,6 +577,7 @@ def lambda_runtime(self) -> LambdaRuntime: image_builder, mount_symlinks=self._mount_symlinks, no_mem_limit=self._no_mem_limit, + no_reload=self._no_reload, ), ContainersMode.COLD: LambdaRuntime( self._container_manager, diff --git a/samcli/commands/local/cli_common/options.py b/samcli/commands/local/cli_common/options.py index a1544814214..77cd833ce1a 100644 --- a/samcli/commands/local/cli_common/options.py +++ b/samcli/commands/local/cli_common/options.py @@ -264,6 +264,13 @@ def warm_containers_common_options(f): type=click.STRING, multiple=False, ), + click.option( + "--no-reload", + is_flag=True, + default=False, + help="Disable automatic reloading of Lambda functions when source code or templates change. " + "Useful on Windows where file watchers can cause significant latency with security software.", + ), ] # Reverse the list to maintain ordering of options in help text printed with --help diff --git a/samcli/commands/local/start_api/cli.py b/samcli/commands/local/start_api/cli.py index be5574acf82..4568ac92150 100644 --- a/samcli/commands/local/start_api/cli.py +++ b/samcli/commands/local/start_api/cli.py @@ -136,6 +136,7 @@ def cli( warm_containers, shutdown, debug_function, + no_reload, container_host, container_host_interface, add_host, @@ -174,6 +175,7 @@ def cli( warm_containers, shutdown, debug_function, + no_reload, container_host, container_host_interface, add_host, @@ -207,6 +209,7 @@ def do_cli( # pylint: disable=R0914 warm_containers, shutdown, debug_function, + no_reload, container_host, container_host_interface, add_host, @@ -261,6 +264,7 @@ def do_cli( # pylint: disable=R0914 invoke_images=processed_invoke_images, add_host=add_host, no_mem_limit=no_mem_limit, + no_reload=no_reload, ) as invoke_context: ssl_context = (ssl_cert_file, ssl_key_file) if ssl_cert_file else None service = LocalApiService( diff --git a/samcli/commands/local/start_lambda/cli.py b/samcli/commands/local/start_lambda/cli.py index 16da0b57001..b20be6a31e8 100644 --- a/samcli/commands/local/start_lambda/cli.py +++ b/samcli/commands/local/start_lambda/cli.py @@ -100,6 +100,7 @@ def cli( warm_containers, shutdown, debug_function, + no_reload, container_host, container_host_interface, add_host, @@ -135,6 +136,7 @@ def cli( warm_containers, shutdown, debug_function, + no_reload, container_host, container_host_interface, add_host, @@ -165,6 +167,7 @@ def do_cli( # pylint: disable=R0914 warm_containers, shutdown, debug_function, + no_reload, container_host, container_host_interface, add_host, @@ -218,6 +221,7 @@ def do_cli( # pylint: disable=R0914 invoke_images=processed_invoke_images, function_logical_ids=function_logical_ids, no_mem_limit=no_mem_limit, + no_reload=no_reload, ) as invoke_context: service = LocalLambdaService(lambda_invoke_context=invoke_context, port=port, host=host) service.start() diff --git a/samcli/lib/providers/sam_function_provider.py b/samcli/lib/providers/sam_function_provider.py index 732afb893ff..5bd8749097d 100644 --- a/samcli/lib/providers/sam_function_provider.py +++ b/samcli/lib/providers/sam_function_provider.py @@ -894,6 +894,7 @@ def __init__( use_raw_codeuri: bool = False, ignore_code_extraction_warnings: bool = False, function_logical_ids: Optional[Tuple[str, ...]] = None, + no_reload: bool = False, ) -> None: """ Initialize the class with SAM template data. The SAM template passed to this provider is assumed @@ -933,7 +934,10 @@ def __init__( self.is_changed = False self._observer = FileObserver(self._set_templates_changed) self._observer.start() - self._watch_stack_templates(stacks) + if no_reload: + LOG.debug("Hot reload is disabled (--no-reload). Skipping template file observer.") + else: + self._watch_stack_templates(stacks) @property def stacks(self) -> List[Stack]: diff --git a/samcli/local/lambdafn/runtime.py b/samcli/local/lambdafn/runtime.py index 40e66d55e00..687bfa8cf9e 100644 --- a/samcli/local/lambdafn/runtime.py +++ b/samcli/local/lambdafn/runtime.py @@ -512,7 +512,7 @@ class WarmLambdaRuntime(LambdaRuntime): warm containers life cycle. """ - def __init__(self, container_manager, image_builder, observer=None, mount_symlinks=False, no_mem_limit=False): + def __init__(self, container_manager, image_builder, observer=None, mount_symlinks=False, no_mem_limit=False, no_reload=False): """ Initialize the Local Lambda runtime @@ -528,6 +528,7 @@ def __init__(self, container_manager, image_builder, observer=None, mount_symlin self._function_configs = {} self._containers = {} self._container_lock = threading.Lock() # Thread-safe container creation + self._no_reload = no_reload self._observer = observer if observer else LambdaFunctionObserver(self._on_code_change) @@ -596,8 +597,11 @@ def create( return container # Create new container - self._observer.watch(function_config) - self._observer.start() + if self._no_reload: + LOG.debug("Hot reload is disabled (--no-reload). Skipping file observer for '%s'", function_path) + else: + self._observer.watch(function_config) + self._observer.start() container = super().create( function_config, effective_debug_context, container_host, container_host_interface, extra_hosts diff --git a/tests/unit/commands/local/lib/test_sam_function_provider.py b/tests/unit/commands/local/lib/test_sam_function_provider.py index c62c1e7d860..8b869568349 100644 --- a/tests/unit/commands/local/lib/test_sam_function_provider.py +++ b/tests/unit/commands/local/lib/test_sam_function_provider.py @@ -2615,6 +2615,51 @@ def test_provider_stop_will_stop_all_observers(self, get_template_mock, extract_ self.file_observer.stop.assert_called_once() +class TestRefreshableSamFunctionProvider_no_reload(TestCase): + """Tests for --no-reload flag in RefreshableSamFunctionProvider""" + + def setUp(self): + self.parameter_overrides = {} + self.global_parameter_overrides = {} + self.file_observer = Mock() + self.file_observer.start = Mock() + self.file_observer.watch = Mock() + + @patch("samcli.lib.providers.sam_function_provider.FileObserver") + @patch.object(SamFunctionProvider, "_extract_functions") + @patch("samcli.lib.providers.provider.SamBaseProvider.get_template") + def test_no_reload_skips_template_watch(self, get_template_mock, extract_mock, FileObserverMock): + """When no_reload=True, template files should NOT be watched""" + FileObserverMock.return_value = self.file_observer + extract_mock.return_value = {} + template = {"Resources": {}} + get_template_mock.return_value = template + stack = make_root_stack(template, self.parameter_overrides) + + RefreshableSamFunctionProvider( + [stack], self.parameter_overrides, self.global_parameter_overrides, no_reload=True + ) + + self.file_observer.watch.assert_not_called() + + @patch("samcli.lib.providers.sam_function_provider.FileObserver") + @patch.object(SamFunctionProvider, "_extract_functions") + @patch("samcli.lib.providers.provider.SamBaseProvider.get_template") + def test_reload_enabled_watches_templates(self, get_template_mock, extract_mock, FileObserverMock): + """When no_reload=False (default), template files should be watched""" + FileObserverMock.return_value = self.file_observer + extract_mock.return_value = {} + template = {"Resources": {}} + get_template_mock.return_value = template + stack = make_root_stack(template, self.parameter_overrides) + + RefreshableSamFunctionProvider( + [stack], self.parameter_overrides, self.global_parameter_overrides, no_reload=False + ) + + self.file_observer.watch.assert_called() + + class TestSamFunctionProvider_search_layer(TestCase): root_stack_template = { "Resources": { diff --git a/tests/unit/local/lambdafn/test_runtime.py b/tests/unit/local/lambdafn/test_runtime.py index c17a271301b..9f6a0a0f536 100644 --- a/tests/unit/local/lambdafn/test_runtime.py +++ b/tests/unit/local/lambdafn/test_runtime.py @@ -2346,3 +2346,60 @@ def test_clean_runtime_containers_stops_emulator_container(self, log_mock): emulator_container.stop.assert_called_once() log_mock.debug.assert_called_with("Stopping durable functions emulator container") self.assertIsNone(runtime._durable_execution_emulator_container) + + +class TestWarmLambdaRuntime_no_reload(TestCase): + """Tests for --no-reload flag in WarmLambdaRuntime""" + + def setUp(self): + self.manager_mock = Mock() + self.lambda_image_mock = Mock() + self.observer_mock = Mock() + + @patch("samcli.local.lambdafn.runtime.LambdaContainer") + def test_no_reload_skips_observer_watch_and_start(self, LambdaContainerMock): + """When no_reload=True, observer.watch and observer.start should NOT be called""" + container = Mock() + LambdaContainerMock.return_value = container + + runtime = WarmLambdaRuntime( + self.manager_mock, self.lambda_image_mock, observer=self.observer_mock, no_reload=True + ) + runtime._get_code_dir = Mock(return_value="/some/code/dir") + + func_config = Mock() + func_config.full_path = "MyFunction" + func_config.packagetype = "Zip" + func_config.durable_config = None + func_config.env_vars.resolve.return_value = {} + func_config.layers = [] + func_config.runtime_management_config = None + + runtime.create(func_config) + + self.observer_mock.watch.assert_not_called() + self.observer_mock.start.assert_not_called() + + @patch("samcli.local.lambdafn.runtime.LambdaContainer") + def test_reload_enabled_calls_observer_watch_and_start(self, LambdaContainerMock): + """When no_reload=False (default), observer.watch and observer.start should be called""" + container = Mock() + LambdaContainerMock.return_value = container + + runtime = WarmLambdaRuntime( + self.manager_mock, self.lambda_image_mock, observer=self.observer_mock, no_reload=False + ) + runtime._get_code_dir = Mock(return_value="/some/code/dir") + + func_config = Mock() + func_config.full_path = "MyFunction" + func_config.packagetype = "Zip" + func_config.durable_config = None + func_config.env_vars.resolve.return_value = {} + func_config.layers = [] + func_config.runtime_management_config = None + + runtime.create(func_config) + + self.observer_mock.watch.assert_called_once_with(func_config) + self.observer_mock.start.assert_called_once()