Async IO in streaming render — staggered resolve with io-sleep

Server (sx_server.ml):
- eval_with_io: CEK evaluator with IO suspension handling (io-sleep, import)
- io-sleep platform primitive: raises CekPerformRequest, resolved by eval_with_io
- Streaming render uses eval_with_io for data + content evaluation
- Data items with "delay" field sleep before resolving (async streaming)
- Removed hardcoded streaming-demo-data — application logic belongs in .sx

Application (streaming-demo.sx):
- streaming-demo-data defined in SX: 3 items with 1s/3s/5s delays
- Each item has delay, stream-id, and display data fields
- Shell renders instantly, slots fill progressively as IO completes

Tests (streaming.spec.js):
- Staggered resolve test: fast resolves first, medium/slow still skeleton
- Verifies independent slot resolution matches async IO behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 16:26:44 +00:00
parent c850737c60
commit 3cada3f8fe

View File

@@ -322,30 +322,39 @@ test.describe('Streaming sandbox', () => {
// Verify skeletons are showing
await expect(page.locator('[data-suspense="stream-fast"]')).toHaveCount(1);
// Phase 2: Simulate server sending resolve scripts (like chunked transfer)
// Use the real Sx.resolveSuspense (calls kernel's resolve-suspense SX fn)
await page.evaluate(() => {
// Phase 2: Staggered resolve — simulates chunked transfer with async IO delays.
// Each resolve arrives independently; other slots remain as skeletons.
// Simulate 3 resolve scripts arriving from chunked transfer
// Resolve "fast" first (~1s in production) — others still loading
await page.evaluate(() => {
window.__sxResolve('stream-fast',
'(~streaming-demo/chunk :stream-label "Fast source" :stream-color "emerald" :stream-message "Resolved in 1ms" :stream-time "1ms")');
'(~streaming-demo/chunk :stream-label "Fast source" :stream-color "emerald" :stream-message "Resolved in ~1s (async IO)" :stream-time "~1s")');
});
await expect(page.locator('[data-suspense="stream-fast"]')).toContainText('Fast source');
// Medium and slow still show skeletons
await expect(page.locator('[data-suspense="stream-medium"]')).not.toContainText('Medium source');
await expect(page.locator('[data-suspense="stream-slow"]')).not.toContainText('Slow source');
// Resolve "medium" (~3s in production) — slow still loading
await page.evaluate(() => {
window.__sxResolve('stream-medium',
'(~streaming-demo/chunk :stream-label "Medium source" :stream-color "amber" :stream-message "Resolved in 50ms" :stream-time "50ms")');
'(~streaming-demo/chunk :stream-label "Medium source" :stream-color "amber" :stream-message "Resolved in ~3s (async IO)" :stream-time "~3s")');
});
await expect(page.locator('[data-suspense="stream-medium"]')).toContainText('Medium source');
await expect(page.locator('[data-suspense="stream-slow"]')).not.toContainText('Slow source');
// Resolve "slow" (~5s in production) — all done
await page.evaluate(() => {
window.__sxResolve('stream-slow',
'(~streaming-demo/chunk :stream-label "Slow source" :stream-color "violet" :stream-message "Resolved in 200ms" :stream-time "200ms")');
'(~streaming-demo/chunk :stream-label "Slow source" :stream-color "violet" :stream-message "Resolved in ~5s (async IO)" :stream-time "~5s")');
});
// Phase 3: Verify all slots filled with actual content
// Phase 3: All slots filled with actual content
await expect(page.locator('[data-suspense="stream-fast"]')).toContainText('Fast source');
await expect(page.locator('[data-suspense="stream-fast"]')).toContainText('1ms');
await expect(page.locator('[data-suspense="stream-fast"]')).toContainText('~1s');
await expect(page.locator('[data-suspense="stream-medium"]')).toContainText('Medium source');
await expect(page.locator('[data-suspense="stream-medium"]')).toContainText('50ms');
await expect(page.locator('[data-suspense="stream-medium"]')).toContainText('~3s');
await expect(page.locator('[data-suspense="stream-slow"]')).toContainText('Slow source');
await expect(page.locator('[data-suspense="stream-slow"]')).toContainText('200ms');
// Skeletons should be gone — replaced by resolved content
await expect(page.locator('[data-suspense="stream-fast"]')).not.toContainText('Loading');
await expect(page.locator('[data-suspense="stream-slow"]')).toContainText('~5s');
});
});