Skip to content

Commit 3969e99

Browse files
committed
[Container App] Fix 'az containerapp exec' crash on non-ASCII (emoji) terminal output
1 parent f7f6f67 commit 3969e99

2 files changed

Lines changed: 101 additions & 2 deletions

File tree

src/azure-cli/azure/cli/command_modules/containerapp/_ssh_utils.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,34 @@ def recv(self, *args, **kwargs):
102102
return self._socket.recv(*args, **kwargs)
103103

104104

105+
def _write_to_terminal(text):
106+
# The terminal's encoding (e.g. cp1252 on Windows) may not be able to
107+
# represent every character the container emits (emoji, non-Latin scripts).
108+
# On a UTF-8 terminal the fast path prints natively; only when the console
109+
# codec cannot encode a character do we fall back to a non-failing policy so
110+
# the rest of the output is still shown instead of crashing the exec session.
111+
try:
112+
print(text, end="", flush=True)
113+
return
114+
except UnicodeEncodeError:
115+
pass
116+
117+
encoding = getattr(sys.stdout, "encoding", None) or SSH_DEFAULT_ENCODING
118+
encoded = text.encode(encoding, errors="backslashreplace")
119+
buffer = getattr(sys.stdout, "buffer", None)
120+
if buffer is not None:
121+
buffer.write(encoded)
122+
buffer.flush()
123+
else:
124+
# Stream has no binary buffer (already wrapped / replaced) -> round-trip
125+
# through the same codec so the write itself cannot raise.
126+
print(encoded.decode(encoding, errors="backslashreplace"), end="", flush=True)
127+
128+
105129
def _decode_and_output_to_terminal(connection: WebSocketConnection, response, encodings):
106130
for i, encoding in enumerate(encodings):
107131
try:
108-
print(response[2:].decode(encoding), end="", flush=True)
132+
decoded = response[2:].decode(encoding)
109133
break
110134
except UnicodeDecodeError as e:
111135
if i == len(encodings) - 1: # ran out of encodings to try
@@ -114,7 +138,11 @@ def _decode_and_output_to_terminal(connection: WebSocketConnection, response, en
114138
logger.info("Cluster Control Byte: %s", response[1])
115139
logger.info("Hexdump: %s", response[2:].hex())
116140
raise CLIInternalError("Failed to decode server data") from e
117-
logger.info("Failed to encode with encoding %s", encoding)
141+
logger.info("Failed to decode with encoding %s", encoding)
142+
else:
143+
return # empty encodings list: nothing to decode or print
144+
145+
_write_to_terminal(decoded)
118146

119147

120148
def read_ssh(connection: WebSocketConnection, response_encodings):
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# --------------------------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for license information.
4+
# --------------------------------------------------------------------------------------------
5+
6+
import io
7+
import unittest
8+
from unittest import mock
9+
10+
from azure.cli.command_modules.containerapp import _ssh_utils
11+
from azure.cli.command_modules.containerapp._ssh_utils import (
12+
_write_to_terminal,
13+
_decode_and_output_to_terminal,
14+
)
15+
16+
# Lobster emoji (U+1F99E): valid UTF-8, but has no representation in cp1252.
17+
EMOJI = "\U0001f99e"
18+
19+
20+
class _NarrowStdout:
21+
"""Mimics a console whose text codec (e.g. cp1252) cannot encode every
22+
character. Its ``write`` re-encodes like a real console, so emoji raises
23+
``UnicodeEncodeError``; bytes written via the fallback land in ``buffer``."""
24+
25+
def __init__(self, encoding="cp1252"):
26+
self.encoding = encoding
27+
self.buffer = io.BytesIO()
28+
29+
def write(self, text):
30+
text.encode(self.encoding) # raises UnicodeEncodeError on unencodable chars
31+
32+
def flush(self):
33+
pass
34+
35+
36+
class SshUtilsTerminalOutputTest(unittest.TestCase):
37+
def test_write_falls_back_when_char_not_encodable(self):
38+
text = f"hello {EMOJI} world"
39+
stdout = _NarrowStdout(encoding="cp1252")
40+
41+
with mock.patch.object(_ssh_utils.sys, "stdout", stdout):
42+
_write_to_terminal(text) # must not raise UnicodeEncodeError
43+
44+
self.assertEqual(stdout.buffer.getvalue(),
45+
text.encode("cp1252", errors="backslashreplace"))
46+
47+
def test_write_uses_print_fast_path_when_encodable(self):
48+
stdout = _NarrowStdout(encoding="utf-8")
49+
50+
with mock.patch.object(_ssh_utils.sys, "stdout", stdout):
51+
_write_to_terminal("plain ascii")
52+
53+
# Encodable text goes through print(); the fallback buffer stays empty.
54+
self.assertEqual(stdout.buffer.getvalue(), b"")
55+
56+
def test_decode_and_output_handles_emoji_without_disconnecting(self):
57+
payload = f"openclaw {EMOJI} v1".encode("utf-8")
58+
response = bytes([_ssh_utils.SSH_PROXY_FORWARD,
59+
_ssh_utils.SSH_CLUSTER_STDOUT]) + payload
60+
connection = mock.MagicMock()
61+
stdout = _NarrowStdout(encoding="cp1252")
62+
63+
with mock.patch.object(_ssh_utils.sys, "stdout", stdout):
64+
_decode_and_output_to_terminal(connection, response, ["utf-8", "latin_1"])
65+
66+
connection.disconnect.assert_not_called()
67+
self.assertIn(b"openclaw", stdout.buffer.getvalue())
68+
69+
70+
if __name__ == "__main__":
71+
unittest.main()

0 commit comments

Comments
 (0)