Commit activity
Last 12 weeks · 194 commits
Community health
3 of 6 standards met
Recent PRs & issues
A process's stdout and stderr readers share one log stream, and whichever reader needs data pumps the next record off it and routes it into the right buffer. Pumping was serialized by a plain lock, and a reader only checked its own buffer before queuing for that lock. So if the stderr reader was mid-pump when a stdout line arrived, the line landed in the stdout buffer, but the stdout reader, already waiting on the lock, then took its turn pumping and blocked on the stream for a record that would never come, because the server was waiting for the client to act on the line it had just sent. That is exactly the shape of a stdio MCP session that forwards the server's stderr: the first reply arrives and the second request is never sent. This replaces the lock with a condition. Only one reader pumps at a time, and it reads from the stream without holding the condition; every other reader waits on the condition and rechecks its buffer each time a record is routed, so data routed on its behalf is returned straight away. Buffer reads and writes now also happen under the condition, which matters for the sync reader across threads. The async and sync readers keep sharing the same core, backed by and respectively. A "skip the pump if someone else pumped while I waited" counter fixes the async case, but not the sync one: isn't fair, so the pumping thread re-acquires the lock and blocks on the stream while the other thread's line sits in its buffer. The new sync regression test fails with that approach. Two behaviours are kept on purpose: cancelling the reader that is pumping still breaks the stream for both readers, since the underlying line iterator can't be resumed, and closing the last reader still waits for an in-flight pump to finish before closing the response. The async condition shields its acquire; that's safe because it is never held across a stream read. 🤖 Generated with Claude Code
TypeScript 3.8.0 added an hook to so callers learn a command's ID as soon as it starts, without switching to detached mode and paying for extra round trips. This adds the Python counterpart as , a scoped, -style handle. It takes the same arguments as and makes the same single streaming request. Entering the block reads only the start record, so the handle's ID is available right away while the process keeps running. Leaving the block normally waits for exit and applies . Leaving it through an exception closes the stream without waiting for, or killing, the process. On , only the step that opens the stream takes part in lifecycle recovery, so nothing raised inside the block can cause the command to run a second time. now uses the same stream-reading code, and its behaviour is unchanged. We considered teaching to be both awaitable and a context manager, but it doesn't fit the sync API. Sync runs eagerly and returns a , so by the time a statement could enter it the process would already have exited. Making it lazy would break every existing . In async, returning an operation object instead of a coroutine would also break callers that use or . A separately named method behaves the same way in both APIs and leaves alone. One thing to watch for: async and sync drive output differently, following the split interactive sessions already use. The async handle pumps output to its destinations from a task group while the block runs. The sync handle starts no threads. Output is read only while you iterate the handle or call , and anything unread stays in the connection rather than in an SDK buffer, the same as readers. Callers who want output drained continuously can hand to their own thread. 🤖 Generated with Claude Code
Sandbox commands can now take standard input, so the Python SDK no longer raises for it. Rather than copying the TypeScript / methods, this follows : starts the command with and exposes a writer on , writes and closes it, and feeds a command it waits on. In the async API is an , so it works with , task groups, and anything else that sends to an AnyIO stream under asyncio or Trio. In the sync API it is a writable that can be wrapped in . Processes from and always have a writer, matching the TypeScript SDK, where any from can write to stdin. That lets you write to a process started by someone else. Together these cover the stdio MCP server case: one task sends JSON-RPC lines while another reads responses from . Writes go to the new stdin endpoint as base64, in 512 KiB requests, serialized behind a FIFO lock so concurrent sends arrive in call order. Every request carries the offset the service has confirmed so far. That makes network errors, 429, 502, and 503 safe to resend with the same body, and 504 timeouts are resent for as long as the process keeps reading. After the process exits or stops reading, writes raise ( in sync code), and / ignore that the way a shell ignores a closed pipe. Any other failure raises the new . If that failure may have delivered part of a write, the stream refuses further writes but can still be closed. Things reviewers may want to look at: The first stdin call on a process sends an empty request to read the confirmed offset, as the TypeScript SDK does. Following vercel/sandbox#381 there is no fallback for sandboxes without offsets. If that response has no , the write fails before sending anything rather than assuming offset 0. A 504 that doesn't report bytes written counts as stalled, and no extra request is made to find out. writes the input from inside the NDJSON loop, right after the initial metadata record arrives, and does not read output meanwhile. That keeps one code path for sync and async and relies on the service buffering output. If writing fails the command is killed, and the error is a rather than a raw , so sandbox lifecycle recovery can never re-run a command that has already started. A looked-up handle learns the confirmed offset from that first empty request, so its writes continue after whatever other handles wrote. Two handles writing at the same time will lose input, though. Each handle sends its own idea of the offset, and the service drops bytes it thinks it already has. The TypeScript SDK has the same limitation and documents it the same way. The service returns both for a process that was never started with stdin and for one that stopped reading. For handles this SDK started, that can only mean "stopped reading", so it is treated as a closed pipe. For a looked-up handle, a first request that comes back unavailable raises ("not started with stdin=subprocess.PIPE, or stopped reading"). That way can't silently drop input sent to a process without stdin. Once one request has succeeded, a later unavailable error means "stopped reading" again. A distinct error code from the service would let this be exact. on a looked-up process only closes stdin if it was given input or the handle has been written to. Otherwise, fetching a running stdio server just to read its output would send it end-of-file. Processes created with always close, as in . handles take the same and options. The run-as-user launcher already passes the original stdin through to the command, so nothing changes in the wrapper scripts. construction now defaults to no writer, and each lookup path asks for one explicitly. That way a user process created without doesn't pick up a looked-up-style writer. Garbage collection does not close the sync stream, since closing takes a network request. The public Python docs still say stdin is unsupported. They need a follow-up once this ships. 🤖 Generated with Claude Code
Recent fixes
Rui's change from , rebased onto . Both of his commits are unchanged and still authored by him. Why stores events and steps one directory per run (, ) since vercel/workflow#4718. here still writes the flat layout. The Python e2e conformance lane in vercel/workflow runs this SDK's and the TypeScript driver against one . The two must agree on the layout, so is pinned to the fork branch. With this merged, that pin can go back to , which also picks up #448. What stores event and step files under and , with unchanged file names, matching . Reading a run lists only that run's files. At startup, a data directory holding only old flat-layout event and step files is deleted, as does. A directory that mixes both layouts, or holds files in or that this package did not write, raises and is left untouched. Startup fails if wiping a flat directory fails. Adds a news fragment. The only change on top of Rui's commits is a follow-up that updates the new test to , since #370 removed . Tests , plus updates to existing LocalWorld tests. unit tests, lint and mypy pass locally.
Process output reaches the SDK over long-lived NDJSON responses, and a dropped connection anywhere in one of them used to be fatal. For that was especially bad: the command keeps running in the sandbox, but the caller gets an error (or a "missing final metadata" response error if the stream simply ended) and the obvious reaction is to run it again. This ports the approach from vercel/sandbox#374 to the Python SDK. The logs endpoint replays a command's output from the beginning on every connection, so recovery is a matter of reconnecting and skipping what has already been delivered. A new helper in counts delivered characters per stream and drops the replayed prefix, regardless of how the replay happens to be split into records. It backs / readers and . When the stream breaks after the initial process record, the API client hands the started process and its delivered offsets to the service, which follows the remaining output through the logs endpoint and then the wait endpoint instead of starting the command again. If the connection fails before the process record arrives nothing is retried, since we can't know whether the command started. (and therefore ) also retries transport errors, 429s, and 5xx responses. Once the process has started, nothing in the follow-up may rerun it. If the logs or wait request reports that the sandbox stopped (a 410, or a 422 /), raises with that lifecycle code and the API error as its cause, the same error it raises when the stop is reported in-band on the run stream. That keeps the sandbox-level resume-and-replay from running the command a second time; it still applies to failures before the process starts. Separately, the API reports losing its own connection to the sandbox as , either as an in-band record or, before the first record, as an HTTP 410. That doesn't mean the sandbox stopped, so it's now a reason to reconnect in both forms rather than a terminal error or a lifecycle stop. If the sandbox really did stop, the retried request says so through the normal lifecycle error. A few things worth a closer look. Streams are now read as raw bytes through an incremental UTF-8 decoder instead of , so we can tell an unterminated final line from a complete one: a final record cut off mid-JSON counts as an interruption, while a malformed newline-terminated record keeps its existing behavior (skipped for logs, for ). Offsets are counted in Python code points rather than the UTF-16 units the JS SDK uses, which works because they're only ever compared against the same server's replay. Retries are bounded at two per interruption with 200ms/400ms backoff, but unlike the JS version a reconnection that delivers new output resets that budget, so a long-running process's readers can survive more than two blips over its lifetime. A clean end of the logs response is still treated as the end of the output, as in the JS SDK. The vercel/sandbox SDK still replays a started command when its post-start recovery hits a lifecycle error, so this is intentionally stricter than TS. 🤖 Generated with Claude Code
Repository: vercel/vercel-py. Description: Develop. Preview. Ship. Python. Stars: 32, Forks: 13. Primary language: Python. Languages: Python (99.9%), Shell (0.1%). License: MIT. Homepage: https://vercel.com/docs Latest release: vercel-v0.12.0 (5d ago). Open PRs: 38, open issues: 7. Last activity: 7m ago. Community health: 62%. Top contributors: scotttrinh, fantix, msullivan, ricardo-agz, elprans, brookemosby, vercel[bot], nsidnev, vvo, 1st1 and others.