Skip to content

Commit 48d6c9d

Browse files
committed
feat: US-083 - Add WasmVM terminal tests: echo, ls, output preservation
Add terminal-level tests for real brush-shell commands through the full WasmVM stack using @xterm/headless for screen-state verification. Key changes: - Add ONLCR output processing to PTY slave write path (POSIX standard) - Fix WasmVM driver to route stdout/stderr through kernel fdWrite for PTY (was only routing pipes, not character devices) - Pass ttyFds to worker so brush-shell detects interactive mode - Implement getIno/getInodeByIno in kernel VFS adapter for WASI path ops - Add @xterm/headless devDep and TerminalHarness to wasmvm test dir Tests: echo, output preservation, export pass with exact screen matching. cd and ls are .todo (pre-existing WASI path resolution / proc_spawn issues).
1 parent e3370a0 commit 48d6c9d

9 files changed

Lines changed: 542 additions & 25 deletions

File tree

packages/kernel/src/pty.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,18 +138,20 @@ export class PtyManager {
138138
return this.processInput(state, data);
139139
} else {
140140
// Slave write → output buffer (master reads)
141+
// ONLCR: convert \n to \r\n (standard POSIX terminal output processing)
141142
if (state.closed.slave) throw new KernelError("EIO", "slave closed");
142143
if (state.closed.master) throw new KernelError("EIO", "master closed");
143144

145+
const processed = this.processOutput(data);
144146
if (state.outputWaiters.length > 0) {
145147
const waiter = state.outputWaiters.shift()!;
146-
waiter(data);
148+
waiter(processed);
147149
} else {
148150
// Enforce buffer limit to prevent unbounded memory growth
149-
if (this.bufferBytes(state.outputBuffer) + data.length > MAX_PTY_BUFFER_BYTES) {
151+
if (this.bufferBytes(state.outputBuffer) + processed.length > MAX_PTY_BUFFER_BYTES) {
150152
throw new KernelError("EAGAIN", "PTY output buffer full");
151153
}
152-
state.outputBuffer.push(new Uint8Array(data));
154+
state.outputBuffer.push(new Uint8Array(processed));
153155
}
154156
}
155157

@@ -316,6 +318,35 @@ export class PtyManager {
316318
return ref.ptyId;
317319
}
318320

321+
// -------------------------------------------------------------------
322+
// Output processing (ONLCR)
323+
// -------------------------------------------------------------------
324+
325+
/** Convert lone \n to \r\n in output data (POSIX ONLCR). */
326+
private processOutput(data: Uint8Array): Uint8Array {
327+
// Fast path: no newlines → return as-is
328+
if (!data.includes(0x0a)) return data;
329+
330+
// Count lone LFs (not preceded by CR) to size the result buffer
331+
let extraCRs = 0;
332+
for (let i = 0; i < data.length; i++) {
333+
if (data[i] === 0x0a && (i === 0 || data[i - 1] !== 0x0d)) {
334+
extraCRs++;
335+
}
336+
}
337+
if (extraCRs === 0) return data;
338+
339+
const result = new Uint8Array(data.length + extraCRs);
340+
let j = 0;
341+
for (let i = 0; i < data.length; i++) {
342+
if (data[i] === 0x0a && (i === 0 || data[i - 1] !== 0x0d)) {
343+
result[j++] = 0x0d; // CR
344+
}
345+
result[j++] = data[i];
346+
}
347+
return result;
348+
}
349+
319350
// -------------------------------------------------------------------
320351
// Line discipline input processing
321352
// -------------------------------------------------------------------

packages/kernel/test/kernel-integration.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1960,7 +1960,7 @@ describe("kernel + MockRuntimeDriver integration", () => {
19601960
proc.kill();
19611961
});
19621962

1963-
it("write to slave → read from master", async () => {
1963+
it("write to slave → read from master (ONLCR converts \\n to \\r\\n)", async () => {
19641964
const driver = new MockRuntimeDriver(["proc"], {
19651965
proc: { neverExit: true },
19661966
});
@@ -1973,8 +1973,9 @@ describe("kernel + MockRuntimeDriver integration", () => {
19731973
const msg = new TextEncoder().encode("hello\n");
19741974
ki.fdWrite(proc.pid, slaveFd, msg);
19751975

1976+
// Slave output goes through ONLCR: \n → \r\n (POSIX default)
19761977
const data = await ki.fdRead(proc.pid, masterFd, 1024);
1977-
expect(new TextDecoder().decode(data)).toBe("hello\n");
1978+
expect(new TextDecoder().decode(data)).toBe("hello\r\n");
19781979

19791980
proc.kill();
19801981
});

packages/runtime/wasmvm/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
},
1919
"devDependencies": {
2020
"@types/node": "^22.10.2",
21+
"@xterm/headless": "^6.0.0",
2122
"typescript": "^5.7.2",
2223
"vitest": "^2.1.8"
2324
}

packages/runtime/wasmvm/src/driver.ts

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -129,21 +129,28 @@ class WasmVmRuntimeDriver implements RuntimeDriver {
129129
};
130130
});
131131

132-
// Set up stdin pipe so writeStdin/closeStdin deliver data through kernel FD 0
133-
const stdinPipe = kernel.pipe(ctx.pid);
134-
kernel.fdDup2(ctx.pid, stdinPipe.readFd, 0);
135-
kernel.fdClose(ctx.pid, stdinPipe.readFd);
136-
const stdinWriteFd = stdinPipe.writeFd;
132+
// Set up stdin pipe for writeStdin/closeStdin — skip if FD 0 is already
133+
// a PTY slave (interactive shell: stdin flows through kernel PTY)
134+
const stdinIsPty = kernel.isatty(ctx.pid, 0);
135+
let stdinWriteFd: number | undefined;
136+
if (!stdinIsPty) {
137+
const stdinPipe = kernel.pipe(ctx.pid);
138+
kernel.fdDup2(ctx.pid, stdinPipe.readFd, 0);
139+
kernel.fdClose(ctx.pid, stdinPipe.readFd);
140+
stdinWriteFd = stdinPipe.writeFd;
141+
}
137142

138143
const proc: DriverProcess = {
139144
onStdout: null,
140145
onStderr: null,
141146
onExit: null,
142147
writeStdin: (data: Uint8Array) => {
143-
kernel.fdWrite(ctx.pid, stdinWriteFd, data);
148+
if (stdinWriteFd !== undefined) kernel.fdWrite(ctx.pid, stdinWriteFd, data);
144149
},
145150
closeStdin: () => {
146-
try { kernel.fdClose(ctx.pid, stdinWriteFd); } catch { /* already closed */ }
151+
if (stdinWriteFd !== undefined) {
152+
try { kernel.fdClose(ctx.pid, stdinWriteFd); } catch { /* already closed */ }
153+
}
147154
},
148155
kill: (_signal: number) => {
149156
const worker = this._activeWorkers.get(ctx.pid);
@@ -169,12 +176,13 @@ class WasmVmRuntimeDriver implements RuntimeDriver {
169176
this._kernel = null;
170177
}
171178

172-
/** Check if a process's FD is a pipe via kernel FD stat. */
173-
private _isFdPiped(pid: number, fd: number): boolean {
179+
/** Check if a process's FD is routed through kernel (pipe or PTY). */
180+
private _isFdKernelRouted(pid: number, fd: number): boolean {
174181
if (!this._kernel) return false;
175182
try {
176183
const stat = this._kernel.fdStat(pid, fd);
177-
return stat.filetype === 6; // FILETYPE_PIPE
184+
if (stat.filetype === 6) return true; // FILETYPE_PIPE
185+
return this._kernel.isatty(pid, fd); // PTY slave
178186
} catch {
179187
return false;
180188
}
@@ -197,10 +205,16 @@ class WasmVmRuntimeDriver implements RuntimeDriver {
197205
const signalBuf = new SharedArrayBuffer(SIGNAL_BUFFER_BYTES);
198206
const dataBuf = new SharedArrayBuffer(DATA_BUFFER_BYTES);
199207

200-
// Check if stdio FDs are piped by inspecting the kernel FD table
201-
const stdinPiped = this._isFdPiped(ctx.pid, 0);
202-
const stdoutPiped = this._isFdPiped(ctx.pid, 1);
203-
const stderrPiped = this._isFdPiped(ctx.pid, 2);
208+
// Check if stdio FDs are kernel-routed (pipe or PTY)
209+
const stdinPiped = this._isFdKernelRouted(ctx.pid, 0);
210+
const stdoutPiped = this._isFdKernelRouted(ctx.pid, 1);
211+
const stderrPiped = this._isFdKernelRouted(ctx.pid, 2);
212+
213+
// Detect which FDs are TTYs (PTY slaves) for brush-shell interactive mode
214+
const ttyFds: number[] = [];
215+
for (const fd of [0, 1, 2]) {
216+
if (kernel.isatty(ctx.pid, fd)) ttyFds.push(fd);
217+
}
204218

205219
const workerData: WorkerInitData = {
206220
wasmBinaryPath: this._wasmBinaryPath,
@@ -212,10 +226,11 @@ class WasmVmRuntimeDriver implements RuntimeDriver {
212226
cwd: ctx.cwd,
213227
signalBuf,
214228
dataBuf,
215-
// Tell worker which stdio channels are piped so it routes writes correctly
229+
// Tell worker which stdio channels are kernel-routed
216230
stdinFd: stdinPiped ? 99 : undefined,
217231
stdoutFd: stdoutPiped ? 99 : undefined,
218232
stderrFd: stderrPiped ? 99 : undefined,
233+
ttyFds: ttyFds.length > 0 ? ttyFds : undefined,
219234
};
220235

221236
const workerUrl = new URL('./kernel-worker.ts', import.meta.url);

packages/runtime/wasmvm/src/kernel-worker.ts

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,45 @@ function createKernelProcessIO(): WasiProcessIO {
175175
function createKernelVfs(): WasiVFS {
176176
const decoder = new TextDecoder();
177177

178+
// Inode cache for getIno/getInodeByIno — synthesizes inodes from kernel VFS stat
179+
let nextIno = 1;
180+
const pathToIno = new Map<string, number>();
181+
const inoCache = new Map<number, WasiInode>();
182+
183+
function resolveIno(path: string): number | null {
184+
const cached = pathToIno.get(path);
185+
if (cached !== undefined) return cached;
186+
187+
const res = rpcCall('vfsStat', { path });
188+
if (res.errno !== 0) return null;
189+
190+
// RPC response fields: { type, mode, uid, gid, nlink, size, atime, mtime, ctime }
191+
const raw = JSON.parse(decoder.decode(res.data)) as Record<string, unknown>;
192+
const ino = nextIno++;
193+
pathToIno.set(path, ino);
194+
195+
const nodeType = raw.type as string ?? 'file';
196+
const isDir = nodeType === 'dir';
197+
const node: WasiInode = {
198+
type: nodeType,
199+
mode: (raw.mode as number) ?? (isDir ? 0o40755 : 0o100644),
200+
uid: (raw.uid as number) ?? 0,
201+
gid: (raw.gid as number) ?? 0,
202+
nlink: (raw.nlink as number) ?? 1,
203+
size: (raw.size as number) ?? 0,
204+
atime: (raw.atime as number) ?? Date.now(),
205+
mtime: (raw.mtime as number) ?? Date.now(),
206+
ctime: (raw.ctime as number) ?? Date.now(),
207+
};
208+
209+
if (isDir) {
210+
node.entries = new Map();
211+
}
212+
213+
inoCache.set(ino, node);
214+
return ino;
215+
}
216+
178217
return {
179218
exists(path: string): boolean {
180219
const res = rpcCall('vfsExists', { path });
@@ -241,11 +280,11 @@ function createKernelVfs(): WasiVFS {
241280
chmod(_path: string, _mode: number): void {
242281
// No-op — permissions handled by kernel
243282
},
244-
getIno(_path: string): number | null {
245-
return null;
283+
getIno(path: string): number | null {
284+
return resolveIno(path);
246285
},
247-
getInodeByIno(_ino: number): WasiInode | null {
248-
return null;
286+
getInodeByIno(ino: number): WasiInode | null {
287+
return inoCache.get(ino) ?? null;
249288
},
250289
snapshot(): VfsSnapshotEntry[] {
251290
return [];
@@ -357,6 +396,34 @@ function createHostProcessImports(getMemory: () => WebAssembly.Memory | null) {
357396
view.setUint32(ret_write_fd_ptr, writeFd, true);
358397
return ERRNO_SUCCESS;
359398
},
399+
400+
/** fd_dup(fd, ret_new_fd) -> errno */
401+
fd_dup(fd: number, ret_new_fd_ptr: number): number {
402+
const mem = getMemory();
403+
if (!mem) return ERRNO_EINVAL;
404+
405+
const res = rpcCall('fdDup', { fd });
406+
if (res.errno !== 0) return res.errno;
407+
408+
new DataView(mem.buffer).setUint32(ret_new_fd_ptr, res.intResult, true);
409+
return ERRNO_SUCCESS;
410+
},
411+
412+
/** proc_getpid(ret_pid) -> errno */
413+
proc_getpid(ret_pid_ptr: number): number {
414+
const mem = getMemory();
415+
if (!mem) return ERRNO_EINVAL;
416+
417+
new DataView(mem.buffer).setUint32(ret_pid_ptr, init.pid, true);
418+
return ERRNO_SUCCESS;
419+
},
420+
421+
/** sleep_ms(milliseconds) -> errno — blocks via Atomics.wait */
422+
sleep_ms(milliseconds: number): number {
423+
const buf = new Int32Array(new SharedArrayBuffer(4));
424+
Atomics.wait(buf, 0, 0, milliseconds);
425+
return ERRNO_SUCCESS;
426+
},
360427
};
361428
}
362429

@@ -422,7 +489,7 @@ async function main(): Promise<void> {
422489
const userManager = new UserManager({
423490
getMemory,
424491
fdTable,
425-
ttyFds: false,
492+
ttyFds: init.ttyFds ? new Set(init.ttyFds) : false,
426493
});
427494

428495
const hostProcess = createHostProcessImports(getMemory);

packages/runtime/wasmvm/src/syscall-rpc.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,6 @@ export interface WorkerInitData {
9090
stdoutFd?: number;
9191
/** FD override for stderr (pipe write end in parent's table, or undefined). */
9292
stderrFd?: number;
93+
/** Which stdio FDs are TTYs (for brush-shell interactive mode detection). */
94+
ttyFds?: number[];
9395
}

0 commit comments

Comments
 (0)