snowflake-sandbox-python 0.2.1a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- snowflake/cli_sandbox/__init__.py +13 -0
- snowflake/cli_sandbox/_adapter.py +170 -0
- snowflake/cli_sandbox/_common.py +77 -0
- snowflake/cli_sandbox/_egress_flags.py +121 -0
- snowflake/cli_sandbox/_get_command.py +109 -0
- snowflake/cli_sandbox/_run_command.py +1091 -0
- snowflake/cli_sandbox/_shell_command.py +666 -0
- snowflake/cli_sandbox/_upload_plan.py +187 -0
- snowflake/cli_sandbox/commands.py +556 -0
- snowflake/cli_sandbox/plugin_spec.py +28 -0
- snowflake/cli_sandbox/py.typed +0 -0
- snowflake/sandbox/__init__.py +317 -0
- snowflake/sandbox/__main__.py +225 -0
- snowflake/sandbox/_ansi.py +206 -0
- snowflake/sandbox/_args.py +208 -0
- snowflake/sandbox/_assemble.py +256 -0
- snowflake/sandbox/_bundle.py +240 -0
- snowflake/sandbox/_connection_resolve.py +328 -0
- snowflake/sandbox/_deploy_spec.py +56 -0
- snowflake/sandbox/_diagnostics.py +501 -0
- snowflake/sandbox/_env.py +143 -0
- snowflake/sandbox/_files_mixin.py +280 -0
- snowflake/sandbox/_fs_ops.py +304 -0
- snowflake/sandbox/_globs.py +176 -0
- snowflake/sandbox/_hosts.py +110 -0
- snowflake/sandbox/_mcp_discovery.py +288 -0
- snowflake/sandbox/_mcp_status.py +183 -0
- snowflake/sandbox/_retry.py +94 -0
- snowflake/sandbox/_runtime/__init__.py +42 -0
- snowflake/sandbox/_runtime/_fs_helper.py +93 -0
- snowflake/sandbox/_runtime/_job_runner.py +111 -0
- snowflake/sandbox/_runtime/_protocol.py +53 -0
- snowflake/sandbox/_runtime/_shims.py +267 -0
- snowflake/sandbox/_sandbox_state.py +303 -0
- snowflake/sandbox/_session_registry.py +222 -0
- snowflake/sandbox/_sse.py +160 -0
- snowflake/sandbox/_stage.py +270 -0
- snowflake/sandbox/_sync_files_mixin.py +272 -0
- snowflake/sandbox/_sync_fs_ops.py +185 -0
- snowflake/sandbox/_sync_transport.py +737 -0
- snowflake/sandbox/_sync_watch.py +99 -0
- snowflake/sandbox/_transport.py +1366 -0
- snowflake/sandbox/_transport_errors.py +270 -0
- snowflake/sandbox/_upload_plan.py +497 -0
- snowflake/sandbox/_version.py +37 -0
- snowflake/sandbox/_watch.py +164 -0
- snowflake/sandbox/_wire.py +348 -0
- snowflake/sandbox/app.py +256 -0
- snowflake/sandbox/client.py +2356 -0
- snowflake/sandbox/config.py +1133 -0
- snowflake/sandbox/connect.py +288 -0
- snowflake/sandbox/deploy.py +499 -0
- snowflake/sandbox/egress.py +388 -0
- snowflake/sandbox/exceptions.py +253 -0
- snowflake/sandbox/exec_stream.py +264 -0
- snowflake/sandbox/files.py +547 -0
- snowflake/sandbox/function.py +567 -0
- snowflake/sandbox/image.py +46 -0
- snowflake/sandbox/jobs.py +649 -0
- snowflake/sandbox/lifecycle.py +67 -0
- snowflake/sandbox/log_stream.py +219 -0
- snowflake/sandbox/mcp.py +480 -0
- snowflake/sandbox/mount.py +161 -0
- snowflake/sandbox/py.typed +0 -0
- snowflake/sandbox/secret.py +244 -0
- snowflake/sandbox/session_app.py +244 -0
- snowflake/sandbox/shell.py +556 -0
- snowflake/sandbox/sync_client.py +2245 -0
- snowflake/sandbox/sync_exec_stream.py +238 -0
- snowflake/sandbox/sync_files.py +377 -0
- snowflake/sandbox/sync_log_stream.py +142 -0
- snowflake/sandbox/sync_shell.py +413 -0
- snowflake/sandbox/types.py +193 -0
- snowflake/sandbox/warm_session.py +700 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
"""Warm session engine — the internal machinery behind ``Sandbox.send``.
|
|
2
|
+
|
|
3
|
+
Deploy a project's daemon **detached** (a long-running loop, never
|
|
4
|
+
run-to-completion), get a `Session` handle back, and drive it with
|
|
5
|
+
``send()`` — a round-trip that injects a message into the live container and
|
|
6
|
+
reads the daemon's reply. It is the stateful sibling of the detached run
|
|
7
|
+
(``jobs.py``): a detached run executes to completion and exits (stateless); a
|
|
8
|
+
session stays alive across many turns with context warm in process memory.
|
|
9
|
+
|
|
10
|
+
The daemon stays warm and pays for idle. ``session.id == container_id``;
|
|
11
|
+
``key`` → container is tracked in a module-level dict in-process.
|
|
12
|
+
|
|
13
|
+
**The mailbox protocol:**
|
|
14
|
+
|
|
15
|
+
* The daemon (inside the container) runs `session_loop()`, which tails
|
|
16
|
+
``/tmp/sandbox_mailbox.jsonl``. Each new line is
|
|
17
|
+
``{"id": "<msg_id>", "msg": "<text>"}``. For each, the loop calls the user's
|
|
18
|
+
``handle(msg, state)`` and prints to **stdout**:
|
|
19
|
+
|
|
20
|
+
__SANDBOX_REPLY__<msg_id>__<base64(json(reply))>
|
|
21
|
+
|
|
22
|
+
* `Session.send()` mints a ``msg_id`` (uuid hex), execs a shell-quoted
|
|
23
|
+
``printf … >> /tmp/sandbox_mailbox.jsonl`` to append the message, then polls
|
|
24
|
+
``logs()`` until the matching ``__SANDBOX_REPLY__<msg_id>__<b64>`` line
|
|
25
|
+
appears, base64-decodes it, and returns the reply string. A timeout raises
|
|
26
|
+
`SandboxExecTimeoutError`.
|
|
27
|
+
|
|
28
|
+
``state`` is a plain dict that the daemon keeps in process memory across calls —
|
|
29
|
+
this is THE warm context mechanism: it stays warm across turns.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import asyncio
|
|
35
|
+
import base64
|
|
36
|
+
import binascii
|
|
37
|
+
import json
|
|
38
|
+
import shlex
|
|
39
|
+
import time
|
|
40
|
+
import uuid
|
|
41
|
+
from collections.abc import Callable
|
|
42
|
+
from contextlib import AbstractContextManager, nullcontext
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import TYPE_CHECKING, Any
|
|
45
|
+
|
|
46
|
+
from snowflake.sandbox._deploy_spec import DeploySpec
|
|
47
|
+
from snowflake.sandbox._runtime._protocol import _MAILBOX_PATH, _REPLY_SENTINEL
|
|
48
|
+
from snowflake.sandbox.client import get_sandbox
|
|
49
|
+
from snowflake.sandbox.exceptions import SandboxError, SandboxExecTimeoutError
|
|
50
|
+
from snowflake.sandbox.types import TERMINAL_STATUSES
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from snowflake.sandbox.config import ConnectionLike
|
|
54
|
+
|
|
55
|
+
__all__ = ["Session", "agent_session", "agent_session_sync", "session_loop"]
|
|
56
|
+
|
|
57
|
+
# Env var carrying the per-session reply nonce into the daemon. The daemon loop
|
|
58
|
+
# reads it once at startup and unsets it before running any user handler, so the
|
|
59
|
+
# untrusted ``on_message`` code cannot read it and forge an authenticated reply.
|
|
60
|
+
#
|
|
61
|
+
# Defined here rather than in ``_runtime._protocol`` with its siblings: no
|
|
62
|
+
# generated in-container program reads this name from Python (the session shim
|
|
63
|
+
# bakes the literal into its source), while ``client``/``sync`` import it from
|
|
64
|
+
# this module to set the env var on create — and under mypy's
|
|
65
|
+
# no-implicit-reexport a pass-through alias would not satisfy them.
|
|
66
|
+
_SESSION_NONCE_ENV = "SANDBOX_SESSION_NONCE"
|
|
67
|
+
|
|
68
|
+
# In-process key -> container_id tracking. A backend-side registry would let
|
|
69
|
+
# create-or-resume survive process restarts; today this is per-process.
|
|
70
|
+
_SESSIONS: dict[str, str] = {}
|
|
71
|
+
# Parallel key -> reply-nonce map, so a same-process resume re-attaches with
|
|
72
|
+
# the nonce that authenticates the daemon's replies. Lost on process restart, like
|
|
73
|
+
# _SESSIONS itself; a rehydrated handle then falls back to unauthenticated matching.
|
|
74
|
+
_SESSION_NONCES: dict[str, str] = {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_reply_sentinel(log_text: str, msg_id: str, *, nonce: str | None = None) -> str | None:
|
|
78
|
+
"""Scan *log_text* for the LAST reply sentinel matching *msg_id* and return the
|
|
79
|
+
decoded reply string. Pure + unit-testable.
|
|
80
|
+
|
|
81
|
+
The reply line is ``__SANDBOX_REPLY__<msg_id>__<nonce>__<b64>`` — the daemon
|
|
82
|
+
reads the per-session ``nonce`` from its environment once at startup and
|
|
83
|
+
unsets it before running any handler, so the (untrusted) ``on_message`` code
|
|
84
|
+
cannot learn it and forge a reply. When *nonce* is given, only a sentinel
|
|
85
|
+
carrying that exact nonce is accepted. The older
|
|
86
|
+
``__SANDBOX_REPLY__<msg_id>__<b64>`` form (a base image shipping a pre-nonce
|
|
87
|
+
SDK) is still decoded when the caller holds no nonce, but is rejected when a
|
|
88
|
+
nonce is required — its reply cannot be authenticated. ``msg_id`` and the
|
|
89
|
+
nonce are uuid hex and standard base64 has no ``_``, so ``__`` splits the
|
|
90
|
+
fields unambiguously.
|
|
91
|
+
|
|
92
|
+
Returns ``None`` if no matching well-formed sentinel is present (a malformed
|
|
93
|
+
base64/json payload is skipped, not raised, so a partial log line never masks
|
|
94
|
+
an earlier good one)."""
|
|
95
|
+
prefix = _REPLY_SENTINEL
|
|
96
|
+
found: str | None = None
|
|
97
|
+
for line in log_text.splitlines():
|
|
98
|
+
idx = line.find(prefix)
|
|
99
|
+
if idx == -1:
|
|
100
|
+
continue
|
|
101
|
+
rest = line[idx + len(prefix) :].strip()
|
|
102
|
+
parts = rest.split("__")
|
|
103
|
+
if len(parts) == 3:
|
|
104
|
+
r_msg_id, r_nonce, b64 = parts
|
|
105
|
+
elif len(parts) == 2:
|
|
106
|
+
r_msg_id, r_nonce, b64 = parts[0], "", parts[1]
|
|
107
|
+
else:
|
|
108
|
+
continue
|
|
109
|
+
if r_msg_id != msg_id or not b64:
|
|
110
|
+
continue
|
|
111
|
+
if nonce is not None and r_nonce != nonce:
|
|
112
|
+
# A reply that cannot prove it came from the trusted daemon loop.
|
|
113
|
+
continue
|
|
114
|
+
try:
|
|
115
|
+
decoded = base64.b64decode(b64, validate=True)
|
|
116
|
+
reply = json.loads(decoded)
|
|
117
|
+
except (binascii.Error, ValueError, UnicodeDecodeError):
|
|
118
|
+
continue
|
|
119
|
+
if not isinstance(reply, str):
|
|
120
|
+
continue
|
|
121
|
+
found = reply
|
|
122
|
+
return found
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _mailbox_append_cmd(msg_id: str, message: str) -> list[str]:
|
|
126
|
+
"""Build the shell argv that appends one mailbox line for *msg_id*/*message*.
|
|
127
|
+
|
|
128
|
+
The line is ``{"id": "<msg_id>", "msg": "<text>"}`` (compact JSON). The JSON
|
|
129
|
+
is shell-quoted with `shlex.quote()` and emitted with ``printf '%s\\n'``
|
|
130
|
+
so embedded quotes/newlines/backslashes survive the round-trip into the
|
|
131
|
+
container's mailbox file."""
|
|
132
|
+
payload = json.dumps({"id": msg_id, "msg": message}, separators=(",", ":"))
|
|
133
|
+
quoted = shlex.quote(payload)
|
|
134
|
+
return [
|
|
135
|
+
"/bin/bash",
|
|
136
|
+
"-c",
|
|
137
|
+
f"printf '%s\\n' {quoted} >> {_MAILBOX_PATH}",
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _ended_before_reply(status: str, msg_id: str) -> str:
|
|
142
|
+
"""The message for a container that went terminal mid-round-trip."""
|
|
143
|
+
return (
|
|
144
|
+
f"the sandbox ended (status {status!r}) before replying to message {msg_id}. "
|
|
145
|
+
f"The daemon is gone, so this is not a timeout and retrying send() on this "
|
|
146
|
+
f"session will not help -- start a new one with force_fresh=True, or check "
|
|
147
|
+
f"the container's logs for why it exited."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Session:
|
|
152
|
+
"""A handle to a warm session.
|
|
153
|
+
|
|
154
|
+
``id == container_id``. Construct via `agent_session()` (which
|
|
155
|
+
deploys the daemon detached) or `from_id()` to rehydrate later.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
container_id: str,
|
|
161
|
+
key: str = "",
|
|
162
|
+
*,
|
|
163
|
+
nonce: str | None = None,
|
|
164
|
+
connection: ConnectionLike | None = None,
|
|
165
|
+
) -> None:
|
|
166
|
+
self.id = container_id
|
|
167
|
+
self.key = key
|
|
168
|
+
# The Snowflake connection this handle reads its container over. Every method
|
|
169
|
+
# re-fetches by id (``get_sandbox``), and a handle rehydrated in a new process
|
|
170
|
+
# has no ambient connection to inherit.
|
|
171
|
+
self._connection = connection
|
|
172
|
+
# Per-session reply nonce: set by `agent_session` at create time and
|
|
173
|
+
# held only in this process, so `send()` can authenticate that a reply came
|
|
174
|
+
# from the trusted daemon loop rather than a forged stdout line. A handle
|
|
175
|
+
# rehydrated by `from_id()` has none — its replies cannot be authenticated.
|
|
176
|
+
self.nonce = nonce
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def from_id(
|
|
180
|
+
cls, container_id: str, key: str = "", *, connection: ConnectionLike | None = None
|
|
181
|
+
) -> Session:
|
|
182
|
+
"""Rehydrate a `Session` handle from a container id.
|
|
183
|
+
|
|
184
|
+
The per-session reply nonce lives only in the process that created the
|
|
185
|
+
session, so a rehydrated handle has none: `send()` matches replies by
|
|
186
|
+
message id but cannot authenticate them.
|
|
187
|
+
|
|
188
|
+
``connection`` names the Snowflake connection the session lives in. Omit it to
|
|
189
|
+
use the enclosing ``using()`` block, else the default connection."""
|
|
190
|
+
return cls(container_id, key=key, connection=connection)
|
|
191
|
+
|
|
192
|
+
async def send(self, message: str, timeout: float = 120.0, poll_s: float = 1.0) -> str:
|
|
193
|
+
"""Send *message* to the daemon and return its reply (the mailbox
|
|
194
|
+
round-trip).
|
|
195
|
+
|
|
196
|
+
Mints a ``msg_id``, execs a shell-quoted append into
|
|
197
|
+
``/tmp/sandbox_mailbox.jsonl`` inside the live container, then polls
|
|
198
|
+
``logs()`` until the daemon prints ``__SANDBOX_REPLY__<msg_id>__<b64>``.
|
|
199
|
+
Base64-decodes the payload and returns the reply string.
|
|
200
|
+
|
|
201
|
+
Raises `SandboxError` if the container reaches a terminal state without
|
|
202
|
+
replying, and `SandboxExecTimeoutError` if it is still alive but silent for
|
|
203
|
+
*timeout* seconds."""
|
|
204
|
+
msg_id = uuid.uuid4().hex
|
|
205
|
+
sb = await get_sandbox(self.id, connection=self._connection)
|
|
206
|
+
await sb.exec(_mailbox_append_cmd(msg_id, message))
|
|
207
|
+
|
|
208
|
+
deadline = time.monotonic() + timeout
|
|
209
|
+
while True:
|
|
210
|
+
log_text = await sb.logs()
|
|
211
|
+
reply = _parse_reply_sentinel(log_text, msg_id, nonce=self.nonce)
|
|
212
|
+
if reply is not None:
|
|
213
|
+
return reply
|
|
214
|
+
# A dead daemon otherwise costs the whole timeout and is then reported as
|
|
215
|
+
# one, which points the caller at their message rather than at the gone
|
|
216
|
+
# container. refresh(), not `sb.status`: that is cached from create time
|
|
217
|
+
# and nothing on the container's lifecycle mutates it (see LogStream).
|
|
218
|
+
if await sb.refresh() in TERMINAL_STATUSES:
|
|
219
|
+
# One last read first -- the daemon may have replied just before
|
|
220
|
+
# exiting, and that reply is the answer, not an error.
|
|
221
|
+
reply = _parse_reply_sentinel(await sb.logs(), msg_id, nonce=self.nonce)
|
|
222
|
+
if reply is not None:
|
|
223
|
+
return reply
|
|
224
|
+
raise SandboxError(_ended_before_reply(sb.status, msg_id))
|
|
225
|
+
if time.monotonic() + poll_s >= deadline:
|
|
226
|
+
raise SandboxExecTimeoutError(f"no reply for message {msg_id} within {timeout}s")
|
|
227
|
+
await asyncio.sleep(poll_s)
|
|
228
|
+
|
|
229
|
+
def send_sync(self, message: str, timeout: float = 120.0, poll_s: float = 1.0) -> str:
|
|
230
|
+
"""Send *message* to the daemon and return its reply — synchronous
|
|
231
|
+
counterpart of `send`.
|
|
232
|
+
|
|
233
|
+
Same mailbox round-trip as `send`, blocking instead of awaiting: mints a
|
|
234
|
+
``msg_id``, execs the shell-quoted append into
|
|
235
|
+
``/tmp/sandbox_mailbox.jsonl`` inside the live container (via the
|
|
236
|
+
synchronous `Sandbox`), then polls ``logs()`` until the daemon prints
|
|
237
|
+
``__SANDBOX_REPLY__<msg_id>__<b64>``, base64-decodes it, and returns the
|
|
238
|
+
reply string. The request built and the reply parsed are byte-identical
|
|
239
|
+
to `send`'s — both go through the shared ``_mailbox_append_cmd`` /
|
|
240
|
+
``_parse_reply_sentinel``. Safe to call with no running event loop and
|
|
241
|
+
never starts one, so it does not deadlock inside a notebook.
|
|
242
|
+
|
|
243
|
+
Raises `SandboxError` if the container reaches a terminal state without
|
|
244
|
+
replying, and `SandboxExecTimeoutError` if it is still alive but silent for
|
|
245
|
+
*timeout* seconds.
|
|
246
|
+
|
|
247
|
+
Example:
|
|
248
|
+
session = TriageSession.session_sync(key="triage")
|
|
249
|
+
reply = session.send_sync("what changed in prod yesterday?")
|
|
250
|
+
"""
|
|
251
|
+
from snowflake.sandbox.sync_client import get_sandbox as _get_sandbox_sync
|
|
252
|
+
|
|
253
|
+
msg_id = uuid.uuid4().hex
|
|
254
|
+
sb = _get_sandbox_sync(self.id, connection=self._connection)
|
|
255
|
+
sb.exec(_mailbox_append_cmd(msg_id, message))
|
|
256
|
+
|
|
257
|
+
deadline = time.monotonic() + timeout
|
|
258
|
+
while True:
|
|
259
|
+
log_text = sb.logs()
|
|
260
|
+
reply = _parse_reply_sentinel(log_text, msg_id, nonce=self.nonce)
|
|
261
|
+
if reply is not None:
|
|
262
|
+
return reply
|
|
263
|
+
# As in `send`: a terminal container is not a timeout, and refresh() is
|
|
264
|
+
# the only live read of its status.
|
|
265
|
+
if sb.refresh() in TERMINAL_STATUSES:
|
|
266
|
+
reply = _parse_reply_sentinel(sb.logs(), msg_id, nonce=self.nonce)
|
|
267
|
+
if reply is not None:
|
|
268
|
+
return reply
|
|
269
|
+
raise SandboxError(_ended_before_reply(sb.status, msg_id))
|
|
270
|
+
if time.monotonic() + poll_s >= deadline:
|
|
271
|
+
raise SandboxExecTimeoutError(f"no reply for message {msg_id} within {timeout}s")
|
|
272
|
+
time.sleep(poll_s)
|
|
273
|
+
|
|
274
|
+
async def status_async(self) -> str:
|
|
275
|
+
"""The lifecycle status from a real server read: ``cold`` | ``running`` |
|
|
276
|
+
``ended``.
|
|
277
|
+
|
|
278
|
+
``ended`` when the container is terminal (``dead`` or ``failed``); otherwise ``running``
|
|
279
|
+
(a daemon the platform has checkpointed still reports ``running`` — it is
|
|
280
|
+
restored warm on the next ``send()``), or ``cold`` before an id exists.
|
|
281
|
+
Safe from async code,
|
|
282
|
+
including from inside a running event loop; prefer it over the sync
|
|
283
|
+
`status` in any ``async def``."""
|
|
284
|
+
sb = await get_sandbox(self.id, connection=self._connection)
|
|
285
|
+
if sb.status in TERMINAL_STATUSES:
|
|
286
|
+
return "ended"
|
|
287
|
+
return "running" if sb._id else "cold"
|
|
288
|
+
|
|
289
|
+
def status(self) -> str:
|
|
290
|
+
"""The lifecycle status from a real server read: ``cold`` | ``running`` |
|
|
291
|
+
``ended`` (``ended`` = the container is terminal: ``dead`` or ``failed``).
|
|
292
|
+
|
|
293
|
+
A synchronous convenience wrapper around `status_async`: it drives the
|
|
294
|
+
read on a fresh event loop, so it may be called **only when no event loop
|
|
295
|
+
is running**. Called from *inside* a running loop it cannot block on the
|
|
296
|
+
network read — and fabricating a status from the id alone can never observe
|
|
297
|
+
``ended`` (it would report a dead session as ``running``, the silent-wrong
|
|
298
|
+
result this method must not produce) — so it raises `SandboxError` instead.
|
|
299
|
+
From async code, ``await session.status_async()``."""
|
|
300
|
+
try:
|
|
301
|
+
asyncio.get_running_loop()
|
|
302
|
+
except RuntimeError:
|
|
303
|
+
return asyncio.run(self.status_async())
|
|
304
|
+
raise SandboxError(
|
|
305
|
+
"Session.status() cannot read the session status from inside a running "
|
|
306
|
+
"event loop: it would have to block on a network read, and reporting a "
|
|
307
|
+
"status from the id alone can never return 'ended' for a dead session. "
|
|
308
|
+
"Use `await session.status_async()` from async code."
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
async def end(self) -> None:
|
|
312
|
+
"""End the Session by destroying its container.
|
|
313
|
+
|
|
314
|
+
Also drops the key→container mapping so a later ``agent_session(key=…)``
|
|
315
|
+
cold-starts a fresh daemon rather than resuming a dead one."""
|
|
316
|
+
sb = await get_sandbox(self.id, connection=self._connection)
|
|
317
|
+
await sb.destroy()
|
|
318
|
+
if self.key and _SESSIONS.get(self.key) == self.id:
|
|
319
|
+
_SESSIONS.pop(self.key, None)
|
|
320
|
+
_SESSION_NONCES.pop(self.key, None)
|
|
321
|
+
|
|
322
|
+
def end_sync(self) -> None:
|
|
323
|
+
"""End the Session by destroying its container — synchronous counterpart
|
|
324
|
+
of `end`.
|
|
325
|
+
|
|
326
|
+
Blocks instead of awaiting; the same teardown as `end`, and it likewise
|
|
327
|
+
drops the key→container mapping so a later ``agent_session(key=…)`` /
|
|
328
|
+
`agent_session_sync()` cold-starts a fresh daemon rather than resuming a
|
|
329
|
+
dead one. Safe to call with no running event loop and never starts one.
|
|
330
|
+
|
|
331
|
+
Example:
|
|
332
|
+
session = TriageSession.session_sync(key="triage")
|
|
333
|
+
session.end_sync()
|
|
334
|
+
"""
|
|
335
|
+
from snowflake.sandbox.sync_client import get_sandbox as _get_sandbox_sync
|
|
336
|
+
|
|
337
|
+
sb = _get_sandbox_sync(self.id, connection=self._connection)
|
|
338
|
+
sb.destroy()
|
|
339
|
+
if self.key and _SESSIONS.get(self.key) == self.id:
|
|
340
|
+
_SESSIONS.pop(self.key, None)
|
|
341
|
+
_SESSION_NONCES.pop(self.key, None)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _layer_shim(spec: DeploySpec, shim_dir: Path) -> DeploySpec:
|
|
345
|
+
"""Return *spec* with the generated shim files copied into its bundle root.
|
|
346
|
+
|
|
347
|
+
``@app.session`` writes its mailbox-loop shim to a temp dir; the daemon also
|
|
348
|
+
needs the user's module. Copying the shim into a throwaway copy of the bundle
|
|
349
|
+
root keeps both without mutating the user's tree.
|
|
350
|
+
"""
|
|
351
|
+
import shutil
|
|
352
|
+
import tempfile
|
|
353
|
+
from dataclasses import replace
|
|
354
|
+
|
|
355
|
+
from snowflake.sandbox._assemble import _DEFAULT_EXCLUDES, Bundle, _copy_tree_into
|
|
356
|
+
|
|
357
|
+
root = Path(spec.bundle.root) if spec.bundle is not None else shim_dir
|
|
358
|
+
exclude = list(spec.bundle.exclude) if spec.bundle is not None else []
|
|
359
|
+
staged = Path(tempfile.mkdtemp(prefix="sbx-session-"))
|
|
360
|
+
if root.is_dir() and root != shim_dir:
|
|
361
|
+
# Route the user's tree through the credential + symlink filters: a bare
|
|
362
|
+
# copytree would ship `.env` / `id_rsa` / `~/.aws` / `*.pem` and ignore the
|
|
363
|
+
# bundle's own excludes. _copy_tree_into applies both.
|
|
364
|
+
_copy_tree_into(root, staged, list(_DEFAULT_EXCLUDES) + exclude)
|
|
365
|
+
for f in shim_dir.iterdir():
|
|
366
|
+
if f.is_file():
|
|
367
|
+
shutil.copy2(f, staged / f.name)
|
|
368
|
+
include = list(spec.bundle.include) if spec.bundle is not None else []
|
|
369
|
+
return replace(
|
|
370
|
+
spec, bundle=Bundle(root=str(staged), include=tuple(include), exclude=tuple(exclude))
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _connection_scope(connection: ConnectionLike | None) -> AbstractContextManager[object]:
|
|
375
|
+
"""``using(connection)`` when one was named, else a no-op.
|
|
376
|
+
|
|
377
|
+
Lets an entry point accept ``connection=`` and have it apply to a whole pipeline of
|
|
378
|
+
calls that take no connection argument themselves.
|
|
379
|
+
``connection`` picks the Snowflake connection for this call; the handle carries it
|
|
380
|
+
onward. See `config.using` for the resolution rules (names, live connections,
|
|
381
|
+
memoisation, and how an enclosing ``using()`` block interacts).
|
|
382
|
+
|
|
383
|
+
"""
|
|
384
|
+
if connection is None:
|
|
385
|
+
return nullcontext()
|
|
386
|
+
from snowflake.sandbox.config import using
|
|
387
|
+
|
|
388
|
+
return using(connection)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
async def agent_session(
|
|
392
|
+
spec: DeploySpec,
|
|
393
|
+
*,
|
|
394
|
+
key: str,
|
|
395
|
+
shim_dir: str | Path | None = None,
|
|
396
|
+
force_fresh: bool = False,
|
|
397
|
+
timeout: float = 3600.0,
|
|
398
|
+
connection: ConnectionLike | None = None,
|
|
399
|
+
git_repo: str | None = None,
|
|
400
|
+
git_ref: str | None = None,
|
|
401
|
+
git_subdir: str | None = None,
|
|
402
|
+
) -> Session:
|
|
403
|
+
"""Create-or-resume a keep-alive Session keyed by *key*.
|
|
404
|
+
|
|
405
|
+
Deploys the project's daemon **detached** — reusing ``deploy_spec(...,
|
|
406
|
+
detach=True)`` internals exactly like ``deploy_async`` reuses the bundle
|
|
407
|
+
path: the spec's ``entry`` is the user's long-running loop (it should call
|
|
408
|
+
`session_loop()`), and the container's main process is that loop. Returns a
|
|
409
|
+
`Session` whose ``id`` is the daemon's container id.
|
|
410
|
+
|
|
411
|
+
**Create-or-resume by ``key``:** an in-process ``dict[str, str]`` tracks
|
|
412
|
+
``key → container_id``. If *key* is already
|
|
413
|
+
tracked and that container is alive, the existing Session is returned
|
|
414
|
+
(resume); otherwise a fresh daemon is deployed and ``key → id`` recorded.
|
|
415
|
+
Set ``force_fresh=True`` to bypass resume and always cold-start.
|
|
416
|
+
``connection`` picks the Snowflake connection for this call; the handle carries it
|
|
417
|
+
onward. See `config.using` for the resolution rules (names, live connections,
|
|
418
|
+
memoisation, and how an enclosing ``using()`` block interacts).
|
|
419
|
+
|
|
420
|
+
"""
|
|
421
|
+
# --- resume path: reuse a live daemon already tracked for this key ---
|
|
422
|
+
if not force_fresh:
|
|
423
|
+
existing_id = _SESSIONS.get(key)
|
|
424
|
+
if existing_id is not None:
|
|
425
|
+
try:
|
|
426
|
+
# Same connection the caller named: checking liveness on the DEFAULT
|
|
427
|
+
# connection instead would raise for a container that is alive on
|
|
428
|
+
# theirs, cold-starting a second daemon and leaking the first.
|
|
429
|
+
sb = await get_sandbox(existing_id, connection=connection)
|
|
430
|
+
except SandboxError:
|
|
431
|
+
sb = None
|
|
432
|
+
if sb is not None and sb.status not in TERMINAL_STATUSES:
|
|
433
|
+
return Session(
|
|
434
|
+
existing_id, key=key, nonce=_SESSION_NONCES.get(key), connection=connection
|
|
435
|
+
)
|
|
436
|
+
# Stale/dead -> drop it and cold-start below.
|
|
437
|
+
_SESSIONS.pop(key, None)
|
|
438
|
+
_SESSION_NONCES.pop(key, None)
|
|
439
|
+
|
|
440
|
+
# --- create path: deploy the daemon detached ---
|
|
441
|
+
# Imported here (not at module import) to keep the lazy __init__ contract:
|
|
442
|
+
# bare ``import snowflake.sandbox`` must not pull deploy.py / httpx.
|
|
443
|
+
from snowflake.sandbox.deploy import deploy_spec
|
|
444
|
+
|
|
445
|
+
# The generated daemon shim lives in shim_dir; layer it over the declared
|
|
446
|
+
# bundle so the user's module stays importable alongside it.
|
|
447
|
+
if shim_dir is not None:
|
|
448
|
+
spec = _layer_shim(spec, Path(shim_dir))
|
|
449
|
+
|
|
450
|
+
# Per-session reply nonce: delivered as the SANDBOX_SESSION_NONCE env var
|
|
451
|
+
# the daemon loop reads once at startup and unsets before running any handler,
|
|
452
|
+
# so an untrusted ``on_message`` cannot forge an authenticated reply. Held
|
|
453
|
+
# in-process so ``send()`` can verify it.
|
|
454
|
+
#
|
|
455
|
+
# It rides the `sandbox_env` PLATFORM channel (deploy_spec -> from_local ->
|
|
456
|
+
# Sandbox._add_platform_env), NOT the user `env` map: SANDBOX_ is a
|
|
457
|
+
# server-reserved prefix, and the create path rejects (400) a reserved-prefix
|
|
458
|
+
# key on the `env` map. Enforcement is unchanged: the container still receives
|
|
459
|
+
# SANDBOX_SESSION_NONCE in its environment (the server merges sandbox_env into
|
|
460
|
+
# the app env), and session_loop still pops it before any handler runs.
|
|
461
|
+
nonce = uuid.uuid4().hex
|
|
462
|
+
|
|
463
|
+
# ``connection=`` has to reach the whole deploy pipeline (deploy_spec -> from_local
|
|
464
|
+
# -> create), not just this function, and threading a kwarg through every layer of
|
|
465
|
+
# it would mean every future layer remembering to forward it. A ``using()`` block
|
|
466
|
+
# carries it through code that takes no connection argument at all -- the case the
|
|
467
|
+
# scope exists for. ``contextlib.nullcontext`` when nothing was named, so the
|
|
468
|
+
# ambient/enclosing binding is left exactly as it was.
|
|
469
|
+
with _connection_scope(connection):
|
|
470
|
+
result = await deploy_spec(
|
|
471
|
+
spec,
|
|
472
|
+
git_repo=git_repo,
|
|
473
|
+
git_ref=git_ref,
|
|
474
|
+
git_subdir=git_subdir,
|
|
475
|
+
detach=True,
|
|
476
|
+
force_fresh=force_fresh,
|
|
477
|
+
timeout=timeout,
|
|
478
|
+
platform_env={_SESSION_NONCE_ENV: nonce},
|
|
479
|
+
)
|
|
480
|
+
if not result.deployed or not result.sandbox_id:
|
|
481
|
+
problems = "; ".join(p.message for p in result.problems if p.severity == "error")
|
|
482
|
+
raise SandboxError(
|
|
483
|
+
"agent_session failed to deploy the daemon" + (f": {problems}" if problems else "")
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
_SESSIONS[key] = result.sandbox_id
|
|
487
|
+
_SESSION_NONCES[key] = nonce
|
|
488
|
+
return Session(result.sandbox_id, key=key, nonce=nonce, connection=connection)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def agent_session_sync(
|
|
492
|
+
spec: DeploySpec,
|
|
493
|
+
*,
|
|
494
|
+
key: str,
|
|
495
|
+
shim_dir: str | Path | None = None,
|
|
496
|
+
force_fresh: bool = False,
|
|
497
|
+
timeout: float = 3600.0,
|
|
498
|
+
connection: ConnectionLike | None = None,
|
|
499
|
+
git_repo: str | None = None,
|
|
500
|
+
git_ref: str | None = None,
|
|
501
|
+
git_subdir: str | None = None,
|
|
502
|
+
) -> Session:
|
|
503
|
+
"""Create-or-resume a keep-alive Session keyed by *key* — synchronous
|
|
504
|
+
counterpart of `agent_session`.
|
|
505
|
+
|
|
506
|
+
Same create-or-resume contract, blocking instead of awaiting: an in-process
|
|
507
|
+
``key -> container_id`` map is consulted first (resume a live daemon), and
|
|
508
|
+
otherwise the daemon is deployed detached via the synchronous deploy core.
|
|
509
|
+
It shares the same `_SESSIONS` registry as `agent_session`, so a session
|
|
510
|
+
opened on either side resumes from the other. Returns a `Session` whose
|
|
511
|
+
``id`` is the daemon's container id; drive it with ``s.send_sync(msg)``. Safe
|
|
512
|
+
with no running event loop and never starts one.
|
|
513
|
+
|
|
514
|
+
``connection`` picks the Snowflake connection for this call; the handle carries it
|
|
515
|
+
onward. See `config.using` for the resolution rules (names, live connections,
|
|
516
|
+
memoisation, and how an enclosing ``using()`` block interacts).
|
|
517
|
+
|
|
518
|
+
Example:
|
|
519
|
+
session = agent_session_sync(app_session.deploy_spec(), key="triage")
|
|
520
|
+
reply = session.send_sync("hello")
|
|
521
|
+
"""
|
|
522
|
+
from snowflake.sandbox.sync_client import get_sandbox as _get_sandbox_sync
|
|
523
|
+
|
|
524
|
+
# --- resume path: reuse a live daemon already tracked for this key ---
|
|
525
|
+
if not force_fresh:
|
|
526
|
+
existing_id = _SESSIONS.get(key)
|
|
527
|
+
if existing_id is not None:
|
|
528
|
+
try:
|
|
529
|
+
# Same connection the caller named — see the async twin.
|
|
530
|
+
sb = _get_sandbox_sync(existing_id, connection=connection)
|
|
531
|
+
except SandboxError:
|
|
532
|
+
sb = None
|
|
533
|
+
if sb is not None and sb.status not in TERMINAL_STATUSES:
|
|
534
|
+
return Session(
|
|
535
|
+
existing_id, key=key, nonce=_SESSION_NONCES.get(key), connection=connection
|
|
536
|
+
)
|
|
537
|
+
# Stale/dead -> drop it and cold-start below.
|
|
538
|
+
_SESSIONS.pop(key, None)
|
|
539
|
+
_SESSION_NONCES.pop(key, None)
|
|
540
|
+
|
|
541
|
+
# --- create path: deploy the daemon detached (sync deploy core) ---
|
|
542
|
+
# Imported lazily (not at module scope) to keep the lazy __init__ contract:
|
|
543
|
+
# a bare ``import snowflake.sandbox`` must not pull deploy.py / httpx.
|
|
544
|
+
from snowflake.sandbox.deploy import deploy_spec_sync
|
|
545
|
+
|
|
546
|
+
if shim_dir is not None:
|
|
547
|
+
spec = _layer_shim(spec, Path(shim_dir))
|
|
548
|
+
|
|
549
|
+
# Per-session reply nonce: rides the `sandbox_env` PLATFORM channel, not the user
|
|
550
|
+
# `env` map (SANDBOX_ is a server-reserved prefix the create path rejects on `env`).
|
|
551
|
+
# See `agent_session` for the full rationale.
|
|
552
|
+
nonce = uuid.uuid4().hex
|
|
553
|
+
|
|
554
|
+
# ``connection=`` has to reach the whole deploy pipeline (deploy_spec -> from_local
|
|
555
|
+
# -> create), not just this function, and threading a kwarg through every layer of
|
|
556
|
+
# it would mean every future layer remembering to forward it. A ``using()`` block
|
|
557
|
+
# carries it through code that takes no connection argument at all -- the case the
|
|
558
|
+
# scope exists for. ``contextlib.nullcontext`` when nothing was named, so the
|
|
559
|
+
# ambient/enclosing binding is left exactly as it was.
|
|
560
|
+
with _connection_scope(connection):
|
|
561
|
+
result = deploy_spec_sync(
|
|
562
|
+
spec,
|
|
563
|
+
git_repo=git_repo,
|
|
564
|
+
git_ref=git_ref,
|
|
565
|
+
git_subdir=git_subdir,
|
|
566
|
+
detach=True,
|
|
567
|
+
force_fresh=force_fresh,
|
|
568
|
+
timeout=timeout,
|
|
569
|
+
platform_env={_SESSION_NONCE_ENV: nonce},
|
|
570
|
+
)
|
|
571
|
+
if not result.deployed or not result.sandbox_id:
|
|
572
|
+
problems = "; ".join(p.message for p in result.problems if p.severity == "error")
|
|
573
|
+
raise SandboxError(
|
|
574
|
+
"agent_session failed to deploy the daemon" + (f": {problems}" if problems else "")
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
_SESSIONS[key] = result.sandbox_id
|
|
578
|
+
_SESSION_NONCES[key] = nonce
|
|
579
|
+
return Session(result.sandbox_id, key=key, nonce=nonce, connection=connection)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def session_loop(
|
|
583
|
+
handle: Callable[[str, dict[str, Any]], str],
|
|
584
|
+
*,
|
|
585
|
+
mailbox_path: str = _MAILBOX_PATH,
|
|
586
|
+
poll_s: float = 0.5,
|
|
587
|
+
) -> None:
|
|
588
|
+
"""Run the daemon-side message loop (the in-container half of a warm agent).
|
|
589
|
+
|
|
590
|
+
The daemon's entry program imports this so you don't hand-write the
|
|
591
|
+
mailbox loop:
|
|
592
|
+
|
|
593
|
+
from snowflake.sandbox import session_loop
|
|
594
|
+
session_loop(lambda msg, state: ...)
|
|
595
|
+
|
|
596
|
+
*handle* is ``handle(msg, state) -> reply``: *msg* is the incoming message
|
|
597
|
+
text, *state* is a plain dict that **persists across calls in process
|
|
598
|
+
memory** (THE warm context). The reply string is returned to the controller.
|
|
599
|
+
|
|
600
|
+
The loop tails *mailbox_path* for new ``{"id", "msg"}`` lines; for each it
|
|
601
|
+
calls ``handle(msg, state)`` and prints
|
|
602
|
+
``__SANDBOX_REPLY__<id>__<base64(json(reply))>`` to **stdout** (flushed). It
|
|
603
|
+
runs forever and is robust to malformed lines (a bad line is skipped, not
|
|
604
|
+
fatal) and to handler exceptions (the error string is returned as the reply
|
|
605
|
+
so ``send()`` doesn't hang).
|
|
606
|
+
|
|
607
|
+
**Surviving the platform's idle checkpoint.** Snowflake's idle auto-suspend
|
|
608
|
+
(``runsc checkpoint``/``restore``) sends **SIGUSR1** to the app immediately
|
|
609
|
+
*before* the memory checkpoint and **SIGUSR2** immediately *after* restore.
|
|
610
|
+
SIGUSR1's default action is *terminate*, so this loop sets both to
|
|
611
|
+
``SIG_IGN`` — without that a checkpoint kills the daemon. In-process *memory*
|
|
612
|
+
(``state``, counters, loaded objects) survives the checkpoint automatically;
|
|
613
|
+
open network sockets (Cortex/Snowflake/TCP) go stale and must be reopened
|
|
614
|
+
lazily by the handler. Ignoring the signals is harmless when the platform
|
|
615
|
+
never checkpoints — they simply never arrive.
|
|
616
|
+
"""
|
|
617
|
+
import os
|
|
618
|
+
import signal
|
|
619
|
+
|
|
620
|
+
# Per-session reply nonce: read once and remove it from the environment
|
|
621
|
+
# before any handler runs, so the (untrusted) handler cannot read it and forge
|
|
622
|
+
# an authenticated reply. Empty when the caller's SDK predates the nonce. Uses
|
|
623
|
+
# the module constant (not a literal) so it stays in sync with the producer.
|
|
624
|
+
_reply_nonce = os.environ.pop(_SESSION_NONCE_ENV, "")
|
|
625
|
+
|
|
626
|
+
state: dict[str, Any] = {}
|
|
627
|
+
|
|
628
|
+
# Ignore the SIGUSR1 (pre-checkpoint) / SIGUSR2 (post-restore) signals the
|
|
629
|
+
# platform's idle auto-suspend sends: SIGUSR1's default action is terminate, so
|
|
630
|
+
# a daemon that installs nothing is killed the first time it is checkpointed.
|
|
631
|
+
# Nothing is wired to them beyond survival — every reply is already printed
|
|
632
|
+
# with flush=True, so no output is buffered across the checkpoint. Must run on
|
|
633
|
+
# the main thread (Python requirement); if the loop is driven off-thread we
|
|
634
|
+
# degrade to the default disposition rather than crash.
|
|
635
|
+
try:
|
|
636
|
+
signal.signal(signal.SIGUSR1, signal.SIG_IGN)
|
|
637
|
+
signal.signal(signal.SIGUSR2, signal.SIG_IGN)
|
|
638
|
+
except (ValueError, OSError, AttributeError):
|
|
639
|
+
# Off the main thread, or a platform without SIGUSR1/2 — checkpoint
|
|
640
|
+
# survival is unavailable, but the keep-alive loop still works.
|
|
641
|
+
pass
|
|
642
|
+
|
|
643
|
+
# Track how many bytes we've consumed so we only process new lines, even
|
|
644
|
+
# across file truncation/rotation.
|
|
645
|
+
offset = 0
|
|
646
|
+
# Ensure the mailbox exists so the first send's append has a target and our
|
|
647
|
+
# read doesn't race a missing file.
|
|
648
|
+
try:
|
|
649
|
+
open(mailbox_path, "a").close()
|
|
650
|
+
except OSError:
|
|
651
|
+
pass
|
|
652
|
+
|
|
653
|
+
while True:
|
|
654
|
+
try:
|
|
655
|
+
size = os.path.getsize(mailbox_path)
|
|
656
|
+
except OSError:
|
|
657
|
+
time.sleep(poll_s)
|
|
658
|
+
continue
|
|
659
|
+
if size < offset:
|
|
660
|
+
# File was truncated/rotated — restart from the top.
|
|
661
|
+
offset = 0
|
|
662
|
+
if size == offset:
|
|
663
|
+
time.sleep(poll_s)
|
|
664
|
+
continue
|
|
665
|
+
# Read in BINARY so `offset` is a true byte count: it is compared against
|
|
666
|
+
# os.path.getsize() (bytes) and seeked with, and a text-mode tell() is an
|
|
667
|
+
# opaque cookie that must not be offset-arithmetic'd (doing so seeks to an
|
|
668
|
+
# invalid position on some CPython builds).
|
|
669
|
+
with open(mailbox_path, "rb") as fh:
|
|
670
|
+
fh.seek(offset)
|
|
671
|
+
chunk = fh.read()
|
|
672
|
+
offset += len(chunk)
|
|
673
|
+
# A trailing line with no newline is a partial append still in flight (a
|
|
674
|
+
# large `printf ... >> mailbox` is not always one atomic write). Rewind the
|
|
675
|
+
# byte offset past it and drop it from this batch, so it is re-read whole
|
|
676
|
+
# next poll rather than split into two fragments that each fail json.loads
|
|
677
|
+
# and silently lose the message.
|
|
678
|
+
if chunk and not chunk.endswith(b"\n"):
|
|
679
|
+
partial = chunk.rpartition(b"\n")[2]
|
|
680
|
+
offset -= len(partial)
|
|
681
|
+
chunk = chunk[: len(chunk) - len(partial)]
|
|
682
|
+
new_lines = chunk.decode("utf-8", "replace").splitlines()
|
|
683
|
+
for line in new_lines:
|
|
684
|
+
line = line.strip()
|
|
685
|
+
if not line:
|
|
686
|
+
continue
|
|
687
|
+
try:
|
|
688
|
+
rec = json.loads(line)
|
|
689
|
+
msg_id = rec["id"]
|
|
690
|
+
msg = rec["msg"]
|
|
691
|
+
except (ValueError, KeyError, TypeError):
|
|
692
|
+
continue # malformed line — skip, never fatal
|
|
693
|
+
try:
|
|
694
|
+
reply = handle(msg, state)
|
|
695
|
+
except Exception as exc: # noqa: BLE001 — never let one turn kill the daemon
|
|
696
|
+
reply = f"handler error: {type(exc).__name__}: {exc}"
|
|
697
|
+
if not isinstance(reply, str):
|
|
698
|
+
reply = str(reply)
|
|
699
|
+
b64 = base64.b64encode(json.dumps(reply).encode()).decode()
|
|
700
|
+
print(f"{_REPLY_SENTINEL}{msg_id}__{_reply_nonce}__{b64}", flush=True)
|