diff --git a/.gitignore b/.gitignore index ce7734c..70d7dbf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ tsconfig.tsbuildinfo playwright-report/ test-results/ test/package-lock.json +*.data *.js *.wasm cockle_wasm_env/ diff --git a/README.md b/README.md index a0d8bcc..5ab4363 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,15 @@ The commands used here are either built-in commands implemented in TypeScript, o commands compiled into `.js` and `.wasm` files. The latter are built by [Emscripten-forge](https://emscripten-forge.org/) and are added to a deployment during the build process. +[Emscripten-forge](https://emscripten-forge.org/) packages containing WebAssembly commands that are +currently supported and tested are as follows. Each package contains a single commmand with the same +name as the package unless otherwise specified: + +- `coreutils`: multiple core commands including `cat`, `cp`, `echo`, `ls`, `mkdir`, `mv`, `rm`, `touch`, `uname`, and `wc` +- `grep` +- `lua` +- `vim` + ## Build ```bash diff --git a/demo/cockle-config-in.json b/demo/cockle-config-in.json index 3c8c0f0..99dbd5e 100644 --- a/demo/cockle-config-in.json +++ b/demo/cockle-config-in.json @@ -5,5 +5,8 @@ { "package": "local-cmd", "local_directory": "../test/local-packages" + }, + { + "package": "vim" } ] diff --git a/demo/ui-tests/demo.test.ts-snapshots/visual-test-1-chromium-linux.png b/demo/ui-tests/demo.test.ts-snapshots/visual-test-1-chromium-linux.png index 1073d40..421f74e 100644 Binary files a/demo/ui-tests/demo.test.ts-snapshots/visual-test-1-chromium-linux.png and b/demo/ui-tests/demo.test.ts-snapshots/visual-test-1-chromium-linux.png differ diff --git a/src/buffered_io.ts b/src/buffered_io.ts index 2e8000a..d8d6ffe 100644 --- a/src/buffered_io.ts +++ b/src/buffered_io.ts @@ -86,11 +86,11 @@ abstract class BufferedIO { protected _enabled: boolean = false; protected _sharedArrayBuffer: SharedArrayBuffer; - protected _maxReadChars: number = 8; // Max number of actual characters in a token. + protected _maxReadChars: number = 64; // Max number of actual characters in a token. protected _readArray: Int32Array; protected _readCount: number = 0; - protected _maxWriteChars: number = 128; // Multiples of this can be sent consecutively. + protected _maxWriteChars: number = 256; // Multiples of this can be sent consecutively. protected _writeArray: Int32Array; } @@ -278,8 +278,34 @@ export class WorkerBufferedIO extends BufferedIO { this._allowAdjacentNewline = set; } + /** + * Poll for whether readable (there is input ready to read) and/or writable. + * Currently assumes always writable. + */ + poll(timeoutMs: number): number { + // Constants. + const POLLIN = 1; + const POLLOUT = 4; + + const t = timeoutMs > 0 ? timeoutMs : 0; + const readableCheck = Atomics.wait(this._readArray, READ_MAIN, this._readCount, t); + const readable = readableCheck === 'not-equal'; + + const writable = true; + return (readable ? POLLIN : 0) | (writable ? POLLOUT : 0); + } + read(): number[] { - Atomics.wait(this._readArray, READ_MAIN, this._readCount); + if ((this.termios.c_iflag & InputFlag.IXON) > 0) { + // Wait for main worker to store a new input characters. + Atomics.wait(this._readArray, READ_MAIN, this._readCount); + } + + const readCount = Atomics.load(this._readArray, READ_MAIN); + if (readCount === this._readCount) { + return []; + } + const read = this._loadFromSharedArrayBuffer(); this._readCount++; @@ -292,6 +318,10 @@ export class WorkerBufferedIO extends BufferedIO { return ret; } + get termios(): Termios { + return this._termios; + } + write(text: string | Int8Array | number[]): void { let chars: number[] = []; if (typeof text === 'string') { @@ -428,7 +458,7 @@ export class WorkerBufferedIO extends BufferedIO { return ret; } - public termios: Termios = Termios.newDefaultWasm(); + private _termios: Termios = Termios.newDefaultWasm(); private _allowAdjacentNewline = false; private _writeColumn = 0; } diff --git a/src/commands/wasm_command_runner.ts b/src/commands/wasm_command_runner.ts index ee5d603..ae81fe7 100644 --- a/src/commands/wasm_command_runner.ts +++ b/src/commands/wasm_command_runner.ts @@ -21,7 +21,7 @@ export abstract class WasmCommandRunner implements ICommandRunner { let _getCharBuffer: number[] = []; // Functions for monkey-patching. - function getChar(tty: any) { + function getChar(tty: any): number | null { if (_getCharBuffer.length > 0) { return _getCharBuffer.shift()!; } @@ -58,6 +58,10 @@ export abstract class WasmCommandRunner implements ICommandRunner { ]; } + function poll(stream: any, timeoutMs: number): number { + return bufferedIO.poll(timeoutMs); + } + function write( stream: any, buffer: Int8Array, @@ -97,35 +101,38 @@ export abstract class WasmCommandRunner implements ICommandRunner { exitCode = moduleExitCode; } }, - preRun: (module: any) => { - if (Object.prototype.hasOwnProperty.call(module, 'FS')) { - // Use PROXYFS so that command sees the shared FS. - const FS = module.FS; - FS.mkdir(mountpoint, 0o777); - FS.mount(fileSystem.PROXYFS, { root: mountpoint, fs: fileSystem.FS }, mountpoint); - FS.chdir(fileSystem.FS.cwd()); + preRun: [ + (module: any) => { + if (Object.prototype.hasOwnProperty.call(module, 'FS')) { + // Use PROXYFS so that command sees the shared FS. + const FS = module.FS; + FS.mkdir(mountpoint, 0o777); + FS.mount(fileSystem.PROXYFS, { root: mountpoint, fs: fileSystem.FS }, mountpoint); + FS.chdir(fileSystem.FS.cwd()); + } + + if (Object.prototype.hasOwnProperty.call(module, 'ENV')) { + // Copy environment variables into command. + context.environment.copyIntoCommand(module.ENV, stdout.supportsAnsiEscapes()); + } + + if (Object.prototype.hasOwnProperty.call(module, 'TTY')) { + // Monkey patch get/set termios and get window size. + module.TTY.default_tty_ops.ioctl_tcgets = getTermios; + module.TTY.default_tty_ops.ioctl_tcsets = setTermios; + module.TTY.default_tty_ops.ioctl_tiocgwinsz = getWindowSize; + + // May only need to be for some TTYs? + module.TTY.stream_ops.write = write; + module.TTY.stream_ops.poll = poll; + + // Monkey patch stdin get_char. + const stdinDeviceId = module.FS.makedev(5, 0); + const stdinTty = module.TTY.ttys[stdinDeviceId]; + stdinTty.ops.get_char = getChar; + } } - - if (Object.prototype.hasOwnProperty.call(module, 'ENV')) { - // Copy environment variables into command. - context.environment.copyIntoCommand(module.ENV, stdout.supportsAnsiEscapes()); - } - - if (Object.prototype.hasOwnProperty.call(module, 'TTY')) { - // Monkey patch get/set termios and get window size. - module.TTY.default_tty_ops.ioctl_tcgets = getTermios; - module.TTY.default_tty_ops.ioctl_tcsets = setTermios; - module.TTY.default_tty_ops.ioctl_tiocgwinsz = getWindowSize; - - // Monkey patch write. - module.TTY.stream_ops.write = write; - - // Monkey patch stdin get_char. - const stdinDeviceId = module.FS.makedev(5, 0); - const stdinTty = module.TTY.ttys[stdinDeviceId]; - stdinTty.ops.get_char = getChar; - } - } + ] }); if (exitCode === undefined) { diff --git a/src/termios.ts b/src/termios.ts index 57f4ff0..2f7017b 100644 --- a/src/termios.ts +++ b/src/termios.ts @@ -34,7 +34,7 @@ export enum LocalFlag { ECHOCTL = 0x0200, // Terminal special characters are echoed ECHOPRT = 0x0400, // Characters are printed as they are erased ECHOKE = 0x0800, // KILL is echoed by erasing each character on the line - IEXTEN = 0x8000 // Enable implementation-dewfined input processing + IEXTEN = 0x8000 // Enable implementation-defined input processing } export enum ControlCharacter { @@ -77,6 +77,27 @@ export class Termios implements ITermios { }; } + // Log to console for debug purposes. + log() { + const enumHelper = (enumType: any, name: string, enumValue: any) => { + const s: string[] = []; + for (const [k, v] of Object.entries(enumType).filter(([k, v]) => k[0].match(/\D/))) { + if ((enumValue & (v as number)) > 0) { + s.push(k); + } + } + return ` ${name} = ${enumValue} 0x${enumValue.toString(16)} = ${s.join(' ')}`; + }; + + const log: string[] = ['Termios:']; + log.push(enumHelper(InputFlag, 'c_iflag', this.c_iflag)); + log.push(enumHelper(OutputFlag, 'c_oflag', this.c_oflag)); + log.push(` c_cflag = ${this.c_cflag} 0x${this.c_cflag.toString(16)}`); + log.push(enumHelper(LocalFlag, 'c_lflag', this.c_lflag)); + log.push(` c_cc = ${this.c_cc}`); + console.log(log.join('\n')); + } + static newDefaultWasm(): Termios { const ret = new Termios(); ret.setDefaultWasm(); @@ -89,6 +110,7 @@ export class Termios implements ITermios { this.c_cflag = iTermios.c_cflag; this.c_lflag = iTermios.c_lflag; this.c_cc = [...iTermios.c_cc]; // Shallow copy. + this.log(); } setDefaultWasm(): void { @@ -139,6 +161,7 @@ export class Termios implements ITermios { 0, 0 ]; + this.log(); } c_iflag: InputFlag = 0 as InputFlag; diff --git a/src/tools/prepare_wasm.ts b/src/tools/prepare_wasm.ts index 5241de7..e8e4c38 100644 --- a/src/tools/prepare_wasm.ts +++ b/src/tools/prepare_wasm.ts @@ -213,13 +213,26 @@ fs.writeFileSync(targetConfigFile, JSON.stringify(cockleConfig, null, 2)); const filenames = [targetConfigFile]; // Output wasm files and their javascript wrappers. +const requiredSuffixes = { + '.js': true, + '.wasm': true, + '.data': false, + '-fs.js': false, + '-fs.data': false +}; for (const packageConfig of cockleConfig) { const sourceDirectory = packageConfig.local_directory ?? path.join(envPath, 'bin'); const moduleNames = packageConfig.modules.map((x: any) => x.name); for (const moduleName of moduleNames) { - for (const suffix of ['.js', '.wasm']) { + for (const [suffix, required] of Object.entries(requiredSuffixes)) { const filename = moduleName + suffix; const srcFilename = path.join(sourceDirectory, filename); + if (!fs.existsSync(srcFilename)) { + if (required) { + throw new Error(`No such file: ${srcFilename}`); + } + continue; + } if (wantCopy) { const targetFileName = path.join(target, filename); fs.copyFileSync(srcFilename, targetFileName); diff --git a/test/cockle-config-in.json b/test/cockle-config-in.json index 19f8cd4..2405d5e 100644 --- a/test/cockle-config-in.json +++ b/test/cockle-config-in.json @@ -2,6 +2,9 @@ { "package": "lua" }, + { + "package": "vim" + }, { "package": "local-cmd", "local_directory": "local-packages" diff --git a/test/tests/command/vim.test.ts b/test/tests/command/vim.test.ts new file mode 100644 index 0000000..5e0b971 --- /dev/null +++ b/test/tests/command/vim.test.ts @@ -0,0 +1,34 @@ +import { expect } from '@playwright/test'; +import { shellLineSimple, test } from '../utils'; + +test.describe('vim command', () => { + test('should output version', async ({ page }) => { + const output = await shellLineSimple(page, 'vim --version'); + expect(output).toMatch(/^vim --version\r\nVIM - Vi IMproved 9.1/); + }); + + test('should run interactively and exit', async ({ page }) => { + await page.evaluate(async () => { + const { shell } = await globalThis.cockle.shellSetupEmpty(); + await Promise.all([ + shell.inputLine('vim'), + globalThis.cockle.terminalInput(shell, ['\x1b', ':', 'q', '\r']) + ]); + }); + }); + + test('should create new file', async ({ page }) => { + const output = await page.evaluate(async () => { + const { shell, output } = await globalThis.cockle.shellSetupEmpty(); + await Promise.all([ + shell.inputLine('vim'), + globalThis.cockle.terminalInput(shell, [...'ihi QW\x1b:wq out\r']) + ]); + // New file should exist. + output.clear(); + await shell.inputLine('cat out'); + return output.text; + }); + expect(output).toMatch(/^cat out\r\nhi QW\r\n/); + }); +});