char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somport workers extension to typescriptb7bd857

main
22.6 KiB519 linesraw
1mod common;
2
3use std::fs;
4use std::process::{Command, Stdio};
5
6fn run(main: &str, worker: &str) -> std::process::Output {
7    let dir = tempfile::tempdir().unwrap();
8    fs::write(dir.path().join("main.js"), main).unwrap();
9    fs::write(dir.path().join("worker.ts"), worker).unwrap();
10    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
11        .arg("run")
12        .arg(dir.path().join("main.js"))
13        .stdout(Stdio::piped())
14        .stderr(Stdio::piped())
15        .spawn()
16        .unwrap();
17    let output = common::wait(child);
18    assert!(
19        output.status.success(),
20        "{}",
21        String::from_utf8_lossy(&output.stderr)
22    );
23    output
24}
25
26#[cfg(unix)]
27#[test]
28fn worker_callbacks_can_run_synchronous_processes_without_pumping_js() {
29    let output = run(
30        r#"
31        const worker = new Worker('./worker.ts', {type: 'module'});
32        const result = new Promise((resolve, reject) => {
33            worker.onmessage = event => resolve(event.data);
34            worker.onerror = reject;
35        });
36        worker.postMessage('run');
37        console.log(await result);
38    "#,
39        r#"
40        import {runSync} from 'avril:std';
41        onmessage = async () => {
42            await new Promise(resolve => setTimeout(resolve, 1));
43            let pumped = false;
44            queueMicrotask(() => pumped = true);
45            const result = runSync('/bin/sh', ['-c', 'cat; printf stderr >&2'], {
46                input: 'worker sync', encoding: 'utf-8',
47            });
48            if (pumped) throw Error('runSync pumped JS');
49            if (result.stderr !== 'stderr') throw Error('Missing stderr');
50            postMessage(result.stdout);
51            close();
52        };
53    "#,
54    );
55    assert_eq!(output.stdout, b"worker sync\n");
56}
57
58#[test]
59fn isolates_share_memory_and_clone_graphs_and_transfer_buffers() {
60    let output = run(
61        r#"
62        globalThis.parentOnly = true;
63        const worker = new Worker(new URL('./worker.ts', import.meta.url), {type: 'module', name: 'test'});
64        const shared = new Int32Array(new SharedArrayBuffer(4));
65        const buffer = new Uint8Array([3, 4]);
66        const graph = {shared, buffer}; graph.self = graph;
67        const answer = new Promise((resolve, reject) => {worker.onmessage = e => resolve(e.data); worker.onerror = reject;});
68        worker.postMessage(graph, [buffer.buffer]);
69        if (!buffer.buffer.detached) throw Error('Not transferred');
70        if (Atomics.wait(shared, 0, 0, 2000) === 'timed-out') throw Error('No parallel worker');
71        const result = await answer;
72        if (result.self !== result || result.buffer[1] !== 4 || result.isolated !== true || result.name !== 'test') throw Error('Bad graph');
73        console.log('isolate and memory');
74    "#,
75        r#"
76        const typed: number = 42;
77        onmessage = ({data}) => {
78            if (data.self !== data || typed !== 42) throw Error('Graph or TS');
79            data.isolated = globalThis.parentOnly === undefined;
80            data.name = name;
81            Atomics.store(data.shared, 0, 1); Atomics.notify(data.shared, 0);
82            postMessage(data, [data.buffer.buffer]);
83            close();
84        };
85    "#,
86    );
87    assert_eq!(output.stdout, b"isolate and memory\n");
88}
89
90#[test]
91fn ports_move_queued_messages_across_isolates_and_back() {
92    let output = run(
93        r#"
94        const worker = new Worker('./worker.ts', {type: 'module'});
95        const channel = new MessageChannel();
96        channel.port1.postMessage('queued');
97        const reply = new Promise(resolve => channel.port1.onmessage = e => resolve(e.data));
98        const returned = new Promise(resolve => worker.onmessage = e => resolve(e));
99        worker.postMessage({port: channel.port2}, [channel.port2]);
100        if (await reply !== 'reply:queued') throw Error('Port reply');
101        const event = await returned;
102        if (event.data.port !== event.ports[0]) throw Error('Port graph identity');
103        event.data.port.onmessage = e => {
104            if (e.data !== 'back') throw Error('Retransferred endpoint');
105            console.log('ports'); worker.terminate();
106        };
107        channel.port1.postMessage('back');
108    "#,
109        r#"
110        onmessage = event => {
111            if (event.data.port !== event.ports[0]) throw Error('Port identity');
112            const port = event.data.port;
113            port.onmessage = ({data}) => {
114                port.postMessage('reply:' + data);
115                postMessage({port}, [port]);
116            };
117        };
118    "#,
119    );
120    assert_eq!(output.stdout, b"ports\n");
121}
122
123#[test]
124fn streams_cross_isolates_with_backpressure_errors_and_cancellation() {
125    let output = run(
126        r#"
127        const worker = new Worker('./worker.ts', {type: 'module'});
128        let pulls = 0, canceled, written = [];
129        const readable = new ReadableStream({pull(c) {pulls++; c.enqueue({value: pulls});}, cancel(reason) {canceled = reason;}}, {highWaterMark: 0});
130        const writable = new WritableStream({write(chunk) {written.push(chunk);}});
131        const transform = new TransformStream({transform(value, c) {c.enqueue(value * 2);}});
132        const failed = new ReadableStream({pull() {throw {failure: true};}});
133        const answer = new Promise((resolve, reject) => {worker.onmessage = e => resolve(e.data); worker.onerror = reject;});
134        worker.postMessage({readable, writable, transform, failed}, [readable, writable, transform, failed]);
135        if (!readable.locked || !writable.locked || !transform.readable.locked || !transform.writable.locked) throw Error('Source locks');
136        const result = await answer;
137        if (result !== 12 || pulls !== 1 || !canceled.stop || written[0].value !== 1) throw Error('Stream protocol');
138        console.log('streams'); worker.terminate();
139    "#,
140        r#"
141        onmessage = async ({data: {readable, writable, transform, failed}}) => {
142            const reader = readable.getReader(), writer = writable.getWriter();
143            await writer.write((await reader.read()).value);
144            await writer.close(); await reader.cancel({stop: true});
145            try { await failed.getReader().read(); throw Error('Missing failure'); }
146            catch (error) { if (!error.failure) throw error; }
147            const tr = transform.readable.getReader(), tw = transform.writable.getWriter();
148            const write = tw.write(6);
149            const value = (await tr.read()).value; await write;
150            await tw.close(); if (!(await tr.read()).done) throw Error('Transform EOF');
151            postMessage(value);
152        };
153    "#,
154    );
155    assert_eq!(output.stdout, b"streams\n");
156}
157
158#[test]
159fn errors_and_cpu_bound_termination_do_not_stall_the_parent() {
160    let output = run(
161        r#"
162        const worker = new Worker('./worker.ts', {type: 'module'});
163        await new Promise(resolve => worker.onerror = event => {
164            if (!event.message.includes('worker failure')) throw Error('Error detail');
165            event.preventDefault(); resolve();
166        });
167        const spinning = new Worker('./worker.ts', {type: 'module', name: 'spin'});
168        await new Promise(resolve => spinning.onmessage = resolve);
169        spinning.terminate(); spinning.terminate();
170        console.log('lifecycle');
171    "#,
172        r#"
173        if (name === 'spin') {postMessage('started'); while (true) {}}
174        throw Error('worker failure');
175    "#,
176    );
177    assert_eq!(output.stdout, b"lifecycle\n");
178}
179
180#[test]
181fn worker_capabilities_are_independent_snapshots() {
182    let output = run(
183        r#"
184        import {avril, pledge} from 'avril:std';
185        const worker = new Worker('./worker.ts', {type: 'module', [avril]: {caps: {env: false}}});
186        await new Promise(resolve => worker.onmessage = resolve);
187        const result = new Promise(resolve => worker.onmessage = e => resolve(e.data));
188        worker.postMessage('test');
189        if (await result !== 'PermissionDenied') throw Error('Worker restriction');
190        worker.terminate();
191        const live = new Worker('./worker.ts', {type: 'module'});
192        await new Promise(resolve => live.onmessage = resolve);
193        // Existing workers keep their snapshot; later ones inherit the pledge.
194        pledge({env: false});
195        const stillAllowed = new Promise(resolve => live.onmessage = e => resolve(e.data));
196        live.postMessage('test');
197        if (await stillAllowed !== 'allowed') throw Error('Existing worker revoked');
198        const narrowed = new Worker('./worker.ts', {type: 'module'});
199        await new Promise(resolve => narrowed.onmessage = resolve);
200        const denied = new Promise(resolve => narrowed.onmessage = e => resolve(e.data));
201        narrowed.postMessage('test');
202        if (await denied !== 'PermissionDenied') throw Error('Later worker escaped pledge');
203        live.terminate(); narrowed.terminate(); console.log('caps');
204    "#,
205        r#"
206        import {avril, env} from 'avril:std';
207        if (avril !== Symbol.for('runtime:avril')) throw Error('Worker avril symbol');
208        postMessage('ready');
209        onmessage = () => {
210            try {env.get('PATH'); postMessage('allowed');}
211            catch (error) {postMessage(error.name);}
212        };
213    "#,
214    );
215    assert_eq!(output.stdout, b"caps\n");
216}
217
218#[test]
219fn termination_cancels_top_level_await_and_nested_workers() {
220    let output = run(
221        r#"
222        for (const name of ['await', 'nested', 'atomics']) {
223            const worker = new Worker('./worker.ts', {type: 'module', name});
224            await new Promise(resolve => worker.onmessage = resolve);
225            worker.terminate();
226        }
227        console.log('canceled');
228    "#,
229        r#"
230        if (location.href !== import.meta.url || location.toString() !== import.meta.url) throw Error('Worker location');
231        if (name === 'atomics') {
232            postMessage('ready');
233            Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0);
234        } else if (name === 'nested') {
235            const worker = new Worker('./worker.ts', {type: 'module', name: 'await'});
236            worker.onmessage = () => postMessage('ready');
237        } else {
238            postMessage('ready');
239            await new Promise(resolve => setTimeout(resolve, 1000000));
240        }
241    "#,
242    );
243    assert_eq!(output.stdout, b"canceled\n");
244}
245
246#[test]
247fn host_objects_and_wasm_modules_clone_between_isolates() {
248    let output = run(
249        r#"
250        const worker = new Worker('./worker.ts', {type: 'module'});
251        const key = await crypto.subtle.generateKey({name: 'HMAC', hash: 'SHA-256'}, false, ['sign']);
252        const module = new WebAssembly.Module(new Uint8Array([0,97,115,109,1,0,0,0]));
253        const file = new File(['contents'], 'test.txt', {type: 'text/plain', lastModified: 42});
254        const error = new DOMException('message', 'AbortError');
255        const answer = new Promise(resolve => worker.onmessage = e => resolve(e.data));
256        try {worker.postMessage({key, fail(){}}); throw Error('Accepted function');}
257        catch (error) {if (error.name !== 'DataCloneError') throw error;}
258        worker.postMessage({key, again: key, module, file, error});
259        const result = await answer;
260        if (!(result.key instanceof CryptoKey) || result.key.extractable || result.key !== result.again ||
261            !(result.module instanceof WebAssembly.Module) || !(result.file instanceof File) ||
262            result.file.name !== 'test.txt' || result.file.lastModified !== 42 || await result.file.text() !== 'contents' ||
263            !(result.error instanceof DOMException) || result.error.name !== 'AbortError') throw Error('Host clone');
264        const local = new Uint8Array(await crypto.subtle.sign('HMAC', key, new Uint8Array([1])));
265        if (local.join() !== result.signature.join()) throw Error('Key material');
266        try {await crypto.subtle.exportKey('raw', result.key); throw Error('Nonextractable export');}
267        catch (error) {if (error.name !== 'InvalidAccessError') throw error;}
268        console.log('hosts'); worker.terminate();
269    "#,
270        r#"
271        onmessage = async ({data}) => {
272            if (!(data.key instanceof CryptoKey) || data.key.extractable || data.key !== data.again ||
273                !(new WebAssembly.Instance(data.module) instanceof WebAssembly.Instance) ||
274                await data.file.text() !== 'contents' || data.error.message !== 'message') throw Error('Worker host clone');
275            try {await crypto.subtle.exportKey('raw', data.key); throw Error('Nonextractable worker export');}
276            catch (error) {if (error.name !== 'InvalidAccessError') throw error;}
277            data.signature = new Uint8Array(await crypto.subtle.sign('HMAC', data.key, new Uint8Array([1])));
278            postMessage(data);
279        };
280    "#,
281    );
282    assert_eq!(output.stdout, b"hosts\n");
283}
284
285#[test]
286fn failed_stream_transfers_are_recoverable_and_pending_reads_cancel() {
287    let output = run(
288        r#"
289        const worker = new Worker('./worker.ts', {type: 'module'});
290        let canceled;
291        const source = new ReadableStream({cancel(reason) {canceled = reason;}}, {highWaterMark: 0});
292        const buffer = new ArrayBuffer(4);
293        for (const transfer of [[source, buffer], [source, source]]) {
294            try {worker.postMessage({source, fail() {}}, transfer); throw Error('Clone succeeded');}
295            catch (error) {if (error.name !== 'DataCloneError') throw error;}
296        }
297        if (source.locked || buffer.detached) throw Error('Failed transfer committed');
298        const answer = new Promise(resolve => worker.onmessage = e => resolve(e.data));
299        worker.postMessage(source, [source]);
300        if (await answer !== 'canceled' || canceled.stop !== true) throw Error('Pending read cancellation');
301        console.log('stream rollback'); worker.terminate();
302    "#,
303        r#"
304        onmessage = async ({data: stream}) => {
305            const reader = stream.getReader();
306            const reading = reader.read();
307            await reader.cancel({stop: true});
308            if (!(await reading).done) throw Error('Pending read');
309            postMessage('canceled');
310        };
311    "#,
312    );
313    assert_eq!(output.stdout, b"stream rollback\n");
314}
315
316#[test]
317fn terminating_a_stream_owner_rejects_pending_operations() {
318    let output = run(
319        r#"
320        const worker = new Worker('./worker.ts', {type: 'module'});
321        const streams = await new Promise(resolve => worker.onmessage = e => resolve(e.data));
322        const reader = streams.readable.getReader(), writer = streams.writable.getWriter();
323        const reading = reader.read().catch(error => error.name);
324        const writing = writer.write(1).catch(error => error.name);
325        const closed = writer.closed.catch(error => error.name);
326        worker.terminate();
327        if (await reading !== 'DataCloneError' || await writing !== 'DataCloneError' || await closed !== 'DataCloneError') throw Error('Unsettled stream operations');
328        console.log('disconnected');
329    "#,
330        r#"
331        const readable = new ReadableStream({}, {highWaterMark: 0});
332        const writable = new WritableStream({write() {return new Promise(() => {});}});
333        postMessage({readable, writable}, [readable, writable]);
334    "#,
335    );
336    assert_eq!(output.stdout, b"disconnected\n");
337}
338
339#[test]
340fn environment_snapshots_and_child_pledges_do_not_mutate_the_parent() {
341    let output = run(
342        r#"
343        import {env} from 'avril:std';
344        env.set('AVRIL_WORKER_TEST', 'parent');
345        const worker = new Worker('./worker.ts', {type: 'module'});
346        const result = await new Promise(resolve => worker.onmessage = e => resolve(e.data));
347        if (result !== 'parent' || env.get('AVRIL_WORKER_TEST') !== 'parent') throw Error('Environment isolation');
348        console.log('environment');
349    "#,
350        r#"
351        import {env, pledge} from 'avril:std';
352        const value = env.get('AVRIL_WORKER_TEST');
353        env.set('AVRIL_WORKER_TEST', 'child');
354        pledge({env: false});
355        postMessage(value); close();
356    "#,
357    );
358    assert_eq!(output.stdout, b"environment\n");
359}
360
361#[test]
362fn closed_writable_streams_preserve_their_terminal_state() {
363    let output = run(
364        r#"
365        const worker = new Worker('./worker.ts', {type: 'module'});
366        const stream = new WritableStream();
367        await stream.close();
368        const result = new Promise(resolve => worker.onmessage = e => resolve(e.data));
369        worker.postMessage(stream, [stream]);
370        if (await result !== 'closed') throw Error('Closed stream transfer');
371        console.log('closed stream'); worker.terminate();
372    "#,
373        r#"
374        onmessage = async ({data: stream}) => {
375            const writer = stream.getWriter();
376            await writer.closed;
377            try {await writer.write(1); throw Error('Write to closed stream');}
378            catch (error) {if (!(error instanceof TypeError)) throw error;}
379            postMessage('closed');
380        };
381    "#,
382    );
383    assert_eq!(output.stdout, b"closed stream\n");
384}
385
386#[test]
387fn background_wasm_compilation_wakes_an_otherwise_idle_worker() {
388    let output = run(
389        r#"
390        const worker = new Worker('./worker.ts', {type: 'module'});
391        const value = await new Promise(resolve => worker.onmessage = e => resolve(e.data));
392        if (value !== 42) throw Error('Background compilation');
393        console.log('compiled');
394    "#,
395        r#"
396        function unsigned(value) {
397            const result = [];
398            do {
399                const byte = value & 127; value >>>= 7;
400                result.push(byte | (value ? 128 : 0));
401            } while (value);
402            return result;
403        }
404        const body = [0, ...new Uint8Array(250000).fill(1), 0x41, 42, 0x0b];
405        const code = [1, ...unsigned(body.length), ...body];
406        const bytes = new Uint8Array([
407            0,97,115,109,1,0,0,0,
408            1,5,1,0x60,0,1,0x7f,
409            3,2,1,0,
410            7,5,1,1,102,0,0,
411            10,...unsigned(code.length),...code,
412        ]);
413        const module = await WebAssembly.compile(bytes);
414        postMessage(new WebAssembly.Instance(module).exports.f()); close();
415    "#,
416    );
417    assert_eq!(output.stdout, b"compiled\n");
418}
419
420#[test]
421fn worker_and_stream_glue_uses_captured_intrinsics() {
422    let output = run(
423        r#"
424        const worker = new Worker('./worker.ts', {type: 'module'});
425        const getData = Object.getOwnPropertyDescriptor(MessageEvent.prototype, 'data').get;
426        const source = new ReadableStream({start(c) {c.enqueue(42); c.close();}});
427        const answer = new Promise(resolve => worker.onmessage = event => resolve(getData.call(event)));
428        worker.postMessage(source, [source]);
429        Array.prototype[Symbol.iterator] = () => {throw Error('Guest iterator');};
430        Object.defineProperty(MessageEvent.prototype, 'data', {get() {throw Error('Guest message getter');}});
431        MessagePort.prototype.postMessage = () => {throw Error('Guest port method');};
432        Promise.prototype.then = () => {throw Error('Guest then');};
433        if (await answer !== 42) throw Error('Hardened delivery');
434        console.log('intrinsics');
435    "#,
436        r#"
437        const getData = Object.getOwnPropertyDescriptor(MessageEvent.prototype, 'data').get;
438        onmessage = async event => {
439            const reader = getData.call(event).getReader();
440            postMessage((await reader.read()).value); close();
441        };
442        Array.prototype[Symbol.iterator] = () => {throw Error('Guest iterator');};
443        Object.defineProperty(MessageEvent.prototype, 'data', {get() {throw Error('Guest message getter');}});
444        MessagePort.prototype.postMessage = () => {throw Error('Guest port method');};
445        Promise.prototype.then = () => {throw Error('Guest then');};
446    "#,
447    );
448    assert_eq!(output.stdout, b"intrinsics\n");
449}
450
451#[test]
452fn construction_preserves_option_reads_subclasses_and_location_shape() {
453    let output = run(
454        r#"
455        import {avril} from 'avril:std';
456        const reads = [];
457        const caps = {get env() {reads.push('env'); return false;}};
458        const extension = {get caps() {reads.push('caps'); return caps;}};
459        const options = {
460            get type() {reads.push('type'); return 'module';},
461            get name() {reads.push('name'); return 'named';},
462            get [avril]() {reads.push('extension'); return extension;},
463        };
464        class Child extends Worker {}
465        const worker = new Child({
466            toString() {reads.push('script'); return './worker.ts';},
467        }, options);
468        if (!(worker instanceof Child) || !(worker instanceof EventTarget)) throw Error('Subclass identity');
469        if (Object.getOwnPropertyNames(worker).length !== 0) throw Error('Leaked worker state');
470        if (reads.join(',') !== 'script,type,type,name,name,extension,caps,caps,env') throw Error(reads);
471        if (Worker.length !== 1 || Worker.prototype.postMessage.length !== 1 ||
472            Worker.prototype.terminate.length !== 0) throw Error('Worker arity');
473        const result = await new Promise((resolve, reject) => {
474            worker.onmessage = event => resolve(event.data);
475            worker.onerror = reject;
476        });
477        if (result.name !== 'named' || result.href !== new URL('./worker.ts', import.meta.url).href)
478            throw Error('Worker metadata');
479        worker.terminate();
480        console.log('construction');
481    "#,
482        r#"
483        if (Object.getOwnPropertyNames(location).length !== 0) throw Error('Leaked location state');
484        if (location.toString() !== location.href || location.toString.length !== 0)
485            throw Error('Location conversion');
486        const href = Object.getOwnPropertyDescriptor(WorkerLocation.prototype, 'href');
487        if (href.set !== undefined || href.get.length !== 0) throw Error('Location descriptor');
488        try {new WorkerLocation(); throw Error('Constructed location');}
489        catch (error) {if (!(error instanceof TypeError)) throw error;}
490        postMessage({name, href: location.href});
491        close();
492    "#,
493    );
494    assert_eq!(output.stdout, b"construction\n");
495}
496
497#[test]
498fn classic_workers_are_rejected_and_execution_restrictions_apply_before_loading() {
499    let output = run(
500        r#"
501        import {avril} from 'avril:std';
502        for (const options of [undefined, {type: 'classic'}]) {
503            try {new Worker('./worker.ts', options); throw Error('Classic worker accepted');}
504            catch (error) {if (!(error instanceof TypeError)) throw error;}
505        }
506        const worker = new Worker('./worker.ts', {
507            type: 'module',
508            [avril]: {caps: {jsExec: false}},
509        });
510        await new Promise(resolve => worker.onerror = event => {
511            if (!event.message.includes('PermissionDenied')) throw Error('Execution authority');
512            event.preventDefault(); resolve();
513        });
514        console.log('entry caps');
515    "#,
516        "throw Error('Unauthorized code executed');",
517    );
518    assert_eq!(output.stdout, b"entry caps\n");
519}