Skip to content

Commit 752063d

Browse files
test(exec): cover fair duplex progress and live SDK completion
Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>
1 parent 7b3f415 commit 752063d

6 files changed

Lines changed: 173 additions & 17 deletions

File tree

architecture/gateway.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,8 @@ input. The gateway sends SSH EOF while keeping the output channel open until
876876
command completion. Input errors terminate the operation rather than masquerading
877877
as normal EOF. The input and output pumps are owned by the exec operation, so
878878
timeout or response abandonment cannot leave a detached stdin task behind.
879+
The pumps share polling fairly, and request processing yields cooperatively even
880+
for ignored resize messages, so sustained input cannot monopolize the operation.
879881

880882
Go and TypeScript interactive-exec helpers distinguish process exit from stream
881883
completion. They consume the final gRPC status before reporting success and retain

crates/openshell-server/src/grpc/interactive_exec_tests.rs

Lines changed: 97 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ use russh::server::{Auth, ChannelOpenHandle, Handler, Msg, Session};
99
use std::time::Duration;
1010

1111
struct ExecPeer {
12-
echo: bool,
12+
channel: Option<russh::Channel<Msg>>,
13+
echo_tx: Option<mpsc::UnboundedSender<Vec<u8>>>,
1314
input: Arc<std::sync::Mutex<Vec<u8>>>,
1415
eof: Arc<AtomicBool>,
1516
release_output: Arc<tokio::sync::Notify>,
@@ -33,11 +34,12 @@ impl Handler for ExecPeer {
3334

3435
async fn channel_open_session(
3536
&mut self,
36-
_channel: russh::Channel<Msg>,
37+
channel: russh::Channel<Msg>,
3738
reply: ChannelOpenHandle,
3839
_session: &mut Session,
3940
) -> Result<(), Self::Error> {
4041
reply.accept().await;
42+
self.channel = Some(channel);
4143
Ok(())
4244
}
4345

@@ -48,7 +50,27 @@ impl Handler for ExecPeer {
4850
session: &mut Session,
4951
) -> Result<(), Self::Error> {
5052
session.channel_success(channel)?;
51-
self.echo = data == b"duplex";
53+
if data == b"duplex" {
54+
let channel = self.channel.take().unwrap();
55+
let release = self.release_output.clone();
56+
let (echo_tx, mut echo_rx) = mpsc::unbounded_channel::<Vec<u8>>();
57+
self.echo_tx = Some(echo_tx);
58+
self.output_task = Some(tokio::spawn(async move {
59+
let (reader, writer) = channel.split();
60+
// The handler receives stdin independently of output flow
61+
// control, as a real process with separate I/O pumps would.
62+
drop(reader);
63+
while let Some(data) = echo_rx.recv().await {
64+
writer.data_bytes(data.clone()).await.unwrap();
65+
writer.extended_data_bytes(1, data).await.unwrap();
66+
}
67+
release.notified().await;
68+
writer.exit_status(7).await.unwrap();
69+
writer.close().await.unwrap();
70+
}));
71+
} else {
72+
self.channel.take();
73+
}
5274
if data == b"early" {
5375
session.exit_status_request(channel, 0)?;
5476
session.close(channel)?;
@@ -61,14 +83,13 @@ impl Handler for ExecPeer {
6183

6284
async fn data(
6385
&mut self,
64-
channel: russh::ChannelId,
86+
_channel: russh::ChannelId,
6587
data: &[u8],
66-
session: &mut Session,
88+
_session: &mut Session,
6789
) -> Result<(), Self::Error> {
6890
self.input.lock().unwrap().extend_from_slice(data);
69-
if self.echo {
70-
session.data(channel, data.to_vec())?;
71-
session.extended_data(channel, 1, data.to_vec())?;
91+
if let Some(tx) = &self.echo_tx {
92+
tx.send(data.to_vec()).unwrap();
7293
}
7394
Ok(())
7495
}
@@ -79,6 +100,10 @@ impl Handler for ExecPeer {
79100
session: &mut Session,
80101
) -> Result<(), Self::Error> {
81102
self.eof.store(true, Ordering::SeqCst);
103+
self.echo_tx.take();
104+
if self.output_task.is_some() {
105+
return Ok(());
106+
}
82107
let release = self.release_output.clone();
83108
let handle = session.handle();
84109
self.output_task = Some(tokio::spawn(async move {
@@ -135,7 +160,8 @@ impl Fixture {
135160
let eof = Arc::new(AtomicBool::new(false));
136161
let release_output = Arc::new(tokio::sync::Notify::new());
137162
let handler = ExecPeer {
138-
echo: false,
163+
channel: None,
164+
echo_tx: None,
139165
input: input.clone(),
140166
eof: eof.clone(),
141167
release_output: release_output.clone(),
@@ -228,10 +254,12 @@ async fn interactive_exec_drains_stdout_and_stderr_after_input_eof() {
228254
async fn interactive_exec_makes_progress_in_both_directions_before_eof() {
229255
const CHUNKS: usize = 128;
230256
const CHUNK_SIZE: usize = 64 * 1024;
257+
const BATCH: usize = 4;
231258
let fixture = Fixture::new().await;
232259
let (input_tx, input_rx) = mpsc::channel(16);
233260
let (output_tx, mut output_rx) = mpsc::channel(2);
234261
let drained = tokio::sync::Notify::new();
262+
let progress = std::cell::Cell::new((0, 0));
235263
let exec = run_interactive_exec_with_russh(
236264
fixture.port,
237265
"duplex",
@@ -243,24 +271,27 @@ async fn interactive_exec_makes_progress_in_both_directions_before_eof() {
243271
output_tx,
244272
);
245273
let writer = async {
246-
for _ in 0..CHUNKS {
274+
for chunk in 1..=CHUNKS {
247275
input_tx
248276
.send(Ok(ExecSandboxInput {
249277
payload: Some(exec_sandbox_input::Payload::Stdin(vec![b'x'; CHUNK_SIZE])),
250278
}))
251279
.await
252280
.unwrap();
281+
if chunk % BATCH == 0 {
282+
drained.notified().await;
283+
}
253284
}
254285
// Keep the request stream open until BOTH output streams have drained.
255-
// The payload exceeds SSH windows and all bridge queues, so buffering
256-
// the entire exchange cannot masquerade as concurrent progress.
257-
drained.notified().await;
286+
// Bound in-flight data to exercise sustained interactive traffic without
287+
// saturating both ends of the fixture's SSH transport simultaneously.
258288
drop(input_tx);
259289
};
260290
let reader = async {
261291
ready(&mut output_rx).await;
262292
let mut stdout = 0;
263293
let mut stderr = 0;
294+
let mut acknowledged = 0;
264295
while stdout < CHUNKS * CHUNK_SIZE || stderr < CHUNKS * CHUNK_SIZE {
265296
let bytes = match output_rx.recv().await.unwrap().unwrap().payload.unwrap() {
266297
exec_sandbox_event::Payload::Stdout(s) => {
@@ -271,26 +302,76 @@ async fn interactive_exec_makes_progress_in_both_directions_before_eof() {
271302
stderr += s.data.len();
272303
s.data
273304
}
274-
event => panic!("unexpected event: {event:?}"),
305+
event @ exec_sandbox_event::Payload::Exit(_) => {
306+
panic!("unexpected event: {event:?}")
307+
}
275308
};
276309
assert!(bytes.iter().all(|b| *b == b'x'));
310+
progress.set((stdout, stderr));
277311
assert!(!fixture.eof.load(Ordering::SeqCst));
312+
if stdout.min(stderr) >= acknowledged + BATCH * CHUNK_SIZE {
313+
acknowledged += BATCH * CHUNK_SIZE;
314+
drained.notify_one();
315+
}
278316
}
279317
assert_eq!(stdout, CHUNKS * CHUNK_SIZE);
280318
assert_eq!(stderr, CHUNKS * CHUNK_SIZE);
281-
drained.notify_one();
282319
fixture.release_output.notify_one();
283320
while output_rx.recv().await.is_some() {}
284321
};
285322
let (result, (), ()) = tokio::time::timeout(Duration::from_secs(30), async {
286323
tokio::join!(exec, writer, reader)
287324
})
288325
.await
289-
.expect("stdin and stdout/stderr must make progress without request EOF");
326+
.unwrap_or_else(|_| {
327+
panic!(
328+
"duplex stalled: input={}, output={:?}, eof={}",
329+
fixture.input.lock().unwrap().len(),
330+
progress.get(),
331+
fixture.eof.load(Ordering::SeqCst)
332+
)
333+
});
290334
assert_eq!(result.unwrap(), 7);
291335
assert_eq!(fixture.input.lock().unwrap().len(), CHUNKS * CHUNK_SIZE);
292336
}
293337

338+
#[tokio::test]
339+
async fn interactive_exec_ready_resize_stream_does_not_starve_output() {
340+
use futures::StreamExt;
341+
use std::sync::atomic::AtomicUsize;
342+
343+
// Finite to make a regression fail rather than wedge the runtime forever.
344+
// These frames have no SSH write await because this session has no PTY.
345+
const FRAMES: usize = 100_000;
346+
let fixture = Fixture::new().await;
347+
let consumed = AtomicUsize::new(0);
348+
let input = futures::stream::repeat_with(|| {
349+
consumed.fetch_add(1, Ordering::SeqCst);
350+
Ok(ExecSandboxInput {
351+
payload: Some(exec_sandbox_input::Payload::Resize(
352+
openshell_core::proto::ExecSandboxWindowResize::default(),
353+
)),
354+
})
355+
})
356+
.take(FRAMES);
357+
let (output_tx, mut output_rx) = mpsc::channel(2);
358+
let exec =
359+
run_interactive_exec_with_russh(fixture.port, "test", input, false, false, 0, 0, output_tx);
360+
let reader = async {
361+
ready(&mut output_rx).await;
362+
assert!(
363+
consumed.load(Ordering::SeqCst) < FRAMES,
364+
"output must be delivered before the continuously ready input ends"
365+
);
366+
drop(output_rx);
367+
};
368+
let (result, ()) =
369+
tokio::time::timeout(Duration::from_secs(5), async { tokio::join!(exec, reader) })
370+
.await
371+
.unwrap();
372+
assert_eq!(result.unwrap_err().code(), tonic::Code::Cancelled);
373+
}
374+
294375
#[tokio::test]
295376
async fn interactive_exec_input_error_is_not_graceful_eof() {
296377
for message in [

e2e/python/test_sandbox_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ def requests():
217217
"printf '%s' \"$input\"; printf 'drained-stderr' >&2; exit 7",
218218
],
219219
tty=False,
220-
timeout_seconds=20,
220+
execution_timeout=duration_pb2.Duration(seconds=20),
221221
)
222222
)
223223
yield openshell_pb2.ExecSandboxInput(stdin=b"drained-stdout")

sdk/typescript/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ mise run sdk:ts:lint # Biome: lint + format check (read-only)
297297
mise run sdk:ts:typecheck # tsc --noEmit
298298
mise run sdk:ts:test # Vitest unit tests with an 80% line-coverage gate
299299
mise run sdk:ts:build # emit dist/
300+
mise run e2e:sdk:ts:exec # public interactive helper against an isolated Docker gateway
300301
```
301302

302303
Formatting and linting are handled by [Biome](https://biomejs.dev) (`biome.json`): 2-space indent, single quotes, semicolons, 120-column width. Generated `src/gen/` is excluded. `sdk:ts:lint` runs in CI as part of `sdk:ts:ci`.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Run through mise run e2e:sdk:ts:exec, which supplies an isolated gateway.
5+
import assert from 'node:assert/strict';
6+
import { readFileSync } from 'node:fs';
7+
import { join } from 'node:path';
8+
import { test } from 'node:test';
9+
import { SandboxClient } from '../dist/index.js';
10+
11+
test('public interactive helper drains output after input EOF and verifies completion', {
12+
timeout: 300_000,
13+
}, async () => {
14+
assert.ok(process.env.XDG_CONFIG_HOME, 'run with the Docker gateway wrapper');
15+
assert.ok(process.env.OPENSHELL_GATEWAY, 'run with the Docker gateway wrapper');
16+
const dir = join(process.env.XDG_CONFIG_HOME, 'openshell', 'gateways', process.env.OPENSHELL_GATEWAY);
17+
const metadata = JSON.parse(readFileSync(join(dir, 'metadata.json'), 'utf8'));
18+
const client = await SandboxClient.connect({
19+
gateway: metadata.gateway_endpoint,
20+
...(metadata.gateway_endpoint.startsWith('https:')
21+
? {
22+
caCert: readFileSync(join(dir, 'mtls', 'ca.crt')),
23+
clientCert: readFileSync(join(dir, 'mtls', 'tls.crt')),
24+
clientKey: readFileSync(join(dir, 'mtls', 'tls.key')),
25+
}
26+
: {}),
27+
});
28+
const name = `ts-eof-${Date.now().toString(36)}`;
29+
await client.create({
30+
name,
31+
image: process.env.OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE ?? 'ghcr.io/nvidia/openshell-community/sandboxes/base:latest',
32+
});
33+
try {
34+
await client.waitReady(name, 180);
35+
const session = await client.execInteractive(
36+
name,
37+
['/bin/sh', '-c', 'input=$(cat); printf "stdout:%s" "$input"; printf "stderr:drained" >&2; exit 7'],
38+
{ tty: false, timeoutSecs: 30, signal: AbortSignal.timeout(45_000) },
39+
);
40+
try {
41+
// cat cannot finish until closeInput reaches the sandbox. All command
42+
// output therefore proves that request EOF left the response open.
43+
session.write(Buffer.from('input-before-eof'));
44+
session.closeInput();
45+
session.closeInput(); // The public control remains idempotent.
46+
const stdout = [];
47+
const stderr = [];
48+
const exits = [];
49+
for await (const event of session.output) {
50+
assert.equal(exits.length, 0, 'exit must be the final application event');
51+
if ('type' in event) exits.push(event.exitCode);
52+
else (event.stream === 'stdout' ? stdout : stderr).push(event.data);
53+
}
54+
assert.equal(Buffer.concat(stdout).toString(), 'stdout:input-before-eof');
55+
assert.equal(Buffer.concat(stderr).toString(), 'stderr:drained');
56+
assert.deepEqual(exits, [7]);
57+
// A nonzero process exit is not a failed transport. done resolves only
58+
// after the helper has verified the final gRPC status.
59+
assert.equal(await session.done, 7);
60+
assert.equal(session.exitCode, 7);
61+
} finally {
62+
session.cancel();
63+
}
64+
} finally {
65+
await client.delete(name);
66+
}
67+
});

tasks/typescript.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ depends = ["sdk:ts:proto"]
5959
dir = "sdk/typescript"
6060
run = "npm test"
6161

62+
["e2e:sdk:ts:exec"]
63+
description = "Test public TypeScript interactive exec against a Docker-backed gateway"
64+
depends = ["sdk:ts:build"]
65+
run = "e2e/with-docker-gateway.sh node --test sdk/typescript/e2e/interactive-exec.mjs"
66+
6267
["sdk:ts:ci"]
6368
description = "TypeScript SDK checks (proto lint, Biome lint, codegen, typecheck, test, build)"
6469
depends = [

0 commit comments

Comments
 (0)