From ce9bf5df49cbd57fb524fa13d5378cd8a3669f44 Mon Sep 17 00:00:00 2001 From: Dipesh Babu Date: Tue, 29 Sep 2026 00:10:00 -0400 Subject: [PATCH] fix(sdk): validate exec status before yielding exit Signed-off-by: Dipesh Babu --- architecture/gateway.md | 2 ++ docs/sdk/typescript.mdx | 4 ++++ sdk/typescript/src/client.test.ts | 38 +++++++++++++++++++++++++++++++ sdk/typescript/src/client.ts | 12 ++++++---- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index 1e3b761d2d..6e9563f81d 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -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 diff --git a/docs/sdk/typescript.mdx b/docs/sdk/typescript.mdx index 96a093fb3f..699c490db1 100644 --- a/docs/sdk/typescript.mdx +++ b/docs/sdk/typescript.mdx @@ -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 diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index 56dcdf3f2b..39cad7c657 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -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'), diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 2f26a71f91..b5e82dd72d 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -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 { @@ -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); }