pages: deploy the WASM benchmark site
Built by scripts/build-wasm-site.sh; the loader fetches the gzipped module and decompresses client-side, and coi-serviceworker.js supplies COOP/COEP so SharedArrayBuffer works without server headers.
This commit is contained in:
commit
01b38309e8
24
.forgejo/workflows/rules-conformance.yml
Normal file
24
.forgejo/workflows/rules-conformance.yml
Normal file
@ -0,0 +1,24 @@
|
||||
# Managed by rules-swift (scripts/sync-rules.sh). Verifies this repo tracks the
|
||||
# CURRENT shared-rules baseline. The repo's own .swiftformat/.swiftlint.yml are
|
||||
# repo-specific (baseline + project rules) and are intentionally NOT byte-checked.
|
||||
name: rules-conformance
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
rules:
|
||||
runs-on: macos
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Rules baseline freshness (VERSION)
|
||||
run: |
|
||||
BASE=https://codeberg.org/Mihaela/rules-swift/raw/branch/main
|
||||
canon=$(curl -fsSL "$BASE/VERSION" | tr -d '[:space:]')
|
||||
mine=$(tr -d '[:space:]' < .rules-version 2>/dev/null || echo MISSING)
|
||||
if [ "$mine" != "$canon" ]; then
|
||||
echo "STALE: shared-rules baseline is v$canon, this repo tracks v$mine."
|
||||
echo "Review the baseline diff, fold it into this repo's rules, then set .rules-version to $canon."
|
||||
exit 1
|
||||
fi
|
||||
echo "rules baseline FRESH at v$canon (repo-specific rules preserved, not checked)"
|
||||
16
.github/workflows/linux.yml
vendored
Normal file
16
.github/workflows/linux.yml
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
name: Linux
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container: swift:6.2
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build and test
|
||||
run: bash scripts/check.sh
|
||||
15
.github/workflows/macos.yml
vendored
Normal file
15
.github/workflows/macos.yml
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
name: macOS
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build and test
|
||||
run: bash scripts/check.sh
|
||||
357
.github/workflows/wasm-site.yml
vendored
Normal file
357
.github/workflows/wasm-site.yml
vendored
Normal file
@ -0,0 +1,357 @@
|
||||
name: WASM Test Site
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "Package.swift"
|
||||
- "Package.resolved"
|
||||
- "Sources/PureYAMLStreaming/**"
|
||||
- "Sources/PureYAMLStreamingWasmSmoke/**"
|
||||
- "WasmSite/**"
|
||||
- "scripts/build-wasm-site.sh"
|
||||
- ".github/workflows/wasm-site.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "Package.swift"
|
||||
- "Package.resolved"
|
||||
- "Sources/PureYAMLStreaming/**"
|
||||
- "Sources/PureYAMLStreamingWasmSmoke/**"
|
||||
- "WasmSite/**"
|
||||
- "scripts/build-wasm-site.sh"
|
||||
- ".github/workflows/wasm-site.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pureyaml-streaming-wasm-site-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y binaryen gzip libcurl4-openssl-dev
|
||||
|
||||
- name: Install swiftly
|
||||
run: |
|
||||
curl -L https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz > swiftly.tar.gz
|
||||
tar zxf swiftly.tar.gz
|
||||
./swiftly init --quiet-shell-followup --assume-yes --skip-install
|
||||
|
||||
- name: Install Swift 6.3.2 and Wasm SDK
|
||||
run: |
|
||||
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
|
||||
swiftly install --use 6.3.2
|
||||
swiftly run swift sdk install https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz --checksum a61f0584c93283589f8b2f42db05c1f9a182b506c2957271402992655591dd7c +6.3.2
|
||||
|
||||
- name: Build WASM site
|
||||
run: |
|
||||
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
|
||||
bash scripts/build-wasm-site.sh
|
||||
mkdir -p site
|
||||
cp WasmSite/index.html site/index.html
|
||||
cp WasmSite/main.js site/main.js
|
||||
cp WasmSite/coi-serviceworker.js site/coi-serviceworker.js
|
||||
cp WasmSite/stream-worker.js site/stream-worker.js
|
||||
cp WasmSite/style.css site/style.css
|
||||
cp WasmSite/pureyaml-streaming-wasm-smoke.wasm.gz site/pureyaml-streaming-wasm-smoke.wasm.gz
|
||||
cp -R WasmSite/vendor site/vendor
|
||||
mkdir -p site/fixtures
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
fixture = Path("site/fixtures/generated-large.yaml")
|
||||
payload = "x" * 1024
|
||||
with fixture.open("w", encoding="utf-8") as handle:
|
||||
for index in range(3000):
|
||||
handle.write("---\n")
|
||||
handle.write(f"name: ci-doc-{index}\n")
|
||||
handle.write(f"enabled: {'true' if index % 2 == 0 else 'false'}\n")
|
||||
handle.write(f"payload: {payload}\n")
|
||||
PY
|
||||
ls -lh site
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: |
|
||||
mkdir -p .build/playwright-ci
|
||||
cd .build/playwright-ci
|
||||
npm init -y
|
||||
npm install playwright-chromium
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Test site locally
|
||||
run: |
|
||||
python3 -m http.server 8766 --bind 127.0.0.1 --directory site &
|
||||
server_pid=$!
|
||||
trap 'kill "$server_pid"' EXIT
|
||||
node <<'NODE'
|
||||
const { chromium } = require('./.build/playwright-ci/node_modules/playwright-chromium');
|
||||
|
||||
async function gotoIsolated(page, url) {
|
||||
await page.goto(url);
|
||||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||||
if (await page.evaluate(() => globalThis.crossOriginIsolated === true)) {
|
||||
return;
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
await page.reload();
|
||||
}
|
||||
throw new Error('Expected page to become crossOriginIsolated');
|
||||
}
|
||||
|
||||
function parsedStdout(output) {
|
||||
const stdoutMatch = output.match(/stdout:\n([\s\S]*?)\n\nstderr:/);
|
||||
return JSON.parse(stdoutMatch?.[1] || '{}');
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await gotoIsolated(page, 'http://127.0.0.1:8766/?v=' + Date.now());
|
||||
const optionCount = await page.locator('#sampleSelect option').count();
|
||||
if (optionCount < 10) {
|
||||
throw new Error(`Expected at least 10 public YAML samples, got ${optionCount}`);
|
||||
}
|
||||
if (await page.locator('#streamButton').isDisabled()) {
|
||||
throw new Error('Expected true streaming button to be enabled');
|
||||
}
|
||||
await page.fill('#urlInput', 'http://127.0.0.1:8766/fixtures/generated-large.yaml');
|
||||
await page.click('#runButton');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('#status')?.textContent === 'Passed',
|
||||
null,
|
||||
{ timeout: 60000 }
|
||||
);
|
||||
const output = await page.locator('#output').textContent();
|
||||
const bytes = await page.locator('#bytesMetric').textContent();
|
||||
const parse = await page.locator('#parseMetric').textContent();
|
||||
const throughput = await page.locator('#throughputMetric').textContent();
|
||||
const documents = await page.locator('#documentsMetric').textContent();
|
||||
const runLog = await page.locator('#runLog').textContent();
|
||||
const streamDocument = await page.locator('#streamDocument').textContent();
|
||||
const parsed = parsedStdout(output);
|
||||
if (parsed.ok !== true) throw new Error(output);
|
||||
if (parsed.transport !== 'SwiftWASIHTTPClient.HostHTTPClient') throw new Error(output);
|
||||
if (parsed.httpStatusCode !== 200) throw new Error(output);
|
||||
if (parsed.documentCount !== 3000) throw new Error(output);
|
||||
if (parsed.firstName !== 'ci-doc-0') throw new Error(output);
|
||||
if (parsed.lastName !== 'ci-doc-2999') throw new Error(output);
|
||||
if (!Number.isFinite(parsed.parseMilliseconds) || parsed.parseMilliseconds <= 0) {
|
||||
throw new Error(`Expected positive parseMilliseconds, got ${parsed.parseMilliseconds}`);
|
||||
}
|
||||
if (!Number.isFinite(parsed.chunkCount) || parsed.chunkCount <= 1) {
|
||||
throw new Error(`Expected multiple Swift chunks, got ${parsed.chunkCount}`);
|
||||
}
|
||||
if (parsed.chunkSize !== 31) {
|
||||
throw new Error(`Expected chunkSize 31, got ${parsed.chunkSize}`);
|
||||
}
|
||||
if (parsed.maxChunkByteCount > parsed.chunkSize) {
|
||||
throw new Error(`Expected max chunk <= chunkSize, got ${parsed.maxChunkByteCount}`);
|
||||
}
|
||||
if (parsed.scannerDocumentSourceCount !== 3000) {
|
||||
throw new Error(`Expected scanner to emit 3000 sources, got ${parsed.scannerDocumentSourceCount}`);
|
||||
}
|
||||
if (documents !== '3000') {
|
||||
throw new Error(`Expected document metric 3000, got: ${documents}`);
|
||||
}
|
||||
if (bytes === '-' || parse === '-') {
|
||||
throw new Error(`Expected benchmark metrics, got bytes=${bytes}, parse=${parse}`);
|
||||
}
|
||||
if (!throughput.includes('/s')) {
|
||||
throw new Error(`Expected throughput metric, got: ${throughput}`);
|
||||
}
|
||||
if (!runLog.includes('Fetching YAML bytes') || !runLog.includes('SwiftWASIHTTPClient.HostHTTPClient') || !runLog.includes('Starting Swift parser.') || !runLog.includes('ChunkedUTF8Reader consumed') || !runLog.includes('Benchmark completed successfully.')) {
|
||||
throw new Error(`Expected detailed run log, got: ${runLog}`);
|
||||
}
|
||||
if (!streamDocument.includes('name: ci-doc-0') || !streamDocument.includes('name: ci-doc-2999')) {
|
||||
throw new Error('Expected buffered document panel to show generated YAML');
|
||||
}
|
||||
if (await page.locator('#streamDocument .yaml-key').count() < 3) {
|
||||
throw new Error('Expected buffered document panel syntax highlighting');
|
||||
}
|
||||
await page.click('#copyRunLogButton');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('#copyRunLogButton')?.textContent === 'Copied',
|
||||
null,
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
await page.click('#streamButton');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('#status')?.textContent === 'Passed',
|
||||
null,
|
||||
{ timeout: 60000 }
|
||||
);
|
||||
const streamingOutput = await page.locator('#output').textContent();
|
||||
const streamingRunLog = await page.locator('#runLog').textContent();
|
||||
const streamingDocument = await page.locator('#streamDocument').textContent();
|
||||
const streamingParsed = parsedStdout(streamingOutput);
|
||||
if (streamingParsed.ok !== true) throw new Error(streamingOutput);
|
||||
if (streamingParsed.transport !== 'SharedArrayBuffer fd_read stdin') throw new Error(streamingOutput);
|
||||
if (streamingParsed.httpStatusCode !== 200) throw new Error(streamingOutput);
|
||||
if (streamingParsed.documentCount !== 3000) throw new Error(streamingOutput);
|
||||
if (streamingParsed.firstName !== 'ci-doc-0') throw new Error(streamingOutput);
|
||||
if (streamingParsed.lastName !== 'ci-doc-2999') throw new Error(streamingOutput);
|
||||
if (!Number.isFinite(streamingParsed.stdinReadCount) || streamingParsed.stdinReadCount <= 1) {
|
||||
throw new Error(`Expected multiple stdin reads, got ${streamingParsed.stdinReadCount}`);
|
||||
}
|
||||
if (streamingParsed.maxStdinReadByteCount > streamingParsed.chunkSize) {
|
||||
throw new Error(`Expected max stdin read <= chunkSize, got ${streamingParsed.maxStdinReadByteCount}`);
|
||||
}
|
||||
if (!streamingRunLog.includes('Streaming HTTP 200 response body into SharedArrayBuffer') || !streamingRunLog.includes('Network chunk') || !streamingRunLog.includes('Worker fd_read calls') || !streamingRunLog.includes('SharedArrayBuffer fd_read stdin')) {
|
||||
throw new Error(`Expected true streaming run log, got: ${streamingRunLog}`);
|
||||
}
|
||||
if (!streamingDocument.includes('name: ci-doc-0') || !streamingDocument.includes('name: ci-doc-2999')) {
|
||||
throw new Error('Expected streaming document panel to append generated YAML');
|
||||
}
|
||||
if (await page.locator('#streamDocument .yaml-key').count() < 3) {
|
||||
throw new Error('Expected streaming document panel syntax highlighting');
|
||||
}
|
||||
await browser.close();
|
||||
})();
|
||||
NODE
|
||||
|
||||
- name: Configure Pages
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload Pages artifact
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: site
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: |
|
||||
mkdir -p .build/playwright-live
|
||||
cd .build/playwright-live
|
||||
npm init -y
|
||||
npm install playwright-chromium
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Validate live Pages benchmark
|
||||
env:
|
||||
PAGE_URL: ${{ steps.deployment.outputs.page_url }}
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const { chromium } = require('./.build/playwright-live/node_modules/playwright-chromium');
|
||||
|
||||
async function gotoIsolated(page, url) {
|
||||
await page.goto(url);
|
||||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||||
if (await page.evaluate(() => globalThis.crossOriginIsolated === true)) {
|
||||
return;
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
await page.reload();
|
||||
}
|
||||
throw new Error('Expected live page to become crossOriginIsolated');
|
||||
}
|
||||
|
||||
function parsedStdout(output) {
|
||||
const stdoutMatch = output.match(/stdout:\n([\s\S]*?)\n\nstderr:/);
|
||||
return JSON.parse(stdoutMatch?.[1] || '{}');
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const pageURL = process.env.PAGE_URL;
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await gotoIsolated(page, pageURL + '?v=' + Date.now());
|
||||
await page.fill('#urlInput', new URL('fixtures/generated-large.yaml', pageURL).href);
|
||||
await page.click('#runButton');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('#status')?.textContent === 'Passed',
|
||||
null,
|
||||
{ timeout: 60000 }
|
||||
);
|
||||
const output = await page.locator('#output').textContent();
|
||||
const throughput = await page.locator('#throughputMetric').textContent();
|
||||
const runLog = await page.locator('#runLog').textContent();
|
||||
const streamDocument = await page.locator('#streamDocument').textContent();
|
||||
const parsed = parsedStdout(output);
|
||||
if (parsed.transport !== 'SwiftWASIHTTPClient.HostHTTPClient') throw new Error(output);
|
||||
if (parsed.httpStatusCode !== 200) throw new Error(output);
|
||||
if (parsed.documentCount !== 3000) throw new Error(output);
|
||||
if (parsed.firstName !== 'ci-doc-0') throw new Error(output);
|
||||
if (parsed.lastName !== 'ci-doc-2999') throw new Error(output);
|
||||
if (!Number.isFinite(parsed.parseMilliseconds) || parsed.parseMilliseconds <= 0) {
|
||||
throw new Error(`Expected positive parseMilliseconds, got ${parsed.parseMilliseconds}`);
|
||||
}
|
||||
if (!Number.isFinite(parsed.chunkCount) || parsed.chunkCount <= 1) {
|
||||
throw new Error(`Expected multiple Swift chunks, got ${parsed.chunkCount}`);
|
||||
}
|
||||
if (parsed.chunkSize !== 31) {
|
||||
throw new Error(`Expected chunkSize 31, got ${parsed.chunkSize}`);
|
||||
}
|
||||
if (parsed.maxChunkByteCount > parsed.chunkSize) {
|
||||
throw new Error(`Expected max chunk <= chunkSize, got ${parsed.maxChunkByteCount}`);
|
||||
}
|
||||
if (parsed.scannerDocumentSourceCount !== 3000) {
|
||||
throw new Error(`Expected scanner to emit 3000 sources, got ${parsed.scannerDocumentSourceCount}`);
|
||||
}
|
||||
if (!throughput.includes('/s')) {
|
||||
throw new Error(`Expected live throughput metric, got: ${throughput}`);
|
||||
}
|
||||
if (!runLog.includes('Fetching YAML bytes') || !runLog.includes('SwiftWASIHTTPClient.HostHTTPClient') || !runLog.includes('Starting Swift parser.') || !runLog.includes('ChunkedUTF8Reader consumed') || !runLog.includes('Benchmark completed successfully.')) {
|
||||
throw new Error(`Expected live detailed run log, got: ${runLog}`);
|
||||
}
|
||||
if (!streamDocument.includes('name: ci-doc-0') || !streamDocument.includes('name: ci-doc-2999')) {
|
||||
throw new Error('Expected live buffered document panel to show generated YAML');
|
||||
}
|
||||
if (await page.locator('#streamDocument .yaml-key').count() < 3) {
|
||||
throw new Error('Expected live buffered document panel syntax highlighting');
|
||||
}
|
||||
await page.click('#streamButton');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('#status')?.textContent === 'Passed',
|
||||
null,
|
||||
{ timeout: 60000 }
|
||||
);
|
||||
const streamingOutput = await page.locator('#output').textContent();
|
||||
const streamingRunLog = await page.locator('#runLog').textContent();
|
||||
const streamingDocument = await page.locator('#streamDocument').textContent();
|
||||
const streamingParsed = parsedStdout(streamingOutput);
|
||||
if (streamingParsed.transport !== 'SharedArrayBuffer fd_read stdin') throw new Error(streamingOutput);
|
||||
if (streamingParsed.httpStatusCode !== 200) throw new Error(streamingOutput);
|
||||
if (streamingParsed.documentCount !== 3000) throw new Error(streamingOutput);
|
||||
if (streamingParsed.firstName !== 'ci-doc-0') throw new Error(streamingOutput);
|
||||
if (streamingParsed.lastName !== 'ci-doc-2999') throw new Error(streamingOutput);
|
||||
if (!Number.isFinite(streamingParsed.stdinReadCount) || streamingParsed.stdinReadCount <= 1) {
|
||||
throw new Error(`Expected multiple live stdin reads, got ${streamingParsed.stdinReadCount}`);
|
||||
}
|
||||
if (!streamingRunLog.includes('Streaming HTTP 200 response body into SharedArrayBuffer') || !streamingRunLog.includes('Network chunk') || !streamingRunLog.includes('Worker fd_read calls') || !streamingRunLog.includes('SharedArrayBuffer fd_read stdin')) {
|
||||
throw new Error(`Expected live true streaming run log, got: ${streamingRunLog}`);
|
||||
}
|
||||
if (!streamingDocument.includes('name: ci-doc-0') || !streamingDocument.includes('name: ci-doc-2999')) {
|
||||
throw new Error('Expected live streaming document panel to append generated YAML');
|
||||
}
|
||||
if (await page.locator('#streamDocument .yaml-key').count() < 3) {
|
||||
throw new Error('Expected live streaming document panel syntax highlighting');
|
||||
}
|
||||
await browser.close();
|
||||
})();
|
||||
NODE
|
||||
37
.github/workflows/wasm.yml
vendored
Normal file
37
.github/workflows/wasm.yml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
name: WASM
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install build tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y binaryen gzip libcurl4-openssl-dev
|
||||
- name: Install swiftly
|
||||
run: |
|
||||
curl -L https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz > swiftly.tar.gz
|
||||
tar zxf swiftly.tar.gz
|
||||
./swiftly init --quiet-shell-followup --assume-yes --skip-install
|
||||
- name: Install Swift 6.3.2 and Wasm SDK
|
||||
run: |
|
||||
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
|
||||
swiftly install --use 6.3.2
|
||||
swiftly run swift sdk install https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz --checksum a61f0584c93283589f8b2f42db05c1f9a182b506c2957271402992655591dd7c +6.3.2
|
||||
- name: Build WASM benchmark site
|
||||
run: |
|
||||
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
|
||||
bash scripts/build-wasm-site.sh
|
||||
- name: Upload WASM site artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: pureyaml-streaming-wasm-site
|
||||
path: WasmSite
|
||||
retention-days: 7
|
||||
24
.github/workflows/windows.yml
vendored
Normal file
24
.github/workflows/windows.yml
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
name: Windows
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Swift
|
||||
uses: compnerd/gha-setup-swift@main
|
||||
with:
|
||||
swift-version: swift-6.3.2-release
|
||||
swift-build: 6.3.2-RELEASE
|
||||
- name: Build
|
||||
shell: pwsh
|
||||
run: swift build
|
||||
- name: Test
|
||||
shell: pwsh
|
||||
run: swift test
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
.DS_Store
|
||||
WasmSite/*.wasm
|
||||
WasmSite/*.wasm.gz
|
||||
WasmSite/vendor/
|
||||
1
.rules-version
Normal file
1
.rules-version
Normal file
@ -0,0 +1 @@
|
||||
1.1.2
|
||||
12
.swiftformat
Normal file
12
.swiftformat
Normal file
@ -0,0 +1,12 @@
|
||||
# SwiftFormat configuration. https://github.com/nicklockwood/SwiftFormat
|
||||
# This is a starting point; adjust to your project, but keep it committed so
|
||||
# every clone and CI run formats identically.
|
||||
|
||||
--swiftversion 6.0
|
||||
--indent 4
|
||||
--maxwidth 180
|
||||
--commas always
|
||||
--self remove
|
||||
--importgrouping testable-bottom
|
||||
--trailingclosures
|
||||
--exclude .build,Packages/.build,.git
|
||||
31
.swiftlint.yml
Normal file
31
.swiftlint.yml
Normal file
@ -0,0 +1,31 @@
|
||||
# SwiftLint configuration. https://github.com/realm/SwiftLint
|
||||
# A starting point; tune to your project, but keep it committed and run it with
|
||||
# --strict so warnings cannot accumulate.
|
||||
|
||||
disabled_rules:
|
||||
- todo
|
||||
|
||||
opt_in_rules:
|
||||
- empty_count
|
||||
- explicit_init
|
||||
- first_where
|
||||
- force_unwrapping
|
||||
- sorted_first_last
|
||||
- unused_import
|
||||
|
||||
line_length:
|
||||
warning: 180
|
||||
error: 220
|
||||
ignores_comments: true
|
||||
ignores_urls: true
|
||||
|
||||
identifier_name:
|
||||
min_length: 2
|
||||
|
||||
type_name:
|
||||
min_length: 2
|
||||
|
||||
excluded:
|
||||
- .build
|
||||
- Packages/.build
|
||||
- .git
|
||||
55
coi-serviceworker.js
Normal file
55
coi-serviceworker.js
Normal file
@ -0,0 +1,55 @@
|
||||
(() => {
|
||||
const coop = "same-origin";
|
||||
const coep = "require-corp";
|
||||
const reloadKey = "pureyamlStreamingCOIReloadedV3";
|
||||
|
||||
if (typeof Window !== "undefined" && self instanceof Window) {
|
||||
if (window.crossOriginIsolated || !("serviceWorker" in navigator) || !window.isSecureContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reloadForIsolation = () => {
|
||||
if (!window.crossOriginIsolated && sessionStorage.getItem(reloadKey) !== "1") {
|
||||
sessionStorage.setItem(reloadKey, "1");
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
navigator.serviceWorker.addEventListener("controllerchange", reloadForIsolation);
|
||||
|
||||
navigator.serviceWorker.register("./coi-serviceworker.js").then(async (registration) => {
|
||||
await registration.update();
|
||||
await navigator.serviceWorker.ready;
|
||||
reloadForIsolation();
|
||||
}).catch((error) => {
|
||||
console.warn("Unable to register cross-origin isolation service worker", error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith((async () => {
|
||||
const response = await fetch(event.request);
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Cross-Origin-Opener-Policy", coop);
|
||||
headers.set("Cross-Origin-Embedder-Policy", coep);
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
})());
|
||||
});
|
||||
})();
|
||||
106
index.html
Normal file
106
index.html
Normal file
@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PureYAMLStreaming WASM Benchmark</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
<script src="./coi-serviceworker.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="card">
|
||||
<p class="eyebrow">PureYAMLStreaming</p>
|
||||
<h1>Live WASM YAML Benchmark</h1>
|
||||
<p>
|
||||
Pick a public real-world YAML file. The browser fetches it, feeds the
|
||||
bytes to the Swift WASI executable through stdin, and reports
|
||||
Swift-measured document parse timing live. The Run Log distinguishes
|
||||
browser buffering from Swift-side chunked scanning.
|
||||
</p>
|
||||
<label for="sampleSelect">Public YAML example</label>
|
||||
<select id="sampleSelect"></select>
|
||||
<label for="urlInput">URL</label>
|
||||
<input id="urlInput" type="url" autocomplete="off" />
|
||||
<button id="runButton" type="button">Run Buffered Benchmark</button>
|
||||
<button id="streamButton" type="button">Run True Streaming Benchmark</button>
|
||||
<div id="status" class="status idle">Idle</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Streaming Capability</h2>
|
||||
<dl class="metrics">
|
||||
<div>
|
||||
<dt>Isolation</dt>
|
||||
<dd id="isolationMetric">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>SharedArrayBuffer</dt>
|
||||
<dd id="sabMetric">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Worker</dt>
|
||||
<dd id="workerMetric">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Mode</dt>
|
||||
<dd id="modeMetric">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Timing</h2>
|
||||
<dl class="metrics">
|
||||
<div>
|
||||
<dt>Bytes</dt>
|
||||
<dd id="bytesMetric">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fetch</dt>
|
||||
<dd id="fetchMetric">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Swift parse</dt>
|
||||
<dd id="parseMetric">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Throughput</dt>
|
||||
<dd id="throughputMetric">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Documents</dt>
|
||||
<dd id="documentsMetric">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-header">
|
||||
<h2>Run Log</h2>
|
||||
<button id="copyRunLogButton" class="secondary-button" type="button">Copy Run Log</button>
|
||||
</div>
|
||||
<div id="runLog" class="run-log" role="log" aria-live="polite">
|
||||
<div class="log-entry muted">Waiting for benchmark run.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Document Stream</h2>
|
||||
<p>
|
||||
In true streaming mode this panel appends decoded YAML chunks as the
|
||||
browser receives them. In buffered mode it shows the fetched body after
|
||||
the browser buffer is complete.
|
||||
</p>
|
||||
<pre id="streamDocument" class="stream-document">Waiting for YAML bytes.</pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Output</h2>
|
||||
<pre id="output">Pick a YAML file and choose a benchmark mode.</pre>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
747
main.js
Normal file
747
main.js
Normal file
@ -0,0 +1,747 @@
|
||||
import {
|
||||
ConsoleStdout,
|
||||
File,
|
||||
OpenFile,
|
||||
WASI,
|
||||
} from "./vendor/browser_wasi_shim/index.js";
|
||||
|
||||
const runButton = document.querySelector("#runButton");
|
||||
const streamButton = document.querySelector("#streamButton");
|
||||
const sampleSelect = document.querySelector("#sampleSelect");
|
||||
const urlInput = document.querySelector("#urlInput");
|
||||
const statusElement = document.querySelector("#status");
|
||||
const outputElement = document.querySelector("#output");
|
||||
const runLogElement = document.querySelector("#runLog");
|
||||
const copyRunLogButton = document.querySelector("#copyRunLogButton");
|
||||
const streamDocumentElement = document.querySelector("#streamDocument");
|
||||
const isolationMetric = document.querySelector("#isolationMetric");
|
||||
const sabMetric = document.querySelector("#sabMetric");
|
||||
const workerMetric = document.querySelector("#workerMetric");
|
||||
const modeMetric = document.querySelector("#modeMetric");
|
||||
const bytesMetric = document.querySelector("#bytesMetric");
|
||||
const fetchMetric = document.querySelector("#fetchMetric");
|
||||
const parseMetric = document.querySelector("#parseMetric");
|
||||
const throughputMetric = document.querySelector("#throughputMetric");
|
||||
const documentsMetric = document.querySelector("#documentsMetric");
|
||||
const params = new URLSearchParams(location.search);
|
||||
const wasmURL = params.get("wasm") || "./pureyaml-streaming-wasm-smoke.wasm.gz";
|
||||
let modulePromise = null;
|
||||
let runStartedAt = performance.now();
|
||||
const streamControlIndex = {
|
||||
read: 0,
|
||||
write: 1,
|
||||
available: 2,
|
||||
closed: 3,
|
||||
error: 4,
|
||||
sequence: 5,
|
||||
totalWritten: 6,
|
||||
readCalls: 7,
|
||||
waitCount: 8,
|
||||
};
|
||||
const streamControlCount = 16;
|
||||
const streamBufferCapacity = 1024 * 1024;
|
||||
const documentRenderBudget = 64 * 1024;
|
||||
let documentRenderQueue = [];
|
||||
let documentRenderScheduled = false;
|
||||
let documentRenderResolvers = [];
|
||||
|
||||
const samples = [
|
||||
{
|
||||
name: "GitHub REST API OpenAPI (8.39 MiB)",
|
||||
url: "https://api.apis.guru/v2/specs/github.com/1.1.4/openapi.yaml",
|
||||
},
|
||||
{
|
||||
name: "Loket.nl API OpenAPI (10.62 MiB)",
|
||||
url: "https://api.apis.guru/v2/specs/loket.nl/V2/openapi.yaml",
|
||||
},
|
||||
{
|
||||
name: "Zuora Billing API OpenAPI (6.46 MiB)",
|
||||
url: "https://api.apis.guru/v2/specs/zuora.com/2021-08-20/openapi.yaml",
|
||||
},
|
||||
{
|
||||
name: "Amazon EC2 OpenAPI (5.36 MiB)",
|
||||
url: "https://api.apis.guru/v2/specs/amazonaws.com/ec2/2016-11-15/openapi.yaml",
|
||||
},
|
||||
{
|
||||
name: "Zoom API OpenAPI (4.80 MiB)",
|
||||
url: "https://api.apis.guru/v2/specs/zoom.us/2.0.0/openapi.yaml",
|
||||
},
|
||||
{
|
||||
name: "UniCourt Enterprise APIs OpenAPI (4.29 MiB)",
|
||||
url: "https://api.apis.guru/v2/specs/unicourt.com/1.0.0/openapi.yaml",
|
||||
},
|
||||
{
|
||||
name: "Kubernetes Swagger (4.26 MiB)",
|
||||
url: "https://api.apis.guru/v2/specs/kubernetes.io/unversioned/swagger.yaml",
|
||||
},
|
||||
{
|
||||
name: "Autotask PSA Swagger (3.90 MiB)",
|
||||
url: "https://api.apis.guru/v2/specs/autotask.net/v1/swagger.yaml",
|
||||
},
|
||||
{
|
||||
name: "Google Document AI Warehouse OpenAPI (3.61 MiB)",
|
||||
url: "https://api.apis.guru/v2/specs/googleapis.com/contentwarehouse/v1/openapi.yaml",
|
||||
},
|
||||
{
|
||||
name: "Stripe API OpenAPI (3.48 MiB)",
|
||||
url: "https://api.apis.guru/v2/specs/stripe.com/2022-11-15/openapi.yaml",
|
||||
},
|
||||
{
|
||||
name: "Google Compute Engine OpenAPI (3.32 MiB)",
|
||||
url: "https://api.apis.guru/v2/specs/googleapis.com/compute/v1/openapi.yaml",
|
||||
},
|
||||
{
|
||||
name: "DocuSign REST API OpenAPI (3.14 MiB)",
|
||||
url: "https://api.apis.guru/v2/specs/docusign.net/v2.1/openapi.yaml",
|
||||
},
|
||||
];
|
||||
|
||||
for (const sample of samples) {
|
||||
const option = document.createElement("option");
|
||||
option.value = sample.url;
|
||||
option.textContent = sample.name;
|
||||
sampleSelect.append(option);
|
||||
}
|
||||
urlInput.value = samples[0].url;
|
||||
|
||||
runButton.addEventListener("click", () => {
|
||||
runBenchmark().catch((error) => {
|
||||
renderFailure(error);
|
||||
});
|
||||
});
|
||||
|
||||
streamButton.addEventListener("click", () => {
|
||||
runStreamingBenchmark().catch((error) => {
|
||||
renderFailure(error);
|
||||
});
|
||||
});
|
||||
|
||||
copyRunLogButton.addEventListener("click", () => {
|
||||
copyRunLog().catch((error) => {
|
||||
logStep(`Unable to copy run log: ${String(error?.message || error)}`, "error");
|
||||
});
|
||||
});
|
||||
|
||||
sampleSelect.addEventListener("change", () => {
|
||||
urlInput.value = sampleSelect.value;
|
||||
});
|
||||
|
||||
updateStreamingCapabilities();
|
||||
|
||||
async function runBenchmark() {
|
||||
const sourceURL = urlInput.value.trim();
|
||||
if (!sourceURL) {
|
||||
throw new Error("Choose or enter a YAML URL first.");
|
||||
}
|
||||
|
||||
runButton.disabled = true;
|
||||
streamButton.disabled = true;
|
||||
statusElement.className = "status idle";
|
||||
statusElement.textContent = "Preparing...";
|
||||
outputElement.textContent = "";
|
||||
resetMetrics();
|
||||
resetLog();
|
||||
resetStreamDocument("Waiting for buffered YAML fetch to complete.");
|
||||
logStep("Queued benchmark run.");
|
||||
logStep(`Selected source: ${sourceURL}`);
|
||||
|
||||
logStep(`Loading WASM module: ${wasmURL}`);
|
||||
const moduleLoadStart = performance.now();
|
||||
const module = await loadWasmModule(wasmURL);
|
||||
const moduleLoadMS = performance.now() - moduleLoadStart;
|
||||
logStep(`WASM module ready in ${formatDuration(moduleLoadMS)}.`);
|
||||
|
||||
statusElement.textContent = "Fetching YAML...";
|
||||
logStep("Fetching YAML bytes in the browser. This fetch is buffered before WASI starts.");
|
||||
const fetchStart = performance.now();
|
||||
const response = await fetch(sourceURL, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch YAML: HTTP ${response.status}`);
|
||||
}
|
||||
const yamlBytes = new Uint8Array(await response.arrayBuffer());
|
||||
const fetchMS = performance.now() - fetchStart;
|
||||
replaceStreamDocument(decodeYAMLBytes(yamlBytes));
|
||||
await waitForDocumentFlush();
|
||||
await nextPaint();
|
||||
logStep(`Fetched HTTP ${response.status}, ${formatBytes(yamlBytes.byteLength)} in ${formatDuration(fetchMS)}.`);
|
||||
logStep("Handing buffered response to SwiftWASIHTTPClient.HostHTTPClient through WASI stdin.");
|
||||
|
||||
logStep("Preparing WASI file descriptors.");
|
||||
let stdoutText = "";
|
||||
let stderrText = "";
|
||||
const fds = [
|
||||
new OpenFile(new File(yamlBytes)),
|
||||
ConsoleStdout.lineBuffered((line) => {
|
||||
stdoutText += `${line}\n`;
|
||||
}),
|
||||
ConsoleStdout.lineBuffered((line) => {
|
||||
stderrText += `${line}\n`;
|
||||
}),
|
||||
];
|
||||
logStep("Creating WASI instance.");
|
||||
const wasi = new WASI(["pureyaml-streaming-wasm-smoke", sourceURL, String(response.status)], [], fds);
|
||||
statusElement.textContent = "Running Swift parser...";
|
||||
logStep("Starting Swift parser. The browser tab can pause while WebAssembly is executing.");
|
||||
await nextPaint();
|
||||
const parseStart = performance.now();
|
||||
const instance = await WebAssembly.instantiate(module, {
|
||||
wasi_snapshot_preview1: wasi.wasiImport,
|
||||
});
|
||||
logStep("WebAssembly instance created; entering WASI _start.");
|
||||
await nextPaint();
|
||||
wasi.start(instance);
|
||||
const wallClockParseMS = performance.now() - parseStart;
|
||||
logStep(`WASI process returned in ${formatDuration(wallClockParseMS)}.`);
|
||||
|
||||
logStep("Parsing Swift JSON result from stdout.");
|
||||
const parsed = parseSmokeOutput(stdoutText);
|
||||
const ok = parsed?.ok === true;
|
||||
if (!Number.isFinite(parsed?.parseMilliseconds)) {
|
||||
throw new Error("WASM output did not include a finite Swift parseMilliseconds value.");
|
||||
}
|
||||
const parseMS = parsed.parseMilliseconds;
|
||||
const throughput = parseMS > 0 ? yamlBytes.byteLength / (parseMS / 1000) : 0;
|
||||
if (!Number.isFinite(parsed?.chunkCount) || !Number.isFinite(parsed?.chunkSize)) {
|
||||
throw new Error("WASM output did not include finite Swift chunk instrumentation.");
|
||||
}
|
||||
logStep(`Swift transport: ${parsed.transport || "unknown"}; HTTP status: ${parsed.httpStatusCode ?? "n/a"}.`);
|
||||
logStep(`Swift parse reported ${formatDuration(parseMS)}.`);
|
||||
logStep(
|
||||
`Swift ChunkedUTF8Reader consumed ${parsed.chunkCount} chunk(s) at chunkSize ${formatBytes(parsed.chunkSize)}.`,
|
||||
);
|
||||
logStep(`Largest Swift chunk observed: ${formatBytes(parsed.maxChunkByteCount ?? 0)}.`);
|
||||
logStep(`Document scanner emitted ${parsed.scannerDocumentSourceCount ?? 0} document source(s).`);
|
||||
logStep(`Parsed ${parsed.documentCount ?? 0} document(s).`);
|
||||
logStep(`Throughput: ${formatBytes(throughput)}/s.`);
|
||||
|
||||
statusElement.className = ok ? "status pass" : "status fail";
|
||||
statusElement.textContent = ok ? "Passed" : "Failed";
|
||||
bytesMetric.textContent = formatBytes(yamlBytes.byteLength);
|
||||
fetchMetric.textContent = formatDuration(fetchMS);
|
||||
parseMetric.textContent = formatDuration(parseMS);
|
||||
throughputMetric.textContent = `${formatBytes(throughput)}/s`;
|
||||
documentsMetric.textContent = parsed?.documentCount ?? "-";
|
||||
outputElement.textContent = [
|
||||
`url: ${sourceURL}`,
|
||||
`moduleLoad: ${formatDuration(moduleLoadMS)}`,
|
||||
`fetch: ${formatDuration(fetchMS)}`,
|
||||
`swiftParse: ${formatDuration(parseMS)}`,
|
||||
`processWallClock: ${formatDuration(wallClockParseMS)}`,
|
||||
`throughput: ${formatBytes(throughput)}/s`,
|
||||
"",
|
||||
"stdout:",
|
||||
stdoutText || "(empty)",
|
||||
"",
|
||||
"stderr:",
|
||||
stderrText || "(empty)",
|
||||
].join("\n");
|
||||
logStep(ok ? "Benchmark completed successfully." : "Benchmark finished with parser failure.");
|
||||
resetButtons();
|
||||
}
|
||||
|
||||
async function runStreamingBenchmark() {
|
||||
const sourceURL = urlInput.value.trim();
|
||||
if (!sourceURL) {
|
||||
throw new Error("Choose or enter a YAML URL first.");
|
||||
}
|
||||
if (!streamingSupported()) {
|
||||
throw new Error("True streaming mode requires cross-origin isolation, SharedArrayBuffer, Worker, and Atomics.wait.");
|
||||
}
|
||||
|
||||
runButton.disabled = true;
|
||||
streamButton.disabled = true;
|
||||
statusElement.className = "status idle";
|
||||
statusElement.textContent = "Streaming...";
|
||||
outputElement.textContent = "";
|
||||
resetMetrics();
|
||||
resetLog();
|
||||
resetStreamDocument("Waiting for streaming YAML chunks.");
|
||||
logStep("Queued true browser-to-WASI streaming run.");
|
||||
logStep(`Selected source: ${sourceURL}`);
|
||||
|
||||
const sharedBuffer = new SharedArrayBuffer(
|
||||
streamControlCount * Int32Array.BYTES_PER_ELEMENT + streamBufferCapacity,
|
||||
);
|
||||
const control = new Int32Array(sharedBuffer, 0, streamControlCount);
|
||||
const bytes = new Uint8Array(sharedBuffer, streamControlCount * Int32Array.BYTES_PER_ELEMENT, streamBufferCapacity);
|
||||
let worker = null;
|
||||
let stdoutText = "";
|
||||
let stderrText = "";
|
||||
let workerDone;
|
||||
const workerDonePromise = new Promise((resolve, reject) => {
|
||||
workerDone = { resolve, reject };
|
||||
});
|
||||
|
||||
logStep("Starting Worker-owned WASI runtime.");
|
||||
const fetchStart = performance.now();
|
||||
const response = await fetch(sourceURL, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch YAML: HTTP ${response.status}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("This browser did not expose a ReadableStream response body.");
|
||||
}
|
||||
|
||||
worker = new Worker("./stream-worker.js", { type: "module" });
|
||||
worker.addEventListener("message", (event) => {
|
||||
const message = event.data || {};
|
||||
if (message.type === "log") {
|
||||
logStep(message.message);
|
||||
} else if (message.type === "stdout") {
|
||||
stdoutText += `${message.line}\n`;
|
||||
} else if (message.type === "stderr") {
|
||||
stderrText += `${message.line}\n`;
|
||||
} else if (message.type === "done") {
|
||||
stdoutText = message.stdoutText;
|
||||
stderrText = message.stderrText;
|
||||
logStep(`Worker fd_read calls: ${message.readCalls}; ring-buffer waits: ${message.waitCount}.`);
|
||||
workerDone.resolve(message);
|
||||
} else if (message.type === "error") {
|
||||
workerDone.reject(new Error(message.message));
|
||||
}
|
||||
});
|
||||
|
||||
worker.postMessage({
|
||||
type: "start",
|
||||
wasmURL,
|
||||
sourceURL,
|
||||
statusCode: response.status,
|
||||
sharedBuffer,
|
||||
capacity: streamBufferCapacity,
|
||||
});
|
||||
|
||||
logStep(`Streaming HTTP ${response.status} response body into SharedArrayBuffer.`);
|
||||
const reader = response.body.getReader();
|
||||
const documentDecoder = new TextDecoder();
|
||||
let networkChunkCount = 0;
|
||||
let fetchedByteCount = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
networkChunkCount += 1;
|
||||
fetchedByteCount += value.byteLength;
|
||||
appendStreamDocumentChunk(documentDecoder.decode(value, { stream: true }));
|
||||
await writeToRingBuffer(value, control, bytes);
|
||||
logStep(`Network chunk ${networkChunkCount}: wrote ${formatBytes(value.byteLength)} to ring buffer.`);
|
||||
}
|
||||
} catch (error) {
|
||||
Atomics.store(control, streamControlIndex.error, 1);
|
||||
Atomics.store(control, streamControlIndex.closed, 1);
|
||||
wakeRingBuffer(control);
|
||||
worker?.terminate();
|
||||
throw error;
|
||||
}
|
||||
appendStreamDocumentChunk(documentDecoder.decode());
|
||||
await waitForDocumentFlush();
|
||||
Atomics.store(control, streamControlIndex.closed, 1);
|
||||
wakeRingBuffer(control);
|
||||
const fetchMS = performance.now() - fetchStart;
|
||||
logStep(`Network stream finished: ${networkChunkCount} chunk(s), ${formatBytes(fetchedByteCount)} in ${formatDuration(fetchMS)}.`);
|
||||
|
||||
let workerResult;
|
||||
try {
|
||||
workerResult = await workerDonePromise;
|
||||
} catch (error) {
|
||||
worker?.terminate();
|
||||
throw error;
|
||||
}
|
||||
worker?.terminate();
|
||||
renderBenchmarkResult({
|
||||
sourceURL,
|
||||
moduleLoadMS: workerResult.moduleLoadMS,
|
||||
fetchMS,
|
||||
wallClockParseMS: workerResult.processWallClockMS,
|
||||
stdoutText,
|
||||
stderrText,
|
||||
inputByteCount: fetchedByteCount,
|
||||
workerResult,
|
||||
modeLabel: "true streaming",
|
||||
});
|
||||
}
|
||||
|
||||
function renderBenchmarkResult({
|
||||
sourceURL,
|
||||
moduleLoadMS,
|
||||
fetchMS,
|
||||
wallClockParseMS,
|
||||
stdoutText,
|
||||
stderrText,
|
||||
inputByteCount,
|
||||
modeLabel,
|
||||
}) {
|
||||
logStep("Parsing Swift JSON result from stdout.");
|
||||
const parsed = parseSmokeOutput(stdoutText);
|
||||
const ok = parsed?.ok === true;
|
||||
if (!Number.isFinite(parsed?.parseMilliseconds)) {
|
||||
throw new Error("WASM output did not include a finite Swift parseMilliseconds value.");
|
||||
}
|
||||
const parseMS = parsed.parseMilliseconds;
|
||||
const throughput = parseMS > 0 ? inputByteCount / (parseMS / 1000) : 0;
|
||||
if (!Number.isFinite(parsed?.chunkCount) || !Number.isFinite(parsed?.chunkSize)) {
|
||||
throw new Error("WASM output did not include finite Swift chunk instrumentation.");
|
||||
}
|
||||
logStep(`Swift transport: ${parsed.transport || "unknown"}; HTTP status: ${parsed.httpStatusCode ?? "n/a"}.`);
|
||||
logStep(`Swift parse reported ${formatDuration(parseMS)}.`);
|
||||
logStep(
|
||||
`Swift ChunkedUTF8Reader consumed ${parsed.chunkCount} chunk(s) at chunkSize ${formatBytes(parsed.chunkSize)}.`,
|
||||
);
|
||||
logStep(`Largest Swift chunk observed: ${formatBytes(parsed.maxChunkByteCount ?? 0)}.`);
|
||||
logStep(`Document scanner emitted ${parsed.scannerDocumentSourceCount ?? 0} document source(s).`);
|
||||
logStep(`Swift stdin read calls: ${parsed.stdinReadCount ?? "n/a"}; max stdin read: ${formatBytes(parsed.maxStdinReadByteCount ?? 0)}.`);
|
||||
logStep(`Parsed ${parsed.documentCount ?? 0} document(s).`);
|
||||
logStep(`Throughput: ${formatBytes(throughput)}/s.`);
|
||||
|
||||
statusElement.className = ok ? "status pass" : "status fail";
|
||||
statusElement.textContent = ok ? "Passed" : "Failed";
|
||||
bytesMetric.textContent = formatBytes(inputByteCount);
|
||||
fetchMetric.textContent = formatDuration(fetchMS);
|
||||
parseMetric.textContent = formatDuration(parseMS);
|
||||
throughputMetric.textContent = `${formatBytes(throughput)}/s`;
|
||||
documentsMetric.textContent = parsed?.documentCount ?? "-";
|
||||
outputElement.textContent = [
|
||||
`url: ${sourceURL}`,
|
||||
`mode: ${modeLabel}`,
|
||||
`moduleLoad: ${formatDuration(moduleLoadMS)}`,
|
||||
`fetch: ${formatDuration(fetchMS)}`,
|
||||
`swiftParse: ${formatDuration(parseMS)}`,
|
||||
`processWallClock: ${formatDuration(wallClockParseMS)}`,
|
||||
`throughput: ${formatBytes(throughput)}/s`,
|
||||
"",
|
||||
"stdout:",
|
||||
stdoutText || "(empty)",
|
||||
"",
|
||||
"stderr:",
|
||||
stderrText || "(empty)",
|
||||
].join("\n");
|
||||
logStep(ok ? "Benchmark completed successfully." : "Benchmark finished with parser failure.");
|
||||
resetButtons();
|
||||
}
|
||||
|
||||
async function writeToRingBuffer(chunk, control, bytes) {
|
||||
let offset = 0;
|
||||
while (offset < chunk.byteLength) {
|
||||
const available = Atomics.load(control, streamControlIndex.available);
|
||||
if (available >= streamBufferCapacity) {
|
||||
logStep("Ring buffer full; fetch pump waiting for Worker fd_read to resume.", "muted");
|
||||
await waitForRingSpace(control);
|
||||
logStep("Ring buffer has capacity again; fetch pump resumed.", "muted");
|
||||
continue;
|
||||
}
|
||||
|
||||
const write = Atomics.load(control, streamControlIndex.write);
|
||||
const space = streamBufferCapacity - available;
|
||||
const byteCount = Math.min(chunk.byteLength - offset, space, streamBufferCapacity - write);
|
||||
bytes.set(chunk.subarray(offset, offset + byteCount), write);
|
||||
Atomics.store(control, streamControlIndex.write, (write + byteCount) % streamBufferCapacity);
|
||||
Atomics.add(control, streamControlIndex.available, byteCount);
|
||||
Atomics.add(control, streamControlIndex.totalWritten, byteCount);
|
||||
offset += byteCount;
|
||||
wakeRingBuffer(control);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRingSpace(control) {
|
||||
while (Atomics.load(control, streamControlIndex.available) >= streamBufferCapacity) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 4));
|
||||
}
|
||||
}
|
||||
|
||||
function wakeRingBuffer(control) {
|
||||
Atomics.add(control, streamControlIndex.sequence, 1);
|
||||
Atomics.notify(control, streamControlIndex.sequence);
|
||||
}
|
||||
|
||||
async function loadWasmModule(url) {
|
||||
if (!modulePromise) {
|
||||
modulePromise = loadWasmBytes(url).then((bytes) => WebAssembly.compile(bytes));
|
||||
}
|
||||
return modulePromise;
|
||||
}
|
||||
|
||||
async function loadWasmBytes(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch WASM: HTTP ${response.status}`);
|
||||
}
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (!url.endsWith(".gz")) {
|
||||
return bytes;
|
||||
}
|
||||
if (!("DecompressionStream" in globalThis)) {
|
||||
throw new Error("This browser does not support DecompressionStream.");
|
||||
}
|
||||
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
|
||||
return new Response(stream).arrayBuffer();
|
||||
}
|
||||
|
||||
function parseSmokeOutput(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderFailure(error) {
|
||||
statusElement.className = "status fail";
|
||||
statusElement.textContent = "Failed";
|
||||
outputElement.textContent = String(error?.stack || error);
|
||||
logStep(`Failed: ${String(error?.message || error)}`, "error");
|
||||
resetButtons();
|
||||
}
|
||||
|
||||
function resetButtons() {
|
||||
runButton.disabled = false;
|
||||
streamButton.disabled = !streamingSupported();
|
||||
}
|
||||
|
||||
function updateStreamingCapabilities() {
|
||||
const isolated = crossOriginIsolated === true;
|
||||
const hasSharedArrayBuffer = "SharedArrayBuffer" in globalThis;
|
||||
const hasWorker = "Worker" in globalThis;
|
||||
const hasAtomicsWait = typeof Atomics?.wait === "function";
|
||||
isolationMetric.textContent = isolated ? "yes" : "no";
|
||||
sabMetric.textContent = hasSharedArrayBuffer ? "yes" : "no";
|
||||
workerMetric.textContent = hasWorker && hasAtomicsWait ? "yes" : "no";
|
||||
modeMetric.textContent = streamingSupported() ? "streaming enabled" : "buffered fallback";
|
||||
resetButtons();
|
||||
}
|
||||
|
||||
function streamingSupported() {
|
||||
return crossOriginIsolated === true
|
||||
&& "SharedArrayBuffer" in globalThis
|
||||
&& "Worker" in globalThis
|
||||
&& typeof Atomics?.wait === "function";
|
||||
}
|
||||
|
||||
function resetMetrics() {
|
||||
bytesMetric.textContent = "-";
|
||||
fetchMetric.textContent = "-";
|
||||
parseMetric.textContent = "-";
|
||||
throughputMetric.textContent = "-";
|
||||
documentsMetric.textContent = "-";
|
||||
}
|
||||
|
||||
async function copyRunLog() {
|
||||
const text = runLogElement.innerText.trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if ("clipboard" in navigator && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
copyTextWithTextarea(text);
|
||||
}
|
||||
} catch {
|
||||
copyTextWithTextarea(text);
|
||||
}
|
||||
const previousLabel = copyRunLogButton.textContent;
|
||||
copyRunLogButton.textContent = "Copied";
|
||||
window.setTimeout(() => {
|
||||
copyRunLogButton.textContent = previousLabel;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function copyTextWithTextarea(text) {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.append(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
textarea.remove();
|
||||
}
|
||||
|
||||
function resetStreamDocument(message) {
|
||||
documentRenderQueue = [];
|
||||
documentRenderResolvers.splice(0).forEach((resolve) => resolve());
|
||||
streamDocumentElement.replaceChildren(document.createTextNode(message));
|
||||
streamDocumentElement.scrollTop = 0;
|
||||
}
|
||||
|
||||
function replaceStreamDocument(text) {
|
||||
documentRenderQueue = [];
|
||||
documentRenderResolvers.splice(0).forEach((resolve) => resolve());
|
||||
streamDocumentElement.replaceChildren();
|
||||
appendStreamDocumentChunk(text || "(empty document)");
|
||||
streamDocumentElement.scrollTop = 0;
|
||||
}
|
||||
|
||||
function appendStreamDocumentChunk(text) {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
if (streamDocumentElement.childNodes.length === 1 && streamDocumentElement.textContent.startsWith("Waiting for")) {
|
||||
streamDocumentElement.replaceChildren();
|
||||
}
|
||||
documentRenderQueue.push(text);
|
||||
scheduleDocumentFlush();
|
||||
}
|
||||
|
||||
function decodeYAMLBytes(bytes) {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function scheduleDocumentFlush() {
|
||||
if (documentRenderScheduled) {
|
||||
return;
|
||||
}
|
||||
documentRenderScheduled = true;
|
||||
requestAnimationFrame(flushDocumentRenderQueue);
|
||||
}
|
||||
|
||||
function flushDocumentRenderQueue() {
|
||||
documentRenderScheduled = false;
|
||||
let remainingBudget = documentRenderBudget;
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
while (documentRenderQueue.length > 0 && remainingBudget > 0) {
|
||||
const next = documentRenderQueue.shift();
|
||||
const piece = next.length > remainingBudget ? next.slice(0, remainingBudget) : next;
|
||||
const rest = next.length > remainingBudget ? next.slice(remainingBudget) : "";
|
||||
fragment.append(renderYAMLSyntax(piece));
|
||||
remainingBudget -= piece.length;
|
||||
if (rest) {
|
||||
documentRenderQueue.unshift(rest);
|
||||
}
|
||||
}
|
||||
|
||||
streamDocumentElement.append(fragment);
|
||||
streamDocumentElement.scrollTop = streamDocumentElement.scrollHeight;
|
||||
|
||||
if (documentRenderQueue.length > 0) {
|
||||
scheduleDocumentFlush();
|
||||
return;
|
||||
}
|
||||
|
||||
documentRenderResolvers.splice(0).forEach((resolve) => resolve());
|
||||
}
|
||||
|
||||
function waitForDocumentFlush() {
|
||||
if (!documentRenderScheduled && documentRenderQueue.length === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
documentRenderResolvers.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function renderYAMLSyntax(text) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const segment of text.split(/(\r?\n)/)) {
|
||||
if (segment === "\n" || segment === "\r\n") {
|
||||
fragment.append(document.createTextNode(segment));
|
||||
} else {
|
||||
renderYAMLLine(segment, fragment);
|
||||
}
|
||||
}
|
||||
return fragment;
|
||||
}
|
||||
|
||||
function renderYAMLLine(line, fragment) {
|
||||
const commentIndex = findYAMLCommentIndex(line);
|
||||
const source = commentIndex >= 0 ? line.slice(0, commentIndex) : line;
|
||||
const comment = commentIndex >= 0 ? line.slice(commentIndex) : "";
|
||||
const keyMatch = source.match(/^(\s*(?:-\s*)?)([A-Za-z0-9_.-]+)(\s*:)(.*)$/);
|
||||
if (source.trim() === "---" || source.trim() === "...") {
|
||||
appendSyntaxSpan(fragment, source, "yaml-marker");
|
||||
} else if (keyMatch) {
|
||||
fragment.append(document.createTextNode(keyMatch[1]));
|
||||
appendSyntaxSpan(fragment, keyMatch[2], "yaml-key");
|
||||
appendSyntaxSpan(fragment, keyMatch[3], "yaml-punctuation");
|
||||
appendYAMLValue(fragment, keyMatch[4]);
|
||||
} else {
|
||||
appendYAMLValue(fragment, source);
|
||||
}
|
||||
if (comment) {
|
||||
appendSyntaxSpan(fragment, comment, "yaml-comment");
|
||||
}
|
||||
}
|
||||
|
||||
function appendYAMLValue(fragment, value) {
|
||||
const trimmed = value.trim();
|
||||
const leading = value.slice(0, value.length - value.trimStart().length);
|
||||
const trailingStart = value.length - value.trimEnd().length;
|
||||
const trailing = trailingStart > 0 ? value.slice(value.length - trailingStart) : "";
|
||||
if (leading) {
|
||||
fragment.append(document.createTextNode(leading));
|
||||
}
|
||||
if (/^(true|false|null|~)$/i.test(trimmed)) {
|
||||
appendSyntaxSpan(fragment, trimmed, "yaml-literal");
|
||||
} else if (/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(trimmed)) {
|
||||
appendSyntaxSpan(fragment, trimmed, "yaml-number");
|
||||
} else if (/^(['"]).*\1$/.test(trimmed)) {
|
||||
appendSyntaxSpan(fragment, trimmed, "yaml-string");
|
||||
} else {
|
||||
fragment.append(document.createTextNode(trimmed));
|
||||
}
|
||||
if (trailing) {
|
||||
fragment.append(document.createTextNode(trailing));
|
||||
}
|
||||
}
|
||||
|
||||
function appendSyntaxSpan(fragment, text, className) {
|
||||
const span = document.createElement("span");
|
||||
span.className = className;
|
||||
span.textContent = text;
|
||||
fragment.append(span);
|
||||
}
|
||||
|
||||
function findYAMLCommentIndex(line) {
|
||||
let quote = null;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const character = line[index];
|
||||
if ((character === "'" || character === "\"") && line[index - 1] !== "\\") {
|
||||
quote = quote === character ? null : quote || character;
|
||||
}
|
||||
if (character === "#" && quote === null && (index === 0 || /\s/.test(line[index - 1]))) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function formatDuration(milliseconds) {
|
||||
if (milliseconds < 1000) {
|
||||
return `${milliseconds.toFixed(1)} ms`;
|
||||
}
|
||||
return `${(milliseconds / 1000).toFixed(3)} s`;
|
||||
}
|
||||
|
||||
function formatBytes(byteCount) {
|
||||
if (byteCount < 1024) {
|
||||
return `${byteCount.toFixed(0)} B`;
|
||||
}
|
||||
if (byteCount < 1024 * 1024) {
|
||||
return `${(byteCount / 1024).toFixed(1)} KiB`;
|
||||
}
|
||||
return `${(byteCount / 1024 / 1024).toFixed(2)} MiB`;
|
||||
}
|
||||
|
||||
function resetLog() {
|
||||
runStartedAt = performance.now();
|
||||
runLogElement.replaceChildren();
|
||||
}
|
||||
|
||||
function logStep(message, level = "info") {
|
||||
const entry = document.createElement("div");
|
||||
entry.className = `log-entry ${level}`;
|
||||
const elapsed = (performance.now() - runStartedAt) / 1000;
|
||||
entry.textContent = `[+${elapsed.toFixed(3)}s] ${message}`;
|
||||
runLogElement.append(entry);
|
||||
runLogElement.scrollTop = runLogElement.scrollHeight;
|
||||
}
|
||||
|
||||
function nextPaint() {
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(resolve);
|
||||
});
|
||||
});
|
||||
}
|
||||
BIN
pureyaml-streaming-wasm-smoke.wasm.gz
Normal file
BIN
pureyaml-streaming-wasm-smoke.wasm.gz
Normal file
Binary file not shown.
126
stream-worker.js
Normal file
126
stream-worker.js
Normal file
@ -0,0 +1,126 @@
|
||||
import {
|
||||
ConsoleStdout,
|
||||
File,
|
||||
OpenFile,
|
||||
WASI,
|
||||
} from "./vendor/browser_wasi_shim/index.js";
|
||||
|
||||
const controlIndex = {
|
||||
read: 0,
|
||||
write: 1,
|
||||
available: 2,
|
||||
closed: 3,
|
||||
error: 4,
|
||||
sequence: 5,
|
||||
totalWritten: 6,
|
||||
readCalls: 7,
|
||||
waitCount: 8,
|
||||
};
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data?.type !== "start") {
|
||||
return;
|
||||
}
|
||||
|
||||
runStreamingBenchmark(event.data).catch((error) => {
|
||||
self.postMessage({
|
||||
type: "error",
|
||||
message: String(error?.stack || error),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function runStreamingBenchmark(config) {
|
||||
const moduleLoadStart = performance.now();
|
||||
const module = await loadWasmModule(config.wasmURL);
|
||||
const moduleLoadMS = performance.now() - moduleLoadStart;
|
||||
self.postMessage({ type: "log", message: `Worker loaded WASM module in ${moduleLoadMS.toFixed(1)} ms.` });
|
||||
let stdoutText = "";
|
||||
let stderrText = "";
|
||||
const stdin = new StreamingStdin(config.sharedBuffer, config.capacity);
|
||||
const wasi = new WASI(
|
||||
["pureyaml-streaming-wasm-smoke", "--stream-stdin", config.sourceURL, String(config.statusCode)],
|
||||
[],
|
||||
[
|
||||
stdin,
|
||||
ConsoleStdout.lineBuffered((line) => {
|
||||
stdoutText += `${line}\n`;
|
||||
self.postMessage({ type: "stdout", line });
|
||||
}),
|
||||
ConsoleStdout.lineBuffered((line) => {
|
||||
stderrText += `${line}\n`;
|
||||
self.postMessage({ type: "stderr", line });
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
self.postMessage({ type: "log", message: "Worker instantiated WASI runtime." });
|
||||
const processStart = performance.now();
|
||||
const instance = await WebAssembly.instantiate(module, {
|
||||
wasi_snapshot_preview1: wasi.wasiImport,
|
||||
});
|
||||
self.postMessage({ type: "log", message: "Worker entering WASI _start with streaming stdin." });
|
||||
wasi.start(instance);
|
||||
const processWallClockMS = performance.now() - processStart;
|
||||
self.postMessage({
|
||||
type: "done",
|
||||
stdoutText,
|
||||
stderrText,
|
||||
moduleLoadMS,
|
||||
processWallClockMS,
|
||||
readCalls: Atomics.load(stdin.control, controlIndex.readCalls),
|
||||
waitCount: Atomics.load(stdin.control, controlIndex.waitCount),
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWasmModule(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch WASM in worker: HTTP ${response.status}`);
|
||||
}
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (!url.endsWith(".gz")) {
|
||||
return WebAssembly.compile(bytes);
|
||||
}
|
||||
if (!("DecompressionStream" in globalThis)) {
|
||||
throw new Error("This browser does not support DecompressionStream in workers.");
|
||||
}
|
||||
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
|
||||
return WebAssembly.compile(await new Response(stream).arrayBuffer());
|
||||
}
|
||||
|
||||
class StreamingStdin extends OpenFile {
|
||||
constructor(sharedBuffer, capacity) {
|
||||
super(new File([]));
|
||||
this.control = new Int32Array(sharedBuffer, 0, 16);
|
||||
this.bytes = new Uint8Array(sharedBuffer, 16 * Int32Array.BYTES_PER_ELEMENT, capacity);
|
||||
this.capacity = capacity;
|
||||
}
|
||||
|
||||
fd_read(length) {
|
||||
const data = this.readBlocking(length);
|
||||
return { ret: 0, data };
|
||||
}
|
||||
|
||||
readBlocking(length) {
|
||||
while (Atomics.load(this.control, controlIndex.available) === 0) {
|
||||
if (Atomics.load(this.control, controlIndex.closed) === 1 || Atomics.load(this.control, controlIndex.error) === 1) {
|
||||
return new Uint8Array();
|
||||
}
|
||||
Atomics.add(this.control, controlIndex.waitCount, 1);
|
||||
const sequence = Atomics.load(this.control, controlIndex.sequence);
|
||||
Atomics.wait(this.control, controlIndex.sequence, sequence);
|
||||
}
|
||||
|
||||
const available = Atomics.load(this.control, controlIndex.available);
|
||||
const read = Atomics.load(this.control, controlIndex.read);
|
||||
const byteCount = Math.min(length, available, this.capacity - read);
|
||||
const result = this.bytes.slice(read, read + byteCount);
|
||||
Atomics.store(this.control, controlIndex.read, (read + byteCount) % this.capacity);
|
||||
Atomics.sub(this.control, controlIndex.available, byteCount);
|
||||
Atomics.add(this.control, controlIndex.readCalls, 1);
|
||||
Atomics.add(this.control, controlIndex.sequence, 1);
|
||||
Atomics.notify(this.control, controlIndex.sequence);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
243
style.css
Normal file
243
style.css
Normal file
@ -0,0 +1,243 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
background: Canvas;
|
||||
color: CanvasText;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
padding: 28px 0;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1040px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid color-mix(in srgb, CanvasText 18%, transparent);
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
background: color-mix(in srgb, Canvas 92%, CanvasText 8%);
|
||||
box-shadow: 0 24px 80px color-mix(in srgb, CanvasText 12%, transparent);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(36px, 7vw, 72px);
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: 720px;
|
||||
line-height: 1.6;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 18px 0 8px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
select,
|
||||
input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
border: 1px solid color-mix(in srgb, CanvasText 18%, transparent);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
background: Canvas;
|
||||
color: CanvasText;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
margin-top: 18px;
|
||||
margin-right: 10px;
|
||||
padding: 12px 18px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
background: CanvasText;
|
||||
color: Canvas;
|
||||
}
|
||||
|
||||
#streamButton {
|
||||
border: 1px solid CanvasText;
|
||||
background: Canvas;
|
||||
color: CanvasText;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
margin-top: 0;
|
||||
border: 1px solid CanvasText;
|
||||
background: Canvas;
|
||||
color: CanvasText;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 18px;
|
||||
display: inline-flex;
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
background: color-mix(in srgb, CanvasText 10%, transparent);
|
||||
}
|
||||
|
||||
.status.pass {
|
||||
background: color-mix(in srgb, #008a45 30%, transparent);
|
||||
}
|
||||
|
||||
.status.fail {
|
||||
background: color-mix(in srgb, #c21f39 30%, transparent);
|
||||
}
|
||||
|
||||
.metrics {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.metrics div {
|
||||
border: 1px solid color-mix(in srgb, CanvasText 14%, transparent);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
background: color-mix(in srgb, Canvas 96%, CanvasText 4%);
|
||||
}
|
||||
|
||||
dt {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font: 700 24px/1.1 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
#isolationMetric,
|
||||
#sabMetric,
|
||||
#workerMetric,
|
||||
#modeMetric {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.run-log {
|
||||
height: min(320px, 45vh);
|
||||
overflow-y: auto;
|
||||
border: 1px solid color-mix(in srgb, CanvasText 14%, transparent);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
background: color-mix(in srgb, Canvas 96%, CanvasText 4%);
|
||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.stream-document {
|
||||
height: min(420px, 55vh);
|
||||
overflow: auto;
|
||||
border: 1px solid color-mix(in srgb, CanvasText 14%, transparent);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
background: color-mix(in srgb, Canvas 96%, CanvasText 4%);
|
||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.yaml-key {
|
||||
color: #4f8cff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.yaml-punctuation,
|
||||
.yaml-marker {
|
||||
color: #b66cff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.yaml-comment {
|
||||
color: #708090;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.yaml-string {
|
||||
color: #138a36;
|
||||
}
|
||||
|
||||
.yaml-number {
|
||||
color: #b36b00;
|
||||
}
|
||||
|
||||
.yaml-literal {
|
||||
color: #c21f39;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, CanvasText 8%, transparent);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.log-entry:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.log-entry.muted {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
color: #c21f39;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.5;
|
||||
}
|
||||
1
vendor/browser_wasi_shim/debug.js
vendored
Normal file
1
vendor/browser_wasi_shim/debug.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
let Debug=class Debug{enable(enabled){this.log=createLogger(enabled===undefined?true:enabled,this.prefix)}get enabled(){return this.isEnabled}constructor(isEnabled){this.isEnabled=isEnabled;this.prefix="wasi:";this.enable(isEnabled)}};function createLogger(enabled,prefix){if(enabled){const a=console.log.bind(console,"%c%s","color: #265BA0",prefix);return a}else{return()=>{}}}export const debug=new Debug(false);
|
||||
1
vendor/browser_wasi_shim/fd.js
vendored
Normal file
1
vendor/browser_wasi_shim/fd.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
import*as wasi from"./wasi_defs.js";export class Fd{fd_allocate(offset,len){return wasi.ERRNO_NOTSUP}fd_close(){return 0}fd_fdstat_get(){return{ret:wasi.ERRNO_NOTSUP,fdstat:null}}fd_fdstat_set_flags(flags){return wasi.ERRNO_NOTSUP}fd_fdstat_set_rights(fs_rights_base,fs_rights_inheriting){return wasi.ERRNO_NOTSUP}fd_filestat_get(){return{ret:wasi.ERRNO_NOTSUP,filestat:null}}fd_filestat_set_size(size){return wasi.ERRNO_NOTSUP}fd_filestat_set_times(atim,mtim,fst_flags){return wasi.ERRNO_NOTSUP}fd_pread(size,offset){return{ret:wasi.ERRNO_NOTSUP,data:new Uint8Array}}fd_prestat_get(){return{ret:wasi.ERRNO_NOTSUP,prestat:null}}fd_pwrite(data,offset){return{ret:wasi.ERRNO_NOTSUP,nwritten:0}}fd_read(size){return{ret:wasi.ERRNO_NOTSUP,data:new Uint8Array}}fd_readdir_single(cookie){return{ret:wasi.ERRNO_NOTSUP,dirent:null}}fd_seek(offset,whence){return{ret:wasi.ERRNO_NOTSUP,offset:0n}}fd_sync(){return 0}fd_tell(){return{ret:wasi.ERRNO_NOTSUP,offset:0n}}fd_write(data){return{ret:wasi.ERRNO_NOTSUP,nwritten:0}}path_create_directory(path){return wasi.ERRNO_NOTSUP}path_filestat_get(flags,path){return{ret:wasi.ERRNO_NOTSUP,filestat:null}}path_filestat_set_times(flags,path,atim,mtim,fst_flags){return wasi.ERRNO_NOTSUP}path_link(path,inode,allow_dir){return wasi.ERRNO_NOTSUP}path_unlink(path){return{ret:wasi.ERRNO_NOTSUP,inode_obj:null}}path_lookup(path,dirflags){return{ret:wasi.ERRNO_NOTSUP,inode_obj:null}}path_open(dirflags,path,oflags,fs_rights_base,fs_rights_inheriting,fd_flags){return{ret:wasi.ERRNO_NOTDIR,fd_obj:null}}path_readlink(path){return{ret:wasi.ERRNO_NOTSUP,data:null}}path_remove_directory(path){return wasi.ERRNO_NOTSUP}path_rename(old_path,new_fd,new_path){return wasi.ERRNO_NOTSUP}path_unlink_file(path){return wasi.ERRNO_NOTSUP}}export class Inode{}
|
||||
1
vendor/browser_wasi_shim/fs_mem.js
vendored
Normal file
1
vendor/browser_wasi_shim/fs_mem.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
vendor/browser_wasi_shim/fs_opfs.js
vendored
Normal file
1
vendor/browser_wasi_shim/fs_opfs.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
import*as wasi from"./wasi_defs.js";import{Fd,Inode}from"./fd.js";export class SyncOPFSFile extends Inode{path_open(oflags,fs_rights_base,fd_flags){if(this.readonly&&(fs_rights_base&BigInt(wasi.RIGHTS_FD_WRITE))==BigInt(wasi.RIGHTS_FD_WRITE)){return{ret:wasi.ERRNO_PERM,fd_obj:null}}if((oflags&wasi.OFLAGS_TRUNC)==wasi.OFLAGS_TRUNC){if(this.readonly)return{ret:wasi.ERRNO_PERM,fd_obj:null};this.handle.truncate(0)}const file=new OpenSyncOPFSFile(this);if(fd_flags&wasi.FDFLAGS_APPEND)file.fd_seek(0n,wasi.WHENCE_END);return{ret:wasi.ERRNO_SUCCESS,fd_obj:file}}get size(){return BigInt(this.handle.getSize())}stat(){return new wasi.Filestat(wasi.FILETYPE_REGULAR_FILE,this.size)}constructor(handle,options){super();this.handle=handle;this.readonly=!!options?.readonly}}export class OpenSyncOPFSFile extends Fd{fd_allocate(offset,len){if(BigInt(this.file.handle.getSize())>offset+len){}else{this.file.handle.truncate(Number(offset+len))}return wasi.ERRNO_SUCCESS}fd_fdstat_get(){return{ret:0,fdstat:new wasi.Fdstat(wasi.FILETYPE_REGULAR_FILE,0)}}fd_filestat_get(){return{ret:0,filestat:new wasi.Filestat(wasi.FILETYPE_REGULAR_FILE,BigInt(this.file.handle.getSize()))}}fd_filestat_set_size(size){this.file.handle.truncate(Number(size));return wasi.ERRNO_SUCCESS}fd_read(size){const buf=new Uint8Array(size);const n=this.file.handle.read(buf,{at:Number(this.position)});this.position+=BigInt(n);return{ret:0,data:buf.slice(0,n)}}fd_seek(offset,whence){let calculated_offset;switch(whence){case wasi.WHENCE_SET:calculated_offset=BigInt(offset);break;case wasi.WHENCE_CUR:calculated_offset=this.position+BigInt(offset);break;case wasi.WHENCE_END:calculated_offset=BigInt(this.file.handle.getSize())+BigInt(offset);break;default:return{ret:wasi.ERRNO_INVAL,offset:0n}}if(calculated_offset<0){return{ret:wasi.ERRNO_INVAL,offset:0n}}this.position=calculated_offset;return{ret:wasi.ERRNO_SUCCESS,offset:this.position}}fd_write(data){if(this.file.readonly)return{ret:wasi.ERRNO_BADF,nwritten:0};const n=this.file.handle.write(data,{at:Number(this.position)});this.position+=BigInt(n);return{ret:wasi.ERRNO_SUCCESS,nwritten:n}}fd_sync(){this.file.handle.flush();return wasi.ERRNO_SUCCESS}constructor(file){super();this.position=0n;this.file=file}}
|
||||
1
vendor/browser_wasi_shim/index.js
vendored
Normal file
1
vendor/browser_wasi_shim/index.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
import WASI,{WASIProcExit}from"./wasi.js";export{WASI,WASIProcExit};export{Fd,Inode}from"./fd.js";export{File,Directory,OpenFile,OpenDirectory,PreopenDirectory,ConsoleStdout}from"./fs_mem.js";export{SyncOPFSFile,OpenSyncOPFSFile}from"./fs_opfs.js";export{strace}from"./strace.js";export*as wasi from"./wasi_defs.js";
|
||||
1
vendor/browser_wasi_shim/strace.js
vendored
Normal file
1
vendor/browser_wasi_shim/strace.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
export function strace(imports,no_trace){return new Proxy(imports,{get(target,prop,receiver){const f=Reflect.get(target,prop,receiver);if(no_trace.includes(prop)){return f}return function(...args){console.log(prop,"(",...args,")");const result=Reflect.apply(f,receiver,args);console.log(" =",result);return result}}})}
|
||||
1
vendor/browser_wasi_shim/wasi.js
vendored
Normal file
1
vendor/browser_wasi_shim/wasi.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
vendor/browser_wasi_shim/wasi_defs.js
vendored
Normal file
1
vendor/browser_wasi_shim/wasi_defs.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user