Skip to content

Commit a28d515

Browse files
committed
feat: US-195 - Fix node -e stdout not appearing in interactive shell
1 parent 91700c4 commit a28d515

2 files changed

Lines changed: 125 additions & 5 deletions

File tree

packages/kernel/src/kernel.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -427,9 +427,13 @@ class KernelImpl implements Kernel {
427427
// Resolve output callbacks: when a child inherits non-piped stdio from
428428
// a parent, forward output to the parent's DriverProcess callbacks so
429429
// cross-runtime child output reaches the top-level collector.
430+
// When piped, wire a callback that forwards through the pipe/PTY so
431+
// drivers that emit output via callbacks (Node) reach the PTY/pipe.
430432
let stdoutCb: ((data: Uint8Array) => void) | undefined;
431433
let stderrCb: ((data: Uint8Array) => void) | undefined;
432-
if (!stdoutPiped) {
434+
if (stdoutPiped) {
435+
stdoutCb = this.createPipedOutputCallback(table, 1);
436+
} else {
433437
if (options?.onStdout) {
434438
stdoutCb = options.onStdout;
435439
} else if (callerPid !== undefined) {
@@ -440,7 +444,9 @@ class KernelImpl implements Kernel {
440444
}
441445
if (!stdoutCb) stdoutCb = (data) => stdoutBuf.push(data);
442446
}
443-
if (!stderrPiped) {
447+
if (stderrPiped) {
448+
stderrCb = this.createPipedOutputCallback(table, 2);
449+
} else {
444450
if (options?.onStderr) {
445451
stderrCb = options.onStderr;
446452
} else if (callerPid !== undefined) {
@@ -983,6 +989,32 @@ class KernelImpl implements Kernel {
983989
return this.pipeManager.isPipe(entry.description.id) || this.ptyManager.isPty(entry.description.id);
984990
}
985991

992+
/**
993+
* Create a callback that forwards data through a piped stdio FD.
994+
* Needed for drivers (like Node) that emit output via callbacks rather
995+
* than kernel FD writes (like WasmVM does via WASI fd_write).
996+
*/
997+
private createPipedOutputCallback(
998+
table: ProcessFDTable,
999+
fd: number,
1000+
): ((data: Uint8Array) => void) | undefined {
1001+
const entry = table.get(fd);
1002+
if (!entry) return undefined;
1003+
1004+
const descId = entry.description.id;
1005+
if (this.pipeManager.isPipe(descId)) {
1006+
return (data) => {
1007+
try { this.pipeManager.write(descId, data); } catch { /* pipe closed */ }
1008+
};
1009+
}
1010+
if (this.ptyManager.isPty(descId)) {
1011+
return (data) => {
1012+
try { this.ptyManager.write(descId, data); } catch { /* pty closed */ }
1013+
};
1014+
}
1015+
return undefined;
1016+
}
1017+
9861018
/** Clean up all FDs for a process, closing pipe/PTY ends when last reference drops. */
9871019
private cleanupProcessFDs(pid: number): void {
9881020
const table = this.fdTableManager.get(pid);

packages/secure-exec/tests/kernel/cross-runtime-terminal.test.ts

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ try {
2929
// pyodide can't be imported as ESM — skip Python tests
3030
}
3131

32+
/**
33+
* Find a line in the screen output that exactly matches the expected text.
34+
* Excludes lines containing the command echo (prompt line).
35+
*/
36+
function findOutputLine(screen: string, expected: string): string | undefined {
37+
return screen.split('\n').find(
38+
(l) => l.trim() === expected && !l.includes(PROMPT),
39+
);
40+
}
41+
3242
// ---------------------------------------------------------------------------
3343
// Node cross-runtime terminal tests
3444
// ---------------------------------------------------------------------------
@@ -42,21 +52,58 @@ describe.skipIf(wasmSkip)('cross-runtime terminal: node', () => {
4252
await ctx?.dispose();
4353
});
4454

45-
it('node -e "console.log(42)" → 42 appears on screen', async () => {
55+
it('node -e stdout appears as actual output (not just command echo)', async () => {
4656
ctx = await createIntegrationKernel({ runtimes: ['wasmvm', 'node'] });
4757
harness = new TerminalHarness(ctx.kernel);
4858

4959
await harness.waitFor(PROMPT);
50-
await harness.type('node -e "console.log(42)"\n');
60+
// Use XYZZY — unique string that does NOT appear in the command text
61+
await harness.type('node -e "console.log(\'XYZZY\')"\n');
5162
await harness.waitFor(PROMPT, 2, 10_000);
5263

5364
const screen = harness.screenshotTrimmed();
54-
expect(screen).toContain('42');
65+
// Verify output on its own line (not just embedded in command echo)
66+
expect(findOutputLine(screen, 'XYZZY')).toBeDefined();
5567
// Verify prompt returned
5668
const lines = screen.split('\n');
5769
expect(lines[lines.length - 1]).toBe(PROMPT);
5870
}, 15_000);
5971

72+
it('node -e multiple console.log lines appear in order', async () => {
73+
ctx = await createIntegrationKernel({ runtimes: ['wasmvm', 'node'] });
74+
harness = new TerminalHarness(ctx.kernel);
75+
76+
await harness.waitFor(PROMPT);
77+
await harness.type('node -e "console.log(\'AAA\'); console.log(\'BBB\')"\n');
78+
await harness.waitFor(PROMPT, 2, 10_000);
79+
80+
const screen = harness.screenshotTrimmed();
81+
expect(findOutputLine(screen, 'AAA')).toBeDefined();
82+
expect(findOutputLine(screen, 'BBB')).toBeDefined();
83+
84+
// Verify order: AAA before BBB
85+
const aaaIdx = screen.indexOf('AAA');
86+
const bbbIdx = screen.indexOf('BBB');
87+
// Both must appear after command echo
88+
const promptIdx = screen.indexOf(PROMPT);
89+
expect(aaaIdx).toBeGreaterThan(promptIdx);
90+
expect(bbbIdx).toBeGreaterThan(aaaIdx);
91+
}, 15_000);
92+
93+
it('WARN message does not suppress real stdout', async () => {
94+
ctx = await createIntegrationKernel({ runtimes: ['wasmvm', 'node'] });
95+
harness = new TerminalHarness(ctx.kernel);
96+
97+
await harness.waitFor(PROMPT);
98+
await harness.type('node -e "console.log(\'HELLO\')"\n');
99+
await harness.waitFor(PROMPT, 2, 10_000);
100+
101+
const screen = harness.screenshotTrimmed();
102+
// Both the WARN and actual output must coexist
103+
expect(screen).toContain('WARN');
104+
expect(findOutputLine(screen, 'HELLO')).toBeDefined();
105+
}, 15_000);
106+
60107
it('^C during node -e — shell survives and prompt returns', async () => {
61108
ctx = await createIntegrationKernel({ runtimes: ['wasmvm', 'node'] });
62109
harness = new TerminalHarness(ctx.kernel);
@@ -81,6 +128,47 @@ describe.skipIf(wasmSkip)('cross-runtime terminal: node', () => {
81128
}, 20_000);
82129
});
83130

131+
// ---------------------------------------------------------------------------
132+
// Node kernel.exec() stdout tests
133+
// ---------------------------------------------------------------------------
134+
135+
describe.skipIf(wasmSkip)('cross-runtime exec: node', () => {
136+
let ctx: IntegrationKernelResult;
137+
138+
afterEach(async () => {
139+
await ctx?.dispose();
140+
});
141+
142+
it('kernel.exec node -e stdout contains output', async () => {
143+
ctx = await createIntegrationKernel({ runtimes: ['wasmvm', 'node'] });
144+
const result = await ctx.kernel.exec('node -e "console.log(42)"');
145+
expect(result.stdout).toContain('42');
146+
expect(result.exitCode).toBe(0);
147+
});
148+
149+
it('kernel.exec node -e multi-line stdout in order', async () => {
150+
ctx = await createIntegrationKernel({ runtimes: ['wasmvm', 'node'] });
151+
const result = await ctx.kernel.exec(
152+
'node -e "console.log(1); console.log(2)"',
153+
);
154+
const lines = result.stdout.split('\n').map((l: string) => l.trim()).filter(Boolean);
155+
expect(lines).toContain('1');
156+
expect(lines).toContain('2');
157+
expect(lines.indexOf('1')).toBeLessThan(lines.indexOf('2'));
158+
});
159+
160+
it('kernel.exec node -e large stdout does not truncate', async () => {
161+
ctx = await createIntegrationKernel({ runtimes: ['wasmvm', 'node'] });
162+
// Generate >64KB of output (100 lines of 700 chars each ≈ 70KB)
163+
const code = `for(let i=0;i<100;i++) console.log('L'+i+' '+'x'.repeat(700))`;
164+
const result = await ctx.kernel.exec(`node -e "${code}"`);
165+
// Verify first and last lines present
166+
expect(result.stdout).toContain('L0 ');
167+
expect(result.stdout).toContain('L99 ');
168+
expect(result.exitCode).toBe(0);
169+
}, 15_000);
170+
});
171+
84172
// ---------------------------------------------------------------------------
85173
// Python cross-runtime terminal tests
86174
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)