mod common; use std::fs; use std::process::{Command, Stdio}; fn run(main: &str, worker: &str) -> std::process::Output { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("main.js"), main).unwrap(); fs::write(dir.path().join("worker.ts"), worker).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .arg(dir.path().join("main.js")) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); output } #[cfg(unix)] #[test] fn worker_callbacks_can_run_synchronous_processes_without_pumping_js() { let output = run( r#" const worker = new Worker('./worker.ts', {type: 'module'}); const result = new Promise((resolve, reject) => { worker.onmessage = event => resolve(event.data); worker.onerror = reject; }); worker.postMessage('run'); console.log(await result); "#, r#" import {runSync} from 'avril:std'; onmessage = async () => { await new Promise(resolve => setTimeout(resolve, 1)); let pumped = false; queueMicrotask(() => pumped = true); const result = runSync('/bin/sh', ['-c', 'cat; printf stderr >&2'], { input: 'worker sync', encoding: 'utf-8', }); if (pumped) throw Error('runSync pumped JS'); if (result.stderr !== 'stderr') throw Error('Missing stderr'); postMessage(result.stdout); close(); }; "#, ); assert_eq!(output.stdout, b"worker sync\n"); } #[test] fn isolates_share_memory_and_clone_graphs_and_transfer_buffers() { let output = run( r#" globalThis.parentOnly = true; const worker = new Worker(new URL('./worker.ts', import.meta.url), {type: 'module', name: 'test'}); const shared = new Int32Array(new SharedArrayBuffer(4)); const buffer = new Uint8Array([3, 4]); const graph = {shared, buffer}; graph.self = graph; const answer = new Promise((resolve, reject) => {worker.onmessage = e => resolve(e.data); worker.onerror = reject;}); worker.postMessage(graph, [buffer.buffer]); if (!buffer.buffer.detached) throw Error('Not transferred'); if (Atomics.wait(shared, 0, 0, 2000) === 'timed-out') throw Error('No parallel worker'); const result = await answer; if (result.self !== result || result.buffer[1] !== 4 || result.isolated !== true || result.name !== 'test') throw Error('Bad graph'); console.log('isolate and memory'); "#, r#" const typed: number = 42; onmessage = ({data}) => { if (data.self !== data || typed !== 42) throw Error('Graph or TS'); data.isolated = globalThis.parentOnly === undefined; data.name = name; Atomics.store(data.shared, 0, 1); Atomics.notify(data.shared, 0); postMessage(data, [data.buffer.buffer]); close(); }; "#, ); assert_eq!(output.stdout, b"isolate and memory\n"); } #[test] fn ports_move_queued_messages_across_isolates_and_back() { let output = run( r#" const worker = new Worker('./worker.ts', {type: 'module'}); const channel = new MessageChannel(); channel.port1.postMessage('queued'); const reply = new Promise(resolve => channel.port1.onmessage = e => resolve(e.data)); const returned = new Promise(resolve => worker.onmessage = e => resolve(e)); worker.postMessage({port: channel.port2}, [channel.port2]); if (await reply !== 'reply:queued') throw Error('Port reply'); const event = await returned; if (event.data.port !== event.ports[0]) throw Error('Port graph identity'); event.data.port.onmessage = e => { if (e.data !== 'back') throw Error('Retransferred endpoint'); console.log('ports'); worker.terminate(); }; channel.port1.postMessage('back'); "#, r#" onmessage = event => { if (event.data.port !== event.ports[0]) throw Error('Port identity'); const port = event.data.port; port.onmessage = ({data}) => { port.postMessage('reply:' + data); postMessage({port}, [port]); }; }; "#, ); assert_eq!(output.stdout, b"ports\n"); } #[test] fn streams_cross_isolates_with_backpressure_errors_and_cancellation() { let output = run( r#" const worker = new Worker('./worker.ts', {type: 'module'}); let pulls = 0, canceled, written = []; const readable = new ReadableStream({pull(c) {pulls++; c.enqueue({value: pulls});}, cancel(reason) {canceled = reason;}}, {highWaterMark: 0}); const writable = new WritableStream({write(chunk) {written.push(chunk);}}); const transform = new TransformStream({transform(value, c) {c.enqueue(value * 2);}}); const failed = new ReadableStream({pull() {throw {failure: true};}}); const answer = new Promise((resolve, reject) => {worker.onmessage = e => resolve(e.data); worker.onerror = reject;}); worker.postMessage({readable, writable, transform, failed}, [readable, writable, transform, failed]); if (!readable.locked || !writable.locked || !transform.readable.locked || !transform.writable.locked) throw Error('Source locks'); const result = await answer; if (result !== 12 || pulls !== 1 || !canceled.stop || written[0].value !== 1) throw Error('Stream protocol'); console.log('streams'); worker.terminate(); "#, r#" onmessage = async ({data: {readable, writable, transform, failed}}) => { const reader = readable.getReader(), writer = writable.getWriter(); await writer.write((await reader.read()).value); await writer.close(); await reader.cancel({stop: true}); try { await failed.getReader().read(); throw Error('Missing failure'); } catch (error) { if (!error.failure) throw error; } const tr = transform.readable.getReader(), tw = transform.writable.getWriter(); const write = tw.write(6); const value = (await tr.read()).value; await write; await tw.close(); if (!(await tr.read()).done) throw Error('Transform EOF'); postMessage(value); }; "#, ); assert_eq!(output.stdout, b"streams\n"); } #[test] fn errors_and_cpu_bound_termination_do_not_stall_the_parent() { let output = run( r#" const worker = new Worker('./worker.ts', {type: 'module'}); await new Promise(resolve => worker.onerror = event => { if (!event.message.includes('worker failure')) throw Error('Error detail'); event.preventDefault(); resolve(); }); const spinning = new Worker('./worker.ts', {type: 'module', name: 'spin'}); await new Promise(resolve => spinning.onmessage = resolve); spinning.terminate(); spinning.terminate(); console.log('lifecycle'); "#, r#" if (name === 'spin') {postMessage('started'); while (true) {}} throw Error('worker failure'); "#, ); assert_eq!(output.stdout, b"lifecycle\n"); } #[test] fn worker_capabilities_are_independent_snapshots() { let output = run( r#" import {avril, pledge} from 'avril:std'; const worker = new Worker('./worker.ts', {type: 'module', [avril]: {caps: {env: false}}}); await new Promise(resolve => worker.onmessage = resolve); const result = new Promise(resolve => worker.onmessage = e => resolve(e.data)); worker.postMessage('test'); if (await result !== 'PermissionDenied') throw Error('Worker restriction'); worker.terminate(); const live = new Worker('./worker.ts', {type: 'module'}); await new Promise(resolve => live.onmessage = resolve); // Existing workers keep their snapshot; later ones inherit the pledge. pledge({env: false}); const stillAllowed = new Promise(resolve => live.onmessage = e => resolve(e.data)); live.postMessage('test'); if (await stillAllowed !== 'allowed') throw Error('Existing worker revoked'); const narrowed = new Worker('./worker.ts', {type: 'module'}); await new Promise(resolve => narrowed.onmessage = resolve); const denied = new Promise(resolve => narrowed.onmessage = e => resolve(e.data)); narrowed.postMessage('test'); if (await denied !== 'PermissionDenied') throw Error('Later worker escaped pledge'); live.terminate(); narrowed.terminate(); console.log('caps'); "#, r#" import {avril, env} from 'avril:std'; if (avril !== Symbol.for('runtime:avril')) throw Error('Worker avril symbol'); postMessage('ready'); onmessage = () => { try {env.get('PATH'); postMessage('allowed');} catch (error) {postMessage(error.name);} }; "#, ); assert_eq!(output.stdout, b"caps\n"); } #[test] fn termination_cancels_top_level_await_and_nested_workers() { let output = run( r#" for (const name of ['await', 'nested', 'atomics']) { const worker = new Worker('./worker.ts', {type: 'module', name}); await new Promise(resolve => worker.onmessage = resolve); worker.terminate(); } console.log('canceled'); "#, r#" if (location.href !== import.meta.url || location.toString() !== import.meta.url) throw Error('Worker location'); if (name === 'atomics') { postMessage('ready'); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); } else if (name === 'nested') { const worker = new Worker('./worker.ts', {type: 'module', name: 'await'}); worker.onmessage = () => postMessage('ready'); } else { postMessage('ready'); await new Promise(resolve => setTimeout(resolve, 1000000)); } "#, ); assert_eq!(output.stdout, b"canceled\n"); } #[test] fn host_objects_and_wasm_modules_clone_between_isolates() { let output = run( r#" const worker = new Worker('./worker.ts', {type: 'module'}); const key = await crypto.subtle.generateKey({name: 'HMAC', hash: 'SHA-256'}, false, ['sign']); const module = new WebAssembly.Module(new Uint8Array([0,97,115,109,1,0,0,0])); const file = new File(['contents'], 'test.txt', {type: 'text/plain', lastModified: 42}); const error = new DOMException('message', 'AbortError'); const answer = new Promise(resolve => worker.onmessage = e => resolve(e.data)); try {worker.postMessage({key, fail(){}}); throw Error('Accepted function');} catch (error) {if (error.name !== 'DataCloneError') throw error;} worker.postMessage({key, again: key, module, file, error}); const result = await answer; if (!(result.key instanceof CryptoKey) || result.key.extractable || result.key !== result.again || !(result.module instanceof WebAssembly.Module) || !(result.file instanceof File) || result.file.name !== 'test.txt' || result.file.lastModified !== 42 || await result.file.text() !== 'contents' || !(result.error instanceof DOMException) || result.error.name !== 'AbortError') throw Error('Host clone'); const local = new Uint8Array(await crypto.subtle.sign('HMAC', key, new Uint8Array([1]))); if (local.join() !== result.signature.join()) throw Error('Key material'); try {await crypto.subtle.exportKey('raw', result.key); throw Error('Nonextractable export');} catch (error) {if (error.name !== 'InvalidAccessError') throw error;} console.log('hosts'); worker.terminate(); "#, r#" onmessage = async ({data}) => { if (!(data.key instanceof CryptoKey) || data.key.extractable || data.key !== data.again || !(new WebAssembly.Instance(data.module) instanceof WebAssembly.Instance) || await data.file.text() !== 'contents' || data.error.message !== 'message') throw Error('Worker host clone'); try {await crypto.subtle.exportKey('raw', data.key); throw Error('Nonextractable worker export');} catch (error) {if (error.name !== 'InvalidAccessError') throw error;} data.signature = new Uint8Array(await crypto.subtle.sign('HMAC', data.key, new Uint8Array([1]))); postMessage(data); }; "#, ); assert_eq!(output.stdout, b"hosts\n"); } #[test] fn failed_stream_transfers_are_recoverable_and_pending_reads_cancel() { let output = run( r#" const worker = new Worker('./worker.ts', {type: 'module'}); let canceled; const source = new ReadableStream({cancel(reason) {canceled = reason;}}, {highWaterMark: 0}); const buffer = new ArrayBuffer(4); for (const transfer of [[source, buffer], [source, source]]) { try {worker.postMessage({source, fail() {}}, transfer); throw Error('Clone succeeded');} catch (error) {if (error.name !== 'DataCloneError') throw error;} } if (source.locked || buffer.detached) throw Error('Failed transfer committed'); const answer = new Promise(resolve => worker.onmessage = e => resolve(e.data)); worker.postMessage(source, [source]); if (await answer !== 'canceled' || canceled.stop !== true) throw Error('Pending read cancellation'); console.log('stream rollback'); worker.terminate(); "#, r#" onmessage = async ({data: stream}) => { const reader = stream.getReader(); const reading = reader.read(); await reader.cancel({stop: true}); if (!(await reading).done) throw Error('Pending read'); postMessage('canceled'); }; "#, ); assert_eq!(output.stdout, b"stream rollback\n"); } #[test] fn terminating_a_stream_owner_rejects_pending_operations() { let output = run( r#" const worker = new Worker('./worker.ts', {type: 'module'}); const streams = await new Promise(resolve => worker.onmessage = e => resolve(e.data)); const reader = streams.readable.getReader(), writer = streams.writable.getWriter(); const reading = reader.read().catch(error => error.name); const writing = writer.write(1).catch(error => error.name); const closed = writer.closed.catch(error => error.name); worker.terminate(); if (await reading !== 'DataCloneError' || await writing !== 'DataCloneError' || await closed !== 'DataCloneError') throw Error('Unsettled stream operations'); console.log('disconnected'); "#, r#" const readable = new ReadableStream({}, {highWaterMark: 0}); const writable = new WritableStream({write() {return new Promise(() => {});}}); postMessage({readable, writable}, [readable, writable]); "#, ); assert_eq!(output.stdout, b"disconnected\n"); } #[test] fn environment_snapshots_and_child_pledges_do_not_mutate_the_parent() { let output = run( r#" import {env} from 'avril:std'; env.set('AVRIL_WORKER_TEST', 'parent'); const worker = new Worker('./worker.ts', {type: 'module'}); const result = await new Promise(resolve => worker.onmessage = e => resolve(e.data)); if (result !== 'parent' || env.get('AVRIL_WORKER_TEST') !== 'parent') throw Error('Environment isolation'); console.log('environment'); "#, r#" import {env, pledge} from 'avril:std'; const value = env.get('AVRIL_WORKER_TEST'); env.set('AVRIL_WORKER_TEST', 'child'); pledge({env: false}); postMessage(value); close(); "#, ); assert_eq!(output.stdout, b"environment\n"); } #[test] fn closed_writable_streams_preserve_their_terminal_state() { let output = run( r#" const worker = new Worker('./worker.ts', {type: 'module'}); const stream = new WritableStream(); await stream.close(); const result = new Promise(resolve => worker.onmessage = e => resolve(e.data)); worker.postMessage(stream, [stream]); if (await result !== 'closed') throw Error('Closed stream transfer'); console.log('closed stream'); worker.terminate(); "#, r#" onmessage = async ({data: stream}) => { const writer = stream.getWriter(); await writer.closed; try {await writer.write(1); throw Error('Write to closed stream');} catch (error) {if (!(error instanceof TypeError)) throw error;} postMessage('closed'); }; "#, ); assert_eq!(output.stdout, b"closed stream\n"); } #[test] fn background_wasm_compilation_wakes_an_otherwise_idle_worker() { let output = run( r#" const worker = new Worker('./worker.ts', {type: 'module'}); const value = await new Promise(resolve => worker.onmessage = e => resolve(e.data)); if (value !== 42) throw Error('Background compilation'); console.log('compiled'); "#, r#" function unsigned(value) { const result = []; do { const byte = value & 127; value >>>= 7; result.push(byte | (value ? 128 : 0)); } while (value); return result; } const body = [0, ...new Uint8Array(250000).fill(1), 0x41, 42, 0x0b]; const code = [1, ...unsigned(body.length), ...body]; const bytes = new Uint8Array([ 0,97,115,109,1,0,0,0, 1,5,1,0x60,0,1,0x7f, 3,2,1,0, 7,5,1,1,102,0,0, 10,...unsigned(code.length),...code, ]); const module = await WebAssembly.compile(bytes); postMessage(new WebAssembly.Instance(module).exports.f()); close(); "#, ); assert_eq!(output.stdout, b"compiled\n"); } #[test] fn worker_and_stream_glue_uses_captured_intrinsics() { let output = run( r#" const worker = new Worker('./worker.ts', {type: 'module'}); const getData = Object.getOwnPropertyDescriptor(MessageEvent.prototype, 'data').get; const source = new ReadableStream({start(c) {c.enqueue(42); c.close();}}); const answer = new Promise(resolve => worker.onmessage = event => resolve(getData.call(event))); worker.postMessage(source, [source]); Array.prototype[Symbol.iterator] = () => {throw Error('Guest iterator');}; Object.defineProperty(MessageEvent.prototype, 'data', {get() {throw Error('Guest message getter');}}); MessagePort.prototype.postMessage = () => {throw Error('Guest port method');}; Promise.prototype.then = () => {throw Error('Guest then');}; if (await answer !== 42) throw Error('Hardened delivery'); console.log('intrinsics'); "#, r#" const getData = Object.getOwnPropertyDescriptor(MessageEvent.prototype, 'data').get; onmessage = async event => { const reader = getData.call(event).getReader(); postMessage((await reader.read()).value); close(); }; Array.prototype[Symbol.iterator] = () => {throw Error('Guest iterator');}; Object.defineProperty(MessageEvent.prototype, 'data', {get() {throw Error('Guest message getter');}}); MessagePort.prototype.postMessage = () => {throw Error('Guest port method');}; Promise.prototype.then = () => {throw Error('Guest then');}; "#, ); assert_eq!(output.stdout, b"intrinsics\n"); } #[test] fn construction_preserves_option_reads_subclasses_and_location_shape() { let output = run( r#" import {avril} from 'avril:std'; const reads = []; const caps = {get env() {reads.push('env'); return false;}}; const extension = {get caps() {reads.push('caps'); return caps;}}; const options = { get type() {reads.push('type'); return 'module';}, get name() {reads.push('name'); return 'named';}, get [avril]() {reads.push('extension'); return extension;}, }; class Child extends Worker {} const worker = new Child({ toString() {reads.push('script'); return './worker.ts';}, }, options); if (!(worker instanceof Child) || !(worker instanceof EventTarget)) throw Error('Subclass identity'); if (Object.getOwnPropertyNames(worker).length !== 0) throw Error('Leaked worker state'); if (reads.join(',') !== 'script,type,type,name,name,extension,caps,caps,env') throw Error(reads); if (Worker.length !== 1 || Worker.prototype.postMessage.length !== 1 || Worker.prototype.terminate.length !== 0) throw Error('Worker arity'); const result = await new Promise((resolve, reject) => { worker.onmessage = event => resolve(event.data); worker.onerror = reject; }); if (result.name !== 'named' || result.href !== new URL('./worker.ts', import.meta.url).href) throw Error('Worker metadata'); worker.terminate(); console.log('construction'); "#, r#" if (Object.getOwnPropertyNames(location).length !== 0) throw Error('Leaked location state'); if (location.toString() !== location.href || location.toString.length !== 0) throw Error('Location conversion'); const href = Object.getOwnPropertyDescriptor(WorkerLocation.prototype, 'href'); if (href.set !== undefined || href.get.length !== 0) throw Error('Location descriptor'); try {new WorkerLocation(); throw Error('Constructed location');} catch (error) {if (!(error instanceof TypeError)) throw error;} postMessage({name, href: location.href}); close(); "#, ); assert_eq!(output.stdout, b"construction\n"); } #[test] fn classic_workers_are_rejected_and_execution_restrictions_apply_before_loading() { let output = run( r#" import {avril} from 'avril:std'; for (const options of [undefined, {type: 'classic'}]) { try {new Worker('./worker.ts', options); throw Error('Classic worker accepted');} catch (error) {if (!(error instanceof TypeError)) throw error;} } const worker = new Worker('./worker.ts', { type: 'module', [avril]: {caps: {jsExec: false}}, }); await new Promise(resolve => worker.onerror = event => { if (!event.message.includes('PermissionDenied')) throw Error('Execution authority'); event.preventDefault(); resolve(); }); console.log('entry caps'); "#, "throw Error('Unauthorized code executed');", ); assert_eq!(output.stdout, b"entry caps\n"); }