Skip to content

Commit a3c1d53

Browse files
committed
wsgi: remove problematic send(resource) workaround
With the WIT updates, there were errors in the WSGI framework code around its use of http_req.send in order to attempt to cause a resource.drop on requests when doing connection reuse. Componentize-py, without any added workarounds, knows to call resource.drop as a finalizer on resources. Finalizers execute when the refcount of an object goes to zero. With the updates, we do a bit of extra work to remove references to resources and add in assertions based on weak references in order to ensure that we don't have lingering referneces to request resources. Use of a context manager was considered, but in this case I did not find a way that use of one would be feasible or reduce complexity. In componentize-py, the only path to calling resource.drop I found related to finalizers. Even if there were a method to explicitly issue a resource.drop, the behavior might be undefined with outstanding references, so I think this change still makes sense. By default, if there are lingering references to request resources under connection reuse, we simply will not reuse connections and print a warning.
1 parent 28abd64 commit a3c1d53

2 files changed

Lines changed: 76 additions & 62 deletions

File tree

examples/game-of-life.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,4 +264,6 @@ def root():
264264

265265

266266
if running_under_compute:
267-
HttpIncoming = WsgiHttpIncoming(app, reuse_sandboxes_for_ms=300)
267+
HttpIncoming = WsgiHttpIncoming(
268+
app, reuse_sandboxes_for_ms=300, assert_on_uncollected_resource=True
269+
)

fastly_compute/wsgi.py

Lines changed: 73 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,16 @@
88
# a top-level import componentize-py won't include the encoding in our final artifact
99
# and we get runtime LookupErrors when werkzeug tries to use the codec.
1010
import encodings.idna # noqa: F401
11+
import gc
1112
import sys
1213
import traceback
14+
import weakref
1315
from collections.abc import Callable
1416
from typing import Any
1517
from urllib.parse import urlparse
1618

1719
from wit_world.exports import HttpIncoming as WitHttpIncoming
18-
from wit_world.imports import http_body, http_resp
19-
from wit_world.imports.http_downstream import (
20-
NextRequestOptions,
21-
await_request,
22-
next_request,
23-
)
24-
from wit_world.imports.http_req import send
25-
from wit_world.imports.http_resp import send_downstream
20+
from wit_world.imports import async_io, http_body, http_downstream, http_req, http_resp
2621
from wit_world.imports.types import Err, Error_CannotRead
2722

2823

@@ -94,7 +89,7 @@ def start_response(
9489
write(body_chunk)
9590

9691
# Send the complete response downstream
97-
send_downstream(response, response_body)
92+
http_resp.send_downstream(response, response_body)
9893

9994
except Exception as e:
10095
if not handle_errors:
@@ -110,7 +105,25 @@ def start_response(
110105
error_response.append_header("content-type", b"text/plain")
111106
error_message = f"Internal Server Error: {e}"
112107
http_body.write(error_body, error_message.encode(), http_body.WriteEnd.BACK)
113-
send_downstream(error_response, error_body)
108+
http_resp.send_downstream(error_response, error_body)
109+
110+
111+
def _await_request(pending_request: async_io.Pollable) -> None | (
112+
http_req.Request,
113+
async_io.Pollabe,
114+
):
115+
try:
116+
return http_downstream.await_request(pending_request)
117+
except Err as exc:
118+
# TODO: Improve error design so we can catch only the exceptions
119+
# we're really interested in, per Python's idiom. Rather than
120+
# carting around a Result type that's Union[Ok[T], Err[E]], we
121+
# should probably return T xor raise E.
122+
if isinstance(exc.value, Error_CannotRead):
123+
return None
124+
else:
125+
# Something went wrong.
126+
raise
114127

115128

116129
class WsgiHttpIncoming(WitHttpIncoming):
@@ -140,6 +153,7 @@ def __init__(
140153
wsgi_app: Callable,
141154
handle_errors: bool = False,
142155
reuse_sandboxes_for_ms: int = 0,
156+
assert_on_uncollected_resource: bool = False,
143157
):
144158
"""Construct.
145159
@@ -148,72 +162,70 @@ def __init__(
148162
500-status response.
149163
:arg reuse_sandboxes_for_ms: If non-0, keep the sandbox alive for this
150164
many milliseconds to potentially serve additional requests.
165+
:arg assert_on_uncollected_resource: If True, if there are references
166+
to request that linger past that request being handled, an assertion
167+
will be raised (when reusing connections). If False, connection
168+
reuse will be disabled if this occurs.
151169
"""
152170
self.wsgi_app = wsgi_app
153171
self.handle_errors = handle_errors
154172
self.reuse_sandboxes_for_ms = reuse_sandboxes_for_ms
173+
self.assert_on_uncollected_resource = assert_on_uncollected_resource
155174

156175
def __call__(self):
157176
return self
158177

159-
def handle(self, request: Any, body: Any) -> None:
160-
"""Handle incoming HTTP requests by serving them through the WSGI app."""
178+
def _handle_single_request(self, request: http_req.Request, body: Any) -> None:
161179
serve_wsgi_request(
162180
request,
163181
body,
164182
self.wsgi_app,
165183
handle_errors=self.handle_errors,
166184
)
167185

168-
if not self.reuse_sandboxes_for_ms:
169-
return
170-
171-
try:
172-
# Drop (in the WIT sense) the `request` resource to get ready for
173-
# another request. Otherwise, we crash.
174-
#
175-
# Here we abuse an arbitrary request-consuming function to trigger
176-
# the drop. Glue code interposed by wasmtime's linker ensures that
177-
# drop happens, but send() otherwise fails before doing anything.
178-
send(request, body, "no such backend")
179-
180-
# TODO: Generate a proper drop_whatever() function for each
181-
# "whatever" resource.
182-
#
183-
# Alternately, it might suffice for the runtime to drop() things
184-
# that get GC'd (i.e. `del` or otherwise) by Python. If we put an
185-
# idiomatic .close() or similar on, for example, a potentially large
186-
# request body, we could implement it in terms of `del`.
187-
except Err:
188-
pass
189-
else:
190-
raise RuntimeError(
191-
"Our use of send() to consume the previous request unexpectedly actually performed a send."
192-
)
193-
194-
options = NextRequestOptions(timeout_ms=self.reuse_sandboxes_for_ms, extra=None)
186+
def _attempt_reuse(self):
187+
options = http_downstream.NextRequestOptions(
188+
timeout_ms=self.reuse_sandboxes_for_ms, extra=None
189+
)
195190
while True:
196-
pending_request = next_request(options)
197-
try:
198-
result = await_request(pending_request)
199-
except Err as exc:
200-
# TODO: Improve error design so we can catch only the exceptions
201-
# we're really interested in, per Python's idiom. Rather than
202-
# carting around a Result type that's Union[Ok[T], Err[E]], we
203-
# should probably return T xor raise E.
204-
if isinstance(exc.value, Error_CannotRead):
205-
# There were no more requests within the timeout.
206-
break
207-
else:
208-
# Something went wrong.
209-
raise
191+
# See if there's another request incoming
192+
pending_request = http_downstream.next_request(options)
193+
maybe_req_body = _await_request(pending_request)
194+
if not maybe_req_body:
195+
break
196+
request, body = maybe_req_body
197+
reqwref = weakref.ref(request)
198+
with request as request, body as body:
199+
maybe_req_body = None
200+
self._handle_single_request(request, body)
201+
del request
202+
del body
203+
204+
req_dropped = reqwref() is None
205+
if not req_dropped:
206+
# force a gc in case it is in a cycle; this may not be optimal
207+
# but worth a shot if the user is trying to make enable
208+
# reuse.
209+
gc.collect()
210+
req_dropped = reqwref() is None
211+
212+
if self.assert_on_uncollected_resource:
213+
assert req_dropped, "Request outlives handler!"
210214
else:
211-
if not result:
215+
if not req_dropped:
216+
# TODO: use proper logging at some point
217+
print(
218+
"Request remained referenced past WSGI handler, not doing connection reuse"
219+
)
212220
break
213-
request, body = result
214-
serve_wsgi_request(
215-
request,
216-
body,
217-
self.wsgi_app,
218-
handle_errors=self.handle_errors,
219-
)
221+
222+
def handle(self, request: Any, body: Any) -> None:
223+
"""Handle incoming HTTP requests by serving them through the WSGI app."""
224+
# always handle the first request coming in normally
225+
self._handle_single_request(request, body)
226+
del request
227+
del body
228+
229+
# then attempt reuse if configured
230+
if self.reuse_sandboxes_for_ms:
231+
return self._attempt_reuse()

0 commit comments

Comments
 (0)