Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ it does not establish that a mutation is safe to repeat. SDKs retain the origina
transport status, metadata, and unknown details alongside decoded fields.
SDK deletion waits recognize missing-resource status through typed error wrappers
without suppressing other failures.
TypeScript exec streams expose the command exit only after consuming the final
RPC status, so stopping at the exit event cannot hide a transport failure.

Ordinary user-callable unary mutations explicitly opt into durable request
admission when the client supplies a UUID. Typed adapters
Expand Down
4 changes: 4 additions & 0 deletions docs/sdk/typescript.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ SSH sessions, sandbox provider attachment, configuration, and policy. Close
operation-scoped streams and forwarding handles when finished. The root client
does not retain a dedicated session and has no `close()` method.

`execStream()` yields stdout and stderr chunks as they arrive. It yields the
terminal exit event only after the RPC finishes successfully. Transport failures
reject the stream even if the gateway already sent a command exit code.

## Use the Raw Client

Use `client.raw` for RPCs that the curated clients do not yet wrap. Import
Expand Down
38 changes: 38 additions & 0 deletions sdk/typescript/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,44 @@ describe('exec / execStream', () => {
expect(result.stdout.toString()).toBe('boom');
});

it('checks the terminal RPC status before exposing exit to a caller that stops there', async () => {
const sandbox = client({
getSandbox: () => readySandbox('sb', 'sb-id-1'),
execSandbox: async function* () {
yield { payload: { case: 'stdout', value: { data: enc('partial') } } };
yield { payload: { case: 'exit', value: { exitCode: 0 } } };
throw new ConnectError('relay failed', Code.Unavailable);
},
});

const output: string[] = [];
await expect(
(async () => {
for await (const event of sandbox.execStream('sb', ['x'])) {
if ('type' in event) break;
output.push(event.data.toString());
}
})(),
).rejects.toMatchObject({ code: 'rpc', connectCode: Code.Unavailable });
expect(output).toEqual(['partial']);
});

it.each(['stdout', 'exit'] as const)('rejects %s received after an exit event', async (payloadCase) => {
const sandbox = client({
getSandbox: () => readySandbox('sb', 'sb-id-1'),
execSandbox: async function* () {
yield { payload: { case: 'exit', value: { exitCode: 0 } } };
if (payloadCase === 'stdout') {
yield { payload: { case: 'stdout', value: { data: enc('too late') } } };
} else {
yield { payload: { case: 'exit', value: { exitCode: 7 } } };
}
},
});

await expect(sandbox.exec('sb', ['x'])).rejects.toMatchObject({ code: 'rpc' });
});

it('execStream throws when the stream ends without an exit event', async () => {
const sandbox = client({
getSandbox: () => readySandbox('sb', 'sb-id-1'),
Expand Down
12 changes: 8 additions & 4 deletions sdk/typescript/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1184,8 +1184,11 @@ export class SandboxClient {
{ signal: options?.signal },
);

let sawExit = false;
let exitCode: number | undefined;
for await (const event of stream) {
if (exitCode !== undefined) {
throw new SdkError('rpc', 'ExecSandbox received an event after exit');
}
switch (event.payload.case) {
case 'stdout':
yield {
Expand All @@ -1200,12 +1203,13 @@ export class SandboxClient {
};
break;
case 'exit':
sawExit = true;
yield { type: 'exit', exitCode: event.payload.value.exitCode };
exitCode = event.payload.value.exitCode;
break;
}
}
if (!sawExit) throw new SdkError('rpc', 'ExecSandbox stream ended without an exit event');
if (exitCode === undefined) throw new SdkError('rpc', 'ExecSandbox stream ended without an exit event');
// Consume the final RPC status before callers can stop at the exit event.
yield { type: 'exit', exitCode };
} catch (e) {
throw e instanceof SdkError ? e : fromConnect(e);
}
Expand Down
Loading