Skip to content

plimsoll v0.21.0

Latest

Choose a tag to compare

@carrollco carrollco released this 09 Oct 21:34
· 7 commits to main since this release

Every answer from the daemon is now tied to the request it answers, so a proxy that serves one request's answer for another can no longer make a call that ran read "nothing ran". The daemon also marks the refusals made before its own code sees a request, so they read "nothing ran" instead of "may have run", and closes a session whose open answer never reached its client at its first idle timeout instead of keeping it for its lifetime. The Go and TypeScript clients now mark a call that never left as not dispatched, as the Python client already did. Two grant-matching gaps from a security scan are closed, and the TypeScript client is published to npm by the release workflow with a provenance statement. This is protocol 3: read Upgrading before you deploy it.

Upgrading

  • Protocol 3: upgrade clients and daemons together. A v0.21.0 daemon refuses a request from an older client before reading it (unimplemented, marked not dispatched, reason protocol), and an older daemon refuses a v0.21.0 client the same way. Nothing runs in either case.
  • Pass two response headers through any proxy. Every answer carries Plimsoll-Request-Id (the client's ID, echoed) and some carry Plimsoll-Not-Dispatched. A proxy that drops response headers it does not know makes every answer read as not bound to its request: every successful call becomes a data-loss error, although the code ran.
  • A grant profile whose * widens a capped route's fixed segment no longer loads. With route_max_calls capping GET /jobs/ep1, an entry GET /jobs/* let ep1.json, ep1. or 0123 reach an upstream that serves them as the capped route, uncounted. Load fails and names both entries: move the cap to the wider route, or list the routes it should reach instead of the *.
  • A * now binds only RFC 3986's unreserved characters: letters, digits and -._~. It no longer binds $ ($batch runs other requests on OData), +, @ or =, which an upstream may read as syntax. A route segment that needs one is granted as a literal.

Answers bound to their request

Found by our failure-injection review, which put a proxy between each client and the daemon and broke the connection at every stage.

  • The request ID. Every official client (Go, Python, TypeScript) sends a fresh random Plimsoll-Request-Id, 32 lowercase hex digits, with every request. The daemon copies it onto every answer: its own refusals, the RPC library's, and an HTTP 404 included.
  • An answer without the ID, or with another one, is believed in nothing. A success becomes a data-loss error, since the code may have run (Go client.ErrAnswerNotBound, Python AnswerNotBoundError, TypeScript data_loss with answerNotBound). An error keeps its code but loses its "nothing ran" mark, its session end and its unanswered-call record. Before, a refusal captured from one request and served for a later one read "nothing ran" in all three clients while the daemon ran the later request, and placement or the code tools could then run it again. A replayed success for an identical request also passed the record check, which binds a result to the request's content, not to the request.
  • Refusals before the handler are marked. A body the RPC library cannot parse, one over its 8 MiB cap, an unsupported content type, or a procedure the daemon does not serve never reaches plimsoll's code, so nothing can have run. They carried no mark and read "may have run". The daemon now adds Plimsoll-Not-Dispatched (unsupported for a 404, request otherwise), and the clients read it when the error has no detail of its own.

A session nobody claimed is closed

Also from the failure-injection review. If the answer to an OpenSession never reached its client (a dropped connection, an answer refused for not carrying its request's ID back, a client that crashed after sending), the session lived on for its whole lifetime, 30 minutes by default: nobody could use or close it, it held a place under SANDBOX_MAX_SESSIONS and the per-caller cap, and on E2B the open's reserved paid time. The daemon now closes a session no request has named by its first idle timeout (5 minutes by default; 5 minutes too when SANDBOX_SESSION_IDLE=0), with the new session end unclaimed, instead of suspending it. A call naming the session claims it, even one refused before it ran. The daemon cannot tell a lost answer from a client that opened a session ahead of calls that did not come, so it closes both; nothing ran in either.

What it costs: a client that opens a session and makes its first call after the idle timeout gets that call refused, not dispatched, with the end unclaimed, and opens a new one; nothing ran in the closed session, so nothing is lost. CodeSandboxes does that by itself, so a warm that its conversation does not use within the idle timeout costs one extra open.

Clients

The first four are also from the failure-injection review.

  • Go and TypeScript mark a call that never left. A failure before any byte of the request was sent is marked not dispatched, reason environment, as Python's has been since v0.20.0: Go's placement then tries another daemon, and a session stays usable in all three clients. Go marks a refused or unreachable connection, a name that did not resolve, and a TLS handshake that failed (a cleartext port, an untrusted certificate). TypeScript marks only the three causes Node names with a code that was reproduced: a refused connection (ECONNREFUSED), TLS against a cleartext port (ERR_SSL_WRONG_VERSION_NUMBER) and a self-signed certificate (DEPTH_ZERO_SELF_SIGNED_CERT). Any other failure stays unmarked, since it does not prove the request stayed here.
  • Python's request timeout covers the name lookup. The daemon's host name was looked up outside both the request timeout and CancelHandle, so a resolver that did not answer held the call for as long as the resolver took. The lookup now runs in a thread of its own: at the deadline the call raises RequestTimeoutError, and a cancel returns at once, both marked not dispatched. Python cannot interrupt a lookup, so its thread runs on until the resolver answers; while 32 such lookups are still running, a new one is refused at once, marked environment. An IP address in the URL is not looked up.
  • Python refuses a base URL whose host it could never look up. A host with an empty label (plimsoll..example) or a label over 63 characters is now InvalidBaseURLError when the Client is built. Before, every call to it raised a UnicodeError, which is not a PlimsollError.
  • Python returns a chunked answer that arrived whole. The client counted an answer as complete only when it matched its Content-Length, and the daemon sends any answer over 2048 bytes in chunks without one. So if the deadline or a cancel came in the instant after such an answer arrived, the client discarded it as timed out or cancelled, and a session stopped. An answer read through its final chunk now counts as complete.
  • Go and Python say when the client stopped a session. After a session call whose answer the client could not check, the client sends nothing more on that session. Go's Session.Stopped() and Python's Session.stopped (and AsyncSession.stopped) now say why, as TypeScript's Session.stopped already did. Before, a caller found out only by sending a call and reading the refusal. Ended() and ended still report only an end the daemon stated. In Go, Ended() no longer waits for a call in flight, and Stopped() does not either.
  • @plimsollmark/client is published by the public repository's release workflow through npm's trusted publishing: no npm token exists, and npm records which repository, commit and workflow run built each version.

Contributors

  • A test server must be built by rpc.NewHandler, which wraps it in rpc.BindAnswers: the clients now refuse an answer that does not echo their request ID, so a test server built another way turns every success into a data-loss error. Every test server in the repository was moved to it.
  • make e2b-session-live also runs TestLiveE2BDaemonClosesAnUnclaimedSession, which goes through the daemon and the Go client against the live E2B service. The provider's conformance suite never reached the daemon, where the unclaimed close lives. It is paid like the rest of the target: one microVM for about its 10 s idle timeout.
  • @plimsollmark/client is published by publish-npm.yml when a GitHub release is published, with the same tag and source checks as the Python workflow; releasing.md has npm's side of the setup.