Skip to content

Commit 8f94713

Browse files
authored
Fix PermissionError on Windows when building durable functions emulator image (#8807)
* dar windows temp issue * Fix Windows volume mount path for durable emulator container Use to_posix_path() to convert the emulator data directory path from Windows-style (D:\a\...) to POSIX-style (/d/a/...) before passing it as a Docker volume mount key. Docker inside WSL2 cannot parse Windows paths in volume specifications. * Fix Windows compatibility: remove emojis, normalize CRLF, fix unit test volume paths - Remove emoji characters from CLI output in durable_formatters.py and durable_callback_handler.py. Windows charmap codec cannot encode Unicode emojis, causing 'charmap codec can't encode character' errors. - Normalize CRLF to LF in integration test stderr comparisons for test_execution.py and test_callback.py (Windows outputs \r\n). - Update unit test volume path assertion to use to_posix_path() matching the source code change. - Update all unit tests referencing the removed emoji strings. * Fix test Popen encoding for Windows compatibility Use encoding='utf-8' in start_command_with_streaming Popen call. On Windows, text=True defaults to cp1252, which silently kills the output reader thread if any non-cp1252 bytes appear, causing wait_for_callback_id to time out. * Revert emoji removal, set PYTHONUTF8=1 in CI workflow instead Restore all emoji characters in durable CLI output. Instead of removing them, set PYTHONUTF8=1 in the integration-tests.yml workflow env to force Python to use UTF-8 on Windows. This makes click.echo handle emoji correctly without changing the user-facing output. * 1 * Add debug logging to test_tier1_callback to diagnose Windows failure * 1
1 parent 7c807bf commit 8f94713

7 files changed

Lines changed: 55 additions & 11 deletions

File tree

.github/workflows/integration-tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ env:
3939
NOSE_PARAMETERIZED_NO_WARN: 1
4040
BY_CANARY: true
4141
UV_PYTHON: python3.11
42+
PYTHONUTF8: 1
4243
CREDENTIAL_DISTRIBUTION_LAMBDA_ARN: ${{ secrets.CREDENTIAL_DISTRIBUTION_LAMBDA_ARN }}
4344
ACCOUNT_RESET_LAMBDA_ARN: ${{ secrets.ACCOUNT_RESET_LAMBDA_ARN }}
4445

samcli/local/docker/durable_functions_emulator_container.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@
1717
from samcli.lib.build.utils import _get_host_architecture
1818
from samcli.lib.clients.lambda_client import DurableFunctionsClient
1919
from samcli.lib.utils.tar import create_tarball
20-
from samcli.local.docker.utils import get_tar_filter_for_windows, get_validated_container_client, is_image_current
20+
from samcli.local.docker.utils import (
21+
get_tar_filter_for_windows,
22+
get_validated_container_client,
23+
is_image_current,
24+
to_posix_path,
25+
)
2126

2227
LOG = logging.getLogger(__name__)
2328

@@ -226,10 +231,14 @@ def _build_emulator_image(self):
226231
# Generate Dockerfile content
227232
dockerfile_content = self._generate_emulator_dockerfile(emulator_binary_name)
228233

229-
# Write Dockerfile to temp location and build image
230-
with NamedTemporaryFile(mode="w", suffix="_Dockerfile") as dockerfile:
234+
# Write Dockerfile to temp location and build image.
235+
# Use delete=False because on Windows, NamedTemporaryFile keeps the file
236+
# locked while open, preventing tarfile.add() from reading it.
237+
dockerfile = NamedTemporaryFile(mode="w", suffix="_Dockerfile", delete=False)
238+
try:
231239
dockerfile.write(dockerfile_content)
232240
dockerfile.flush()
241+
dockerfile.close()
233242

234243
# Prepare tar paths for build context
235244
tar_paths = {
@@ -248,6 +257,8 @@ def _build_emulator_image(self):
248257
return image_tag
249258
except Exception as e:
250259
raise ClickException(f"Failed to build emulator image: {e}")
260+
finally:
261+
os.unlink(dockerfile.name)
251262

252263
def _pull_image_if_needed(self):
253264
"""Pull the emulator image if it doesn't exist locally or is out of date."""
@@ -287,7 +298,7 @@ def start(self):
287298
os.makedirs(emulator_data_dir, exist_ok=True)
288299

289300
volumes = {
290-
emulator_data_dir: {"bind": "/tmp/.durable-executions-local", "mode": "rw"},
301+
to_posix_path(emulator_data_dir): {"bind": "/tmp/.durable-executions-local", "mode": "rw"},
291302
}
292303

293304
# Build image with emulator binary

tests/get_testing_resources.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def get_testing_credentials(skip_role_deletion=False):
4545
lambda_client = boto3.client(
4646
"lambda",
4747
config=Config(
48-
retries={"max_attempts": 0, "mode": "standard"},
48+
retries={"max_attempts": 5, "mode": "standard"},
4949
connect_timeout=LAMBDA_TIME_OUT + 60,
5050
read_timeout=LAMBDA_TIME_OUT + 60,
5151
),

tests/integration/durable_integ_base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,9 @@ def start_command_with_streaming(self, command_list, test_name, env=None, cwd=No
126126
Returns:
127127
tuple: (process, output_lines, thread) where output_lines is a list that gets populated as output arrives
128128
"""
129-
process = Popen(command_list, stdout=PIPE, stderr=STDOUT, stdin=PIPE, text=True, env=env, cwd=cwd)
129+
process = Popen(
130+
command_list, stdout=PIPE, stderr=STDOUT, stdin=PIPE, text=True, encoding="utf-8", env=env, cwd=cwd
131+
)
130132
output_lines = []
131133

132134
def log_output():

tests/integration/local/callback/test_callback.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Integration tests for sam local callback commands - edge cases only."""
22

3+
import logging
34
import re
45
from pathlib import Path
56

@@ -11,6 +12,8 @@
1112
from tests.integration.durable_function_examples import DurableFunctionExamples
1213
from tests.testing_utils import run_command
1314

15+
LOG = logging.getLogger(__name__)
16+
1417

1518
@pytest.mark.xdist_group(name="durable")
1619
class TestLocalCallback(DurableIntegBase, InvokeIntegBase):
@@ -41,31 +44,54 @@ def _do_callback_test(self, action, operation_name, callback_type, error_suffix)
4144
command_list = self.get_invoke_command_list(
4245
example.function_name, no_event=True, durable_execution_name=execution_name
4346
)
47+
LOG.info("Starting invoke command: %s", " ".join(command_list))
4448
process, output_lines, thread = self.start_command_with_streaming(
4549
command_list, f"test_callback_already_completed_{action}"
4650
)
4751

4852
# Wait for callback ID
53+
LOG.info("Waiting for callback ID from streaming output...")
4954
callback_id = self.wait_for_callback_id(output_lines)
50-
self.assertIsNotNone(callback_id, "Failed to get callback ID from output")
55+
LOG.info("Callback ID result: %s, output_lines collected: %d", callback_id, len(output_lines))
56+
if not callback_id:
57+
LOG.error("Failed to get callback ID. Output lines so far: %s", output_lines)
58+
# Also capture process state
59+
LOG.error("Process poll() = %s", process.poll())
60+
self.assertIsNotNone(callback_id, f"Failed to get callback ID from output. Lines: {output_lines}")
5161

5262
# Send first callback to complete the execution
5363
succeed_command = self.get_callback_command_list("succeed", callback_id, result="test result")
64+
LOG.info("Sending first callback: %s", " ".join(succeed_command))
5465
result = run_command(succeed_command)
55-
self.assertEqual(result.process.returncode, 0)
66+
stdout_str = result.stdout.decode("utf-8") if isinstance(result.stdout, bytes) else result.stdout
67+
stderr_str = result.stderr.decode("utf-8") if isinstance(result.stderr, bytes) else result.stderr
68+
LOG.info(
69+
"First callback result: returncode=%d, stdout=%s, stderr=%s",
70+
result.process.returncode,
71+
stdout_str,
72+
stderr_str,
73+
)
74+
self.assertEqual(
75+
result.process.returncode, 0, f"First callback failed: stdout={stdout_str}, stderr={stderr_str}"
76+
)
5677

5778
# Wait for process to complete and close file handles
79+
LOG.info("Waiting for invoke process to complete...")
5880
process.wait(timeout=30)
5981
thread.join(timeout=5)
6082
if process.stdin:
6183
process.stdin.close()
6284
if process.stdout:
6385
process.stdout.close()
86+
LOG.info("Invoke process completed with returncode=%d", process.returncode)
6487

6588
# Try to send another callback (should fail)
6689
second_command = self.get_callback_command_list(action, callback_id)
90+
LOG.info("Sending second callback (should fail): %s", " ".join(second_command))
6791
result = run_command(second_command)
6892
stderr_str = result.stderr.decode("utf-8") if isinstance(result.stderr, bytes) else result.stderr
93+
stderr_str = stderr_str.replace("\r\n", "\n")
94+
LOG.info("Second callback result: returncode=%d, stderr=%s", result.process.returncode, stderr_str)
6995

7096
self.assertNotEqual(result.process.returncode, 0)
7197
expected_pattern = f"Error: An error occurred \\(404\\) when calling the {operation_name} operation: Failed to process callback {callback_type}: Callback .+ {error_suffix}"

tests/integration/local/execution/test_execution.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def _do_execution_nonexistent_test(self, command, operation_name):
3838

3939
result = run_command(command_list)
4040
stderr_str = result.stderr.decode("utf-8") if isinstance(result.stderr, bytes) else result.stderr
41+
stderr_str = stderr_str.replace("\r\n", "\n")
4142

4243
self.assertNotEqual(result.process.returncode, 0)
4344
expected_message = f"Error: An error occurred (404) when calling the {operation_name} operation: Execution {nonexistent_arn} not found\n"
@@ -63,6 +64,7 @@ def test_execution_stop_already_completed(self):
6364
stop_command = [self.cmd, "local", "execution", "stop", execution_arn]
6465
result = run_command(stop_command)
6566
stderr_str = result.stderr.decode("utf-8") if isinstance(result.stderr, bytes) else result.stderr
67+
stderr_str = stderr_str.replace("\r\n", "\n")
6668

6769
self.assertNotEqual(result.process.returncode, 0)
6870
expected_message = f"Error: An error occurred (409) when calling the StopDurableExecution operation: Execution {execution_arn} is already completed\n"

tests/unit/local/docker/test_durable_functions_emulator_container.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from click import ClickException
1313

1414
from samcli.local.docker.durable_functions_emulator_container import DurableFunctionsEmulatorContainer
15+
from samcli.local.docker.utils import to_posix_path
1516

1617

1718
class TestDurableFunctionsEmulatorContainer(TestCase):
@@ -302,9 +303,10 @@ def test_create_container(
302303
# Verify volumes
303304
volumes = call_args.kwargs["volumes"]
304305
expected_data_dir = os.path.join(test_dir, ".durable-executions-local")
305-
self.assertIn(expected_data_dir, volumes)
306-
self.assertEqual(volumes[expected_data_dir]["bind"], "/tmp/.durable-executions-local")
307-
self.assertEqual(volumes[expected_data_dir]["mode"], "rw")
306+
expected_volume_key = to_posix_path(expected_data_dir)
307+
self.assertIn(expected_volume_key, volumes)
308+
self.assertEqual(volumes[expected_volume_key]["bind"], "/tmp/.durable-executions-local")
309+
self.assertEqual(volumes[expected_volume_key]["mode"], "rw")
308310

309311
# Verify networking
310312
self.assertEqual(call_args.kwargs["extra_hosts"], {"host.docker.internal": "host-gateway"})

0 commit comments

Comments
 (0)