@fireemu/darwin-x64 0.42.0 → 0.44.0

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.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @fireemu/darwin-x64
2
2
 
3
3
  The [fireemu](https://www.npmjs.com/package/fireemu) daemon built for `darwin-x64`
4
- (Rust target `x86_64-apple-darwin`), version 0.42.0.
4
+ (Rust target `x86_64-apple-darwin`), version 0.44.0.
5
5
 
6
6
  macOS 13 or newer on Intel. Cross-compiled from the Apple silicon runner against the same universal SDK; its unit tests run on the arm64 host, which builds the same code.
7
7
 
@@ -13,5 +13,6 @@ npm install -D fireemu
13
13
  ```
14
14
 
15
15
  It contains the daemon (`bin/fireemu`, with the Emulator UI compiled in) and the
16
- Node runner that hosts a Functions codebase (`bin/runner-node/`). Nothing is downloaded at
16
+ Node runner that hosts a Functions codebase (`bin/runner-node/`) and the Python runner for
17
+ Python codebases (`bin/runner-python/`). Nothing is downloaded at
17
18
  install time and there is no install script.
package/bin/fireemu CHANGED
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/bin/fireemu
3
- size: 40434968 bytes
4
- sha256: d4ee02d9d66bf542b3fe1db1d0431a53df28fd5c983a446a6aea23505d985c00
3
+ size: 40771600 bytes
4
+ sha256: 9f136845640648162c73761f815eba34c64ed9db9705ed7cefd87178bf4e9951
@@ -0,0 +1,5 @@
1
+ """The fireemu runner for Python Functions codebases (see docs/specifications and the design memo).
2
+
3
+ The daemon speaks to a runner over its stdin and stdout with length-prefixed JSON frames. This
4
+ package is imported by `runner.py`, which sits beside it.
5
+ """
@@ -0,0 +1,255 @@
1
+ """Callable App Check: the Admin SDK verifies the token against the App Check emulator's keys.
2
+
3
+ SECURITY-SENSITIVE. A Python callable checks the `X-Firebase-AppCheck` header itself, in the SDK's wrapper, with
4
+ `firebase_admin.app_check.verify_token` (`firebase_functions/private/util.py`), which fetches Google's JWKS. The
5
+ Python SDK has no switch to skip that check (Node's `skipTokenVerification`), and the daemon's callable trust
6
+ boundary does not apply to it, so the runner makes the SDK's own check meaningful locally:
7
+
8
+ - when the daemon names the App Check emulator's JWKS (`FIREEMU_APP_CHECK_JWKS_URL`, a loopback address), the
9
+ Admin SDK's App Check service is replaced, before any user code runs, by one that fetches keys from it alone, so
10
+ only a token the emulator's key signed verifies, and the runner tells the daemon the callables do not consume tokens (`consumeAppCheckToken:
11
+ disabled`, true: the SDK verifies with `consume=False`), which lets the daemon forward the header;
12
+ - otherwise nothing is changed and the runner says nothing, so the daemon keeps removing the header before the
13
+ function and the SDK never fetches a key from Google.
14
+
15
+ Anything but a loopback `http` URL is refused, so a stray value cannot make the runner fetch keys from elsewhere.
16
+
17
+ The runner fetches the keys itself (`LoopbackKeyClient`) instead of leaving it to PyJWT's `PyJWKClient`, whose
18
+ redirect and proxy behaviour depends on the PyJWT version (2.13.0, which the SDK's constraints allow, follows
19
+ redirects). The client connects to a loopback address it resolved and checked, ignores proxy variables, treats every
20
+ redirect as a failure, and bounds time and size. What the Python SDK checks of a token is the stock SDK's, plus what
21
+ the emulator always does and the stock SDK leaves optional (`exp`, the exact issuer).
22
+
23
+ Differences from Node, which the Functions page states: the Python SDK verifies against the keys alone, so it has no
24
+ check of the daemon's current project epoch or app state (the Node callable trust boundary has one), and it is not
25
+ instrumented, so it has no start-up self-test. The PyJWT floor below is the lowest version the tests ran against.
26
+ """
27
+
28
+ import http.client
29
+ import ipaddress
30
+ import json
31
+ import re
32
+ import socket
33
+ import threading
34
+ import time
35
+ from urllib.parse import urlsplit
36
+
37
+ URL_VARIABLE = "FIREEMU_APP_CHECK_JWKS_URL"
38
+ _LOOPBACK_NAMES = frozenset(("localhost",))
39
+
40
+
41
+ def jwks_url(environ) -> str | None:
42
+ """The App Check emulator's JWKS URL from the daemon's environment, or None when it is absent or not loopback."""
43
+ value = environ.get(URL_VARIABLE)
44
+ if not value or not isinstance(value, str):
45
+ return None
46
+ try:
47
+ parts = urlsplit(value)
48
+ port = parts.port # raises for a port out of range
49
+ host = parts.hostname
50
+ except ValueError:
51
+ return None
52
+ if parts.scheme != "http" or host is None or parts.username is not None or parts.password is not None:
53
+ return None
54
+ if parts.fragment or not parts.path.startswith("/") or len(parts.path) < 2 or port is None:
55
+ return None
56
+ if host not in _LOOPBACK_NAMES:
57
+ try:
58
+ if not ipaddress.ip_address(host).is_loopback:
59
+ return None
60
+ except ValueError:
61
+ return None
62
+ return value
63
+
64
+
65
+ MIN_PYJWT = (2, 10, 1)
66
+ _REQUIRED_ATTRIBUTES = ("_JWKS_URL", "_APP_CHECK_HEADERS", "_decode_and_verify", "_has_valid_token_headers", "verify_token")
67
+ _ISSUER = re.compile(r"https://firebaseappcheck\.googleapis\.com/([0-9]+)")
68
+
69
+
70
+ def _pyjwt_supported() -> bool:
71
+ import jwt
72
+
73
+ match = re.match(r"^(\d+)\.(\d+)\.(\d+)", str(getattr(jwt, "__version__", "")))
74
+ return match is not None and tuple(int(part) for part in match.groups()) >= MIN_PYJWT
75
+
76
+
77
+ def _loopback_addresses(host: str, port: int) -> list[str]:
78
+ """The loopback addresses `host` names. Every address a name resolves to must be loopback, or none is used."""
79
+ literal = host.strip("[]")
80
+ try:
81
+ addresses = [ipaddress.ip_address(literal)]
82
+ except ValueError:
83
+ try:
84
+ infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
85
+ except OSError as error:
86
+ raise OSError(f"cannot resolve {host}") from error
87
+ addresses = [ipaddress.ip_address(info[4][0].split("%")[0]) for info in infos]
88
+ if not addresses or not all(address.is_loopback for address in addresses):
89
+ raise OSError(f"{host} is not a loopback address")
90
+ return [str(address) for address in addresses]
91
+
92
+
93
+ class LoopbackKeyClient:
94
+ """Fetches and caches the App Check emulator's signing keys from one loopback URL, and nowhere else.
95
+
96
+ Same contract as `jwt.PyJWKClient.get_signing_key_from_jwt` (the object it returns has a `.key`), without any of
97
+ its transport: no redirect is followed (a 3xx is a failure), no proxy variable is read, the connection goes to a
98
+ loopback address the client resolved and checked itself, and time and size are bounded. A failure means no key,
99
+ so the token does not verify.
100
+ """
101
+
102
+ def __init__(self, url, *, timeout=5.0, max_bytes=256 * 1024, lifespan=21600, refresh_interval=1.0, headers=None):
103
+ self._url = url
104
+ self._timeout = timeout
105
+ self._max_bytes = max_bytes
106
+ self._lifespan = lifespan
107
+ self._refresh_interval = refresh_interval
108
+ self._headers = dict(headers or {})
109
+ self._lock = threading.Lock()
110
+ self._keys = {}
111
+ self._fetched_at = None
112
+ self._attempted_at = None
113
+
114
+ def _connect(self, addresses, port):
115
+ """A connection to the first of the checked loopback addresses that accepts one (`localhost` may list `::1`)."""
116
+ last = OSError("no address")
117
+ for address in addresses:
118
+ try:
119
+ return socket.create_connection((address, port), timeout=self._timeout)
120
+ except OSError as error:
121
+ last = error
122
+ raise last
123
+
124
+ def _fetch(self):
125
+ parts = urlsplit(self._url)
126
+ host, port = parts.hostname, parts.port
127
+ if parts.scheme != "http" or host is None or port is None:
128
+ raise OSError("the key URL is not a loopback http URL")
129
+ path = (parts.path or "/") + (f"?{parts.query}" if parts.query else "")
130
+ addresses = _loopback_addresses(host, port)
131
+ connection = http.client.HTTPConnection(host, port, timeout=self._timeout)
132
+ try:
133
+ connection.sock = self._connect(addresses, port)
134
+ connection.request("GET", path, headers={"Accept": "application/json", "Connection": "close", **self._headers})
135
+ response = connection.getresponse()
136
+ if response.status != 200:
137
+ raise OSError(f"the key server answered {response.status}")
138
+ body = response.read(self._max_bytes + 1)
139
+ if len(body) > self._max_bytes:
140
+ raise OSError("the key set is larger than the bound")
141
+ finally:
142
+ connection.close()
143
+ document = json.loads(body)
144
+ listed = document.get("keys") if isinstance(document, dict) else None
145
+ if not isinstance(listed, list):
146
+ raise ValueError("the key set has no keys list")
147
+ import jwt
148
+
149
+ keys = {}
150
+ for entry in listed:
151
+ if isinstance(entry, dict) and entry.get("kty") == "RSA" and isinstance(entry.get("kid"), str):
152
+ try:
153
+ keys[entry["kid"]] = jwt.PyJWK(entry)
154
+ except Exception: # a key that cannot be built is no key
155
+ continue
156
+ return keys
157
+
158
+ def get_signing_key_from_jwt(self, token):
159
+ import jwt
160
+ from jwt.exceptions import PyJWKClientError
161
+
162
+ kid = jwt.get_unverified_header(token).get("kid")
163
+ if not isinstance(kid, str) or not kid:
164
+ raise PyJWKClientError("the token has no key id")
165
+ with self._lock:
166
+ now = time.monotonic()
167
+ stale = self._fetched_at is None or now - self._fetched_at > self._lifespan
168
+ if kid in self._keys and not stale:
169
+ return self._keys[kid]
170
+ if self._attempted_at is None or now - self._attempted_at >= self._refresh_interval:
171
+ self._attempted_at = now
172
+ try:
173
+ self._keys = self._fetch()
174
+ self._fetched_at = now
175
+ except Exception as error:
176
+ self._keys, self._fetched_at = {}, None
177
+ raise PyJWKClientError("the App Check keys could not be fetched") from error
178
+ if kid not in self._keys:
179
+ raise PyJWKClientError(f"unable to find a signing key that matches {kid!r}")
180
+ return self._keys[kid]
181
+
182
+
183
+ def _emulator_service(stock, url: str):
184
+ """The Admin SDK's App Check service, keyed to `url` and without credentials or a way to reach Google.
185
+
186
+ The stock service builds an authorised HTTP client in its constructor (`app.credential.get_credential()`), which
187
+ only limited-use consumption uses; a runner has no Google credential, so the stock service cannot even verify.
188
+ This one is the same verifier (`verify_token`, the header and signature checks are the SDK's own methods) with
189
+ the keys fetched by `LoopbackKeyClient`, consumption refused, and two claim checks the emulator always satisfies.
190
+ The audience rule stays the stock SDK's (`projects/<projectId>`).
191
+ """
192
+ from firebase_admin import exceptions
193
+
194
+ class EmulatorAppCheckService(stock):
195
+ _JWKS_URL = url
196
+
197
+ def __init__(self, app):
198
+ project_id = app.project_id
199
+ if not project_id:
200
+ raise ValueError(
201
+ "A project ID must be specified to access the App Check service. Either set the projectId "
202
+ "option, use service account credentials, or set the GOOGLE_CLOUD_PROJECT environment variable."
203
+ )
204
+ self._project_id = project_id
205
+ self._scoped_project_id = "projects/" + project_id
206
+ self._jwks_client = LoopbackKeyClient(self._JWKS_URL, lifespan=21600, headers=self._APP_CHECK_HEADERS)
207
+ self._http_client = None
208
+
209
+ def _decode_and_verify(self, token, signing_key):
210
+ payload = super()._decode_and_verify(token, signing_key)
211
+ exp = payload.get("exp")
212
+ if isinstance(exp, bool) or not isinstance(exp, (int, float)):
213
+ raise ValueError('The provided App Check token has no "exp" (expiration) claim.')
214
+ issuer = payload.get("iss")
215
+ match = _ISSUER.fullmatch(issuer) if isinstance(issuer, str) else None
216
+ if match is None:
217
+ raise ValueError('The provided App Check token has an incorrect "iss" (issuer) claim.')
218
+ audience = payload.get("aud")
219
+ audiences = [audience] if isinstance(audience, str) else audience if isinstance(audience, list) else []
220
+ numbers = [item[len("projects/"):] for item in audiences if isinstance(item, str) and item.startswith("projects/") and item[len("projects/"):].isdigit()]
221
+ if any(number != match.group(1) for number in numbers):
222
+ raise ValueError('The provided App Check token has a project number in "aud" that is not its issuer\'s.')
223
+ subject = payload.get("sub")
224
+ if not isinstance(subject, str) or not subject:
225
+ # The stock SDK refuses a missing one and lets an empty one through.
226
+ raise ValueError('The provided App Check token has no "sub" (app ID) claim.')
227
+ return payload
228
+
229
+ def _verify_replay_protection(self, token):
230
+ raise exceptions.FailedPreconditionError(
231
+ "limited-use App Check token consumption is not available in fireemu (APP_CHECK_REPLAY_UNSUPPORTED)"
232
+ )
233
+
234
+ return EmulatorAppCheckService
235
+
236
+
237
+ def install_jwks_hook(url: str) -> bool:
238
+ """Replaces the Admin SDK's App Check service with one keyed to `url`. False when this SDK is not the one that
239
+ replacement was written for, or PyJWT is older than the tested floor (the runner then announces nothing, and the
240
+ daemon keeps removing the header)."""
241
+ from firebase_admin import app_check
242
+
243
+ stock = getattr(app_check, "_AppCheckService", None)
244
+ if stock is None or not isinstance(getattr(stock, "_JWKS_URL", None), str):
245
+ return False
246
+ if not all(hasattr(stock, name) for name in _REQUIRED_ATTRIBUTES) or not _pyjwt_supported():
247
+ return False
248
+ app_check._AppCheckService = _emulator_service(stock, url)
249
+ return True
250
+
251
+
252
+ def install_from_environment(environ) -> bool:
253
+ """Installs the hook when the daemon named a loopback JWKS URL; True when the SDK now verifies against it."""
254
+ url = jwks_url(environ)
255
+ return url is not None and install_jwks_hook(url)
@@ -0,0 +1,96 @@
1
+ """The text of an event's JSON body in the layouts the recorded deliveries had.
2
+
3
+ Recorded 2026-11-04 (sandbox run PYCHECK-EVENTS-20261104-01, the raw twins; `tests/recorded_bodies.json`):
4
+
5
+ - an Eventarc custom event: the publisher's own compact text, member order as published (51 and 48 bytes);
6
+ - a Pub/Sub message: sorted compact JSON followed by a newline (348 and 382 bytes);
7
+ - a Cloud Storage object: the object resource indented by two spaces, in the Cloud Storage JSON API member order, followed by a
8
+ newline (963 bytes).
9
+
10
+ A Realtime Database event was not recorded. Numbers keep the spelling they have in the text the daemon framed (`1e-7`, `1e20`,
11
+ `-0.0`): `Number` is a number as written. Characters are written as UTF-8, not as `\\u` escapes (the recorded bodies hold none).
12
+ """
13
+
14
+ import json
15
+
16
+
17
+ class Number(str):
18
+ """A JSON number as it was written."""
19
+
20
+
21
+ def parse_keeping_numbers(text: str):
22
+ """The value of a JSON text with every number kept as `Number`."""
23
+ return json.loads(text, parse_int=Number, parse_float=Number)
24
+
25
+
26
+ def write(value, *, sort_keys: bool = False, indent: int | None = None, level: int = 0) -> str:
27
+ """JSON text of `value`: compact, or with each member on its own line indented by `indent` spaces."""
28
+ if isinstance(value, Number):
29
+ return str(value)
30
+ if isinstance(value, dict):
31
+ members = sorted(value.items()) if sort_keys else list(value.items())
32
+ if not members:
33
+ return "{}"
34
+ parts = [f"{json.dumps(str(k), ensure_ascii=False)}{': ' if indent else ':'}{write(v, sort_keys=sort_keys, indent=indent, level=level + 1)}" for k, v in members]
35
+ return _join("{", parts, "}", indent, level)
36
+ if isinstance(value, list):
37
+ if not value:
38
+ return "[]"
39
+ return _join("[", [write(v, sort_keys=sort_keys, indent=indent, level=level + 1) for v in value], "]", indent, level)
40
+ return json.dumps(value, ensure_ascii=False)
41
+
42
+
43
+ def _join(open_: str, parts: list, close: str, indent: int | None, level: int) -> str:
44
+ if not indent:
45
+ return open_ + ",".join(parts) + close
46
+ inner = "\n" + " " * (indent * (level + 1))
47
+ return open_ + inner + ("," + inner).join(parts) + "\n" + " " * (indent * level) + close
48
+
49
+
50
+ def compact(value) -> str:
51
+ return write(value)
52
+
53
+
54
+ def pubsub_layout(value) -> str:
55
+ """Sorted compact JSON and a newline: what Pub/Sub's push delivery sent (recorded)."""
56
+ return write(value, sort_keys=True) + "\n"
57
+
58
+
59
+ # The member order of a Cloud Storage object resource as the recorded body had it. Members the recording does not show are
60
+ # UNRECORDED: they follow the known ones in alphabetical order.
61
+ _GCS_OBJECT_ORDER = (
62
+ "kind",
63
+ "id",
64
+ "selfLink",
65
+ "name",
66
+ "bucket",
67
+ "generation",
68
+ "metageneration",
69
+ "contentType",
70
+ "timeCreated",
71
+ "updated",
72
+ "storageClass",
73
+ "timeStorageClassUpdated",
74
+ "size",
75
+ "md5Hash",
76
+ "mediaLink",
77
+ "crc32c",
78
+ "etag",
79
+ )
80
+
81
+
82
+ def _sorted_deep(value):
83
+ if isinstance(value, dict):
84
+ return {k: _sorted_deep(v) for k, v in sorted(value.items())}
85
+ if isinstance(value, list):
86
+ return [_sorted_deep(v) for v in value]
87
+ return value
88
+
89
+
90
+ def storage_layout(value) -> str:
91
+ """The object resource in the API's member order, indented by two spaces, and a newline (recorded for the known members)."""
92
+ if isinstance(value, dict):
93
+ known = [(k, _sorted_deep(value[k])) for k in _GCS_OBJECT_ORDER if k in value]
94
+ rest = [(k, _sorted_deep(v)) for k, v in sorted(value.items()) if k not in _GCS_OBJECT_ORDER]
95
+ value = dict(known + rest)
96
+ return write(value, indent=2) + "\n"
@@ -0,0 +1,36 @@
1
+ """Turning a failure into text without trusting the failure.
2
+
3
+ An exception raised by user code can run user code when it is turned into text (`__str__`, `__repr__`, a
4
+ chained cause). With one process environment shared by every invocation, that text must be made while the
5
+ failing invocation still holds its place in the secret gate, and a failure to make it must not lose the result.
6
+ """
7
+
8
+ import inspect
9
+ import traceback
10
+
11
+
12
+ def describe(error: BaseException) -> tuple[str, str]:
13
+ """`(traceback text, short text)` of `error`; never raises, whatever the error's methods do."""
14
+ try:
15
+ diagnostic = "".join(traceback.format_exception(error))
16
+ except BaseException:
17
+ diagnostic = f"{type(error).__name__}: (the error could not be formatted)\n"
18
+ try:
19
+ text = str(error) or type(error).__name__
20
+ except BaseException:
21
+ text = type(error).__name__
22
+ return diagnostic, text
23
+
24
+
25
+ ASYNC_HANDLER = (
26
+ "the function is an async handler: the Python SDK never awaits a handler, so its body would never run "
27
+ "(production skips it silently); declare it with def"
28
+ )
29
+
30
+
31
+ def is_async_handler(function) -> bool:
32
+ """Whether the user's function behind an SDK wrapper is a coroutine function."""
33
+ try:
34
+ return inspect.iscoroutinefunction(inspect.unwrap(function))
35
+ except ValueError: # a cycle of __wrapped__ links: not something this can decide
36
+ return False
@@ -0,0 +1,141 @@
1
+ """Invocation frames: checked against the manifest, run on their own thread, answered with a result."""
2
+
3
+ import os
4
+ import threading
5
+
6
+ from cloudevents.http import from_http
7
+
8
+ from .events import database_request, eventarc_request, firestore_request, pubsub_request, storage_request
9
+ from .request_view import delivery_request
10
+ from .diagnostics import ASYNC_HANDLER, describe, is_async_handler
11
+ from .environment import EnvironmentGate
12
+ from .framing import FrameWriter
13
+ from .scheduler import SchedulerFailure, deliver_schedule
14
+
15
+ MAX_ACTIVE_INVOCATIONS = 4096
16
+ _MAX_ERROR_CHARS = 4096
17
+ _MAX_DIAGNOSTIC_CHARS = 256 * 1024
18
+
19
+ # Trigger types that arrive as an `invoke` frame whose function takes a CloudEvent, and the adapter that builds the request
20
+ # (headers and body) that delivers it.
21
+ _CLOUD_EVENTS = {
22
+ "firestore": firestore_request,
23
+ "pubsub": pubsub_request,
24
+ "storage": storage_request,
25
+ "eventarc": eventarc_request,
26
+ "database": database_request,
27
+ }
28
+ # A Gen2 scheduled function is an HTTP function: the SDK wrapper takes the scheduler's request.
29
+ _TRIGGERS = frozenset((*_CLOUD_EVENTS, "schedule"))
30
+
31
+
32
+ def _deliver(trigger: str, function, event, raw_data: str | None = None) -> None:
33
+ if trigger == "schedule":
34
+ deliver_schedule(function, event.get("data") if isinstance(event, dict) else None)
35
+ else:
36
+ delivery = _CLOUD_EVENTS[trigger](event, raw_data)
37
+ # As functions-framework does: the function runs inside the request that delivers the event. The event is built
38
+ # from the encoding the adapters have always used; the request shows the layout the recorded delivery had.
39
+ with delivery_request(delivery.headers, delivery.request_body, event):
40
+ function(from_http(delivery.headers, delivery.event_body))
41
+
42
+
43
+ # What `FUNCTION_SIGNATURE_TYPE` says for each (the Node runner's `signatureType`).
44
+ _SIGNATURE_TYPES = {"schedule": "http"}
45
+
46
+
47
+ def _bound(text: str, limit: int) -> str:
48
+ return text if len(text) <= limit else text[: limit - 1] + "…"
49
+
50
+
51
+ class Dispatcher:
52
+ """Runs `invoke` frames against the functions the manifest serves."""
53
+
54
+ def __init__(self, functions: dict, manifest: dict, writer: FrameWriter, gate: EnvironmentGate | None = None):
55
+ self._gate = gate if gate is not None else EnvironmentGate({})
56
+ self._functions = functions
57
+ self._specs = {spec["name"]: spec for spec in manifest["functions"]}
58
+ self._writer = writer
59
+ self._active = 0
60
+ self._lock = threading.Lock()
61
+
62
+ def _check(self, message: dict) -> tuple[dict, object]:
63
+ spec = self._specs.get(message["function"])
64
+ if spec is None:
65
+ raise LookupError("unknown function")
66
+ if "entryPoint" in message and message["entryPoint"] != spec["entryPoint"]:
67
+ raise LookupError("entry point does not match the function manifest")
68
+ trigger = spec["trigger"]["type"]
69
+ if message["trigger"] != trigger or trigger not in _TRIGGERS:
70
+ raise LookupError("trigger does not match the function manifest")
71
+ function = self._functions.get(spec["entryPoint"])
72
+ if not callable(function):
73
+ raise LookupError("missing function entry point")
74
+ return spec, function
75
+
76
+ def reserve(self) -> bool:
77
+ with self._lock:
78
+ if self._active >= MAX_ACTIVE_INVOCATIONS:
79
+ return False
80
+ self._active += 1
81
+ return True
82
+
83
+ def start(self, message: dict) -> None:
84
+ """Runs one invoke frame on a new thread; the caller has reserved a slot with `reserve`."""
85
+ threading.Thread(target=self._run, args=(message,), daemon=True, name=f"invoke-{message['invocationId']}").start()
86
+
87
+ def _deliver_held(self, message: dict) -> tuple[str, str] | None:
88
+ """Runs the invocation and returns `(diagnostic, text)` of its failure, or None.
89
+
90
+ The failure is described here, inside the gate: turning an exception into text can run user code
91
+ (`__str__`), and after the invocation leaves the gate another invocation's secrets may be present.
92
+ """
93
+ try:
94
+ spec, function = self._check(message)
95
+ except BaseException as error: # not user code, but a malformed frame: still answered, never raised
96
+ return describe(error)
97
+ if is_async_handler(function):
98
+ return ASYNC_HANDLER, ASYNC_HANDLER
99
+ with self._gate.hold(spec.get("platformOptions", {}).get("secrets")):
100
+ try:
101
+ # One process serves the whole codebase, so these name the invocation that started most
102
+ # recently, not a fixed target (the design memo, section 5).
103
+ os.environ["FUNCTION_TARGET"] = spec["entryPoint"]
104
+ os.environ["FUNCTION_SIGNATURE_TYPE"] = _SIGNATURE_TYPES.get(message["trigger"], "cloudevent")
105
+ os.environ["K_SERVICE"] = spec["name"]
106
+ _deliver(message["trigger"], function, message["event"], message.get("rawEventData"))
107
+ except BaseException as error: # a SystemExit in user code must still answer the daemon
108
+ diagnostic, text = describe(error)
109
+ # A scheduled attempt the SDK wrapper answered 500 has been logged by the SDK; its status is the news.
110
+ return (str(error) if isinstance(error, SchedulerFailure) else diagnostic), text
111
+ return None
112
+
113
+ def _run(self, message: dict) -> None:
114
+ invocation = message["invocationId"]
115
+ try:
116
+ failure = self._deliver_held(message)
117
+ if failure is None:
118
+ result = {"type": "result", "invocationId": invocation, "ok": True}
119
+ else:
120
+ diagnostic, text = failure
121
+ self._send_log("error", f"{message['function']}: {diagnostic}", invocation, message["function"])
122
+ result = {"type": "result", "invocationId": invocation, "ok": False, "error": _bound(text, _MAX_ERROR_CHARS)}
123
+ self._writer.write(result)
124
+ finally:
125
+ with self._lock:
126
+ self._active -= 1
127
+
128
+ def _send_log(self, level: str, text: str, invocation: str, function: str) -> None:
129
+ try:
130
+ self._writer.write(
131
+ {
132
+ "type": "log",
133
+ "level": level,
134
+ "message": _bound(text, _MAX_DIAGNOSTIC_CHARS),
135
+ "invocationId": invocation,
136
+ "functionName": function,
137
+ "user": False,
138
+ }
139
+ )
140
+ except Exception: # a diagnostic can never suppress the result
141
+ pass