Compare commits

..
14 Commits
Author SHA1 Message Date
meiseayoung 22e8dd4548 chore: update v.mod version 2026-01-06 14:11:40 +08:00
meiseayoung a702746df7 build(usockets): add pre-compiled library for MacOS-arm64 platform 2026-01-06 14:10:02 +08:00
meiseayoung a57ce2e77a chore: update v.mod version 2026-01-06 12:01:45 +08:00
meiseayoung 7a08c3142a build(usockets): add pre-compiled library for MacOS-x86 platform 2026-01-06 12:01:20 +08:00
meiseayoung 362015c1cf docs: update build instructions for uSockets backend
- Clarify that Windows requires gcc (tcc does not support .a static library format)
- Add separate build commands for macOS/Linux and Windows
- Document pre-compiled library locations by platform
- Add w64devkit as recommended MinGW download option
2026-01-05 12:25:06 +08:00
meiseayoung 0969901481 chore: update v.mod version 2026-01-05 11:50:46 +08:00
meiseayoung 6bddfc3731 build(usockets): add pre-compiled library for Linux platform 2026-01-05 11:49:47 +08:00
meiseayoung 26ac804b1d chore: update v.mod version 2026-01-05 11:07:41 +08:00
meiseayoung 56299b5485 refactor(usockets): reorganize lib directory structure for multi-platform support
- Organize lib directory by platform: windows/, macos-arm64/, macos-x64/, linux/
- Update build.sh to support unified build process for Windows/macOS/Linux
- Compile libuv + uSockets and merge into complete static library
- Update usockets.v conditional compilation to point to new directory structure
- Optimize benchmark tests with connection pooling for high concurrency testing
- Clean up unused legacy library files

Performance test results (Windows):
- 1000 concurrent, 1M requests: 35,223 req/s
- 10000 concurrent, 1M requests: 26,884 req/s
2026-01-05 11:06:56 +08:00
meiseayoung d31492fd59 chore: update v.mod version 2026-01-04 18:12:21 +08:00
meiseayoung e19f799e46 chore: upload usokets lib 2026-01-04 18:11:19 +08:00
meiseayoung f7e753969d fix: change the usockets/lib path 2026-01-04 15:44:54 +08:00
meiseayoung e9d14a35f6 fix(streaming): use direct socket fd writes for real-time SSE streaming
Previously, PicoevStreamWriter used picohttpparser.Response.raw() to write
streaming data, which only buffered the data until the entire request handler
completed. This caused SSE responses to be delayed until all sleep() calls
finished (e.g., 2.89s delay for /stream-text endpoint).

Changes:
- Modified PicoevStreamWriter to use socket fd directly from picohttpparser.Response
- Implemented write_to_fd() using C.send() for immediate data delivery
- Updated handle_picoev_streaming_response() to write HTTP headers directly to socket
- Added C.send() function declaration for direct socket writes

This fix enables true real-time streaming where each write() or write_sse()
call immediately sends data to the client, rather than buffering until
the callback completes.

All 74 test files pass with no regressions.
2026-01-04 15:43:01 +08:00
meiseayoung 9ae9170bcb feat: add SSE streaming helper support
Add Server-Sent Events (SSE) and streaming response support with three
streaming functions:

- stream(): Basic binary streaming with chunked transfer encoding
- stream_text(): Text streaming with proper Content-Type headers
- stream_sse(): SSE event streaming with full event field support

Features:
- SSEEvent struct with data, event, id, retry fields
- StreamContext for write, writeln, write_sse, sleep, pipe operations
- Multi-line data handling in SSE events
- Custom error handler support
- Client disconnect detection via on_abort callback
- Integrated with both Picoev and uSockets backends

New files:
- streaming.v: Core streaming implementation
- examples/sse_demo.v: Comprehensive SSE demo with test page
- tests/test_stream_*.v: 62 property-based tests

Updated:
- picoev_server.v: PicoevStreamWriter integration
- usockets_server.v: UsocketsStreamWriter integration
- README.md: SSE streaming documentation
2026-01-04 15:13:28 +08:00
21 changed files with 6225 additions and 76 deletions
+1 -1
View File
@@ -2,7 +2,6 @@
*.exe
*.out
*.o
*.a
*.so
*.dll
@@ -47,3 +46,4 @@ temp/
chunks/
.kiro
*.def
+275 -11
View File
@@ -19,6 +19,7 @@ A high-performance V language web framework inspired by [Hono.js](https://hono.d
- 📤 **File Upload** - Chunked file upload support
- 🗄️ **Database** - SQLite integration for data persistence
- 🔌 **WebSocket** - RFC 6455 compliant WebSocket support with event-based API
- 📡 **SSE Streaming** - Server-Sent Events and streaming response support
## Installation
@@ -49,8 +50,13 @@ cd v-hono
### Standard Build (picoev backend)
```bash
v your_app.v -o app.exe
./app.exe
# macOS / Linux
v -prod -o app your_app.v
./app
# Windows
v -o app.exe your_app.v
.\app.exe
```
### High-Performance Build (uSockets backend)
@@ -58,25 +64,33 @@ v your_app.v -o app.exe
For maximum performance with uSockets backend:
```bash
v -enable-globals -cc gcc -ldflags "-ldbghelp" your_app.v -o app.exe
./app.exe
# macOS / Linux
v -enable-globals -prod -o app your_app.v
./app
# Windows (must use gcc, tcc does not support .a static library format)
v -enable-globals -cc gcc -ldflags "-ldbghelp" -o app.exe your_app.v
.\app.exe
```
**Note**: The `-enable-globals` flag is required for uSockets backend. The `-ldflags "-ldbghelp"` fixes Windows linking issues.
**Important Notes:**
- The `-enable-globals` flag is required for uSockets backend
- On Windows, you **must** use `-cc gcc` because V's default compiler (tcc) does not support MinGW `.a` static library format
- The `-ldflags "-ldbghelp"` is required on Windows for libuv linking
#### Windows Prerequisites
To use uSockets backend on Windows, you need:
1. **GCC (MinGW-w64)** - C compiler
1. **GCC (MinGW-w64)** - C compiler (required, tcc is not supported)
```powershell
# Install via Scoop
# Install via Scoop (recommended)
scoop install mingw
# Or via Chocolatey
choco install mingw
# Or download from: https://www.mingw-w64.org/downloads/
# Or download w64devkit from: https://github.com/skeeto/w64devkit/releases
```
2. **V Compiler** - Version 0.4.x or later
@@ -93,7 +107,11 @@ To use uSockets backend on Windows, you need:
v version # Should show V 0.4.x
```
The uSockets library and libuv are pre-compiled and included in the `usockets/lib/` directory.
The uSockets library and libuv are pre-compiled and included in the `usockets/lib/{platform}/` directory:
- `usockets/lib/windows/` - Windows x64
- `usockets/lib/linux/` - Linux x64
- `usockets/lib/macos-arm64/` - macOS Apple Silicon
- `usockets/lib/macos-x64/` - macOS Intel
### Using uSockets Backend
@@ -784,6 +802,251 @@ fn main() {
| 1009 | `ws_close_message_too_big` | Message too big |
| 1011 | `ws_close_internal_error` | Internal server error |
### 9. SSE Streaming Helper
Server-Sent Events (SSE) and streaming response support for real-time data push.
#### Basic Binary Streaming
```v
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
// Basic stream - binary data streaming
// Sets Transfer-Encoding: chunked header
app.get('/stream', fn (mut c hono.Context) http.Response {
return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
// Register abort callback for client disconnection
stream.on_abort(fn () {
println('Client disconnected')
})
// Stream binary data in chunks
for i in 0 .. 5 {
data := 'Chunk ${i + 1}: Hello from stream!\n'
stream.write(data.bytes())!
stream.sleep(500) // 500ms delay between chunks
}
})
})
app.listen(':3000')
}
```
#### Text Streaming
```v
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
// Text stream - for streaming text content
// Sets Content-Type: text/plain; charset=utf-8
// Sets Transfer-Encoding: chunked
// Sets X-Content-Type-Options: nosniff
app.get('/stream-text', fn (mut c hono.Context) http.Response {
return hono.c_stream_text(mut c, fn (mut stream hono.StreamContext) ! {
stream.writeln('=== Text Streaming Demo ===')!
stream.sleep(300)
for i in 0 .. 5 {
stream.write_string('Processing item ${i + 1}... ')!
stream.sleep(200)
stream.writeln('Done!')!
}
})
})
app.listen(':3000')
}
```
#### Server-Sent Events (SSE)
```v
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
// SSE stream - for Server-Sent Events
// Sets Content-Type: text/event-stream
// Sets Cache-Control: no-cache
// Sets Connection: keep-alive
app.get('/sse', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Send initial connection event
stream.write_sse(hono.SSEEvent{
data: 'Connected to SSE stream'
event: 'connect'
id: '0'
})!
// Send periodic updates
for i in 1 .. 6 {
stream.sleep(1000) // 1 second delay
stream.write_sse(hono.SSEEvent{
data: 'Update ${i}'
event: 'update'
id: '${i}'
})!
}
// Send completion event
stream.write_sse(hono.SSEEvent{
data: 'Stream completed'
event: 'complete'
id: '999'
})!
})
})
app.listen(':3000')
}
```
#### SSE with Multi-line Data
```v
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
app.get('/sse-json', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Send JSON data (multi-line formatted)
json_data := '{\n "name": "v-hono",\n "version": "1.0.0"\n}'
stream.write_sse(hono.SSEEvent{
data: json_data
event: 'json'
id: '1'
})!
})
})
app.listen(':3000')
}
```
#### SSE with Error Handling
```v
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
app.get('/sse-error', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
stream.write_sse(hono.SSEEvent{
data: 'Starting...'
event: 'start'
})!
// Simulate an error
return error('Something went wrong')
}, fn (err IError, mut stream hono.StreamContext) {
// Custom error handler
stream.write_sse(hono.SSEEvent{
data: 'Error: ${err.msg()}'
event: 'error'
}) or {}
})
})
app.listen(':3000')
}
```
#### SSE with Retry Field
```v
import meiseayoung.hono
fn main() {
mut app := hono.Hono.new()
app.get('/sse-retry', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Send event with retry field (client reconnects after 3 seconds)
stream.write_sse(hono.SSEEvent{
data: 'This event includes a retry field'
event: 'message'
id: '1'
retry: 3000 // 3 seconds
})!
})
})
app.listen(':3000')
}
```
#### StreamContext Methods
| Method | Description |
|--------|-------------|
| `write(data []u8)` | Write raw bytes to stream |
| `write_string(data string)` | Write string to stream |
| `writeln(data string)` | Write string with newline |
| `write_sse(event SSEEvent)` | Write SSE event |
| `sleep(ms int)` | Pause execution for milliseconds |
| `pipe(data []u8)` | Pipe data to stream |
| `on_abort(callback fn())` | Register abort callback |
| `close()` | Close the stream |
| `is_open()` | Check if stream is still open |
#### SSEEvent Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `data` | `string` | Yes | Event data (supports multi-line) |
| `event` | `string` | No | Event type name |
| `id` | `string` | No | Event ID for client reconnection |
| `retry` | `int` | No | Reconnection interval in milliseconds |
#### Streaming Functions
| Function | Headers Set | Use Case |
|----------|-------------|----------|
| `c_stream()` | `Transfer-Encoding: chunked` | Binary data streaming |
| `c_stream_text()` | `Content-Type: text/plain`, `Transfer-Encoding: chunked`, `X-Content-Type-Options: nosniff` | Text streaming |
| `c_stream_sse()` | `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive` | Server-Sent Events |
#### Client-Side JavaScript Example
```html
<script>
// Connect to SSE endpoint
const eventSource = new EventSource('/sse');
// Listen for specific event types
eventSource.addEventListener('connect', (e) => {
console.log('Connected:', e.data);
});
eventSource.addEventListener('update', (e) => {
console.log('Update:', e.data, 'ID:', e.lastEventId);
});
eventSource.addEventListener('complete', (e) => {
console.log('Complete:', e.data);
eventSource.close();
});
// Handle errors
eventSource.onerror = (e) => {
console.error('SSE Error:', e);
};
</script>
```
## Route Grouping
```v
@@ -889,8 +1152,9 @@ v-hono/
├── rate_limit.v # Rate limiting middleware
├── validator.v # Request validation
├── websocket.v # WebSocket helper (RFC 6455)
├── picoev_server.v # Picoev backend with WebSocket support
├── usockets_server.v # uSockets backend with WebSocket support
├── streaming.v # SSE streaming helper
├── picoev_server.v # Picoev backend with WebSocket/SSE support
├── usockets_server.v # uSockets backend with WebSocket/SSE support
├── v.mod # Module definition
├── README.md # Documentation
├── examples/ # Example applications
+33 -5
View File
@@ -448,21 +448,49 @@ func checkKeepAlive(client *http.Client) bool {
func checkThroughput(client *http.Client) float64 {
start := time.Now()
success := 0
requests := 200
concurrency := 1000
totalRequests := 1000000
requestsPerWorker := totalRequests / concurrency
for i := 0; i < requests; i++ {
resp, err := client.Get(baseURL + "/health")
// 创建共享的 Transport,启用连接池复用
transport := &http.Transport{
MaxIdleConns: concurrency,
MaxIdleConnsPerHost: concurrency,
IdleConnTimeout: 30 * time.Second,
DisableKeepAlives: false,
}
results := make(chan int, concurrency)
for i := 0; i < concurrency; i++ {
go func() {
localSuccess := 0
// 每个 goroutine 使用独立 Client,但共享 Transport(连接池)
localClient := &http.Client{
Timeout: 10 * time.Second,
Transport: transport,
}
for j := 0; j < requestsPerWorker; j++ {
resp, err := localClient.Get(baseURL + "/health")
if err != nil {
continue
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode == 200 {
success++
localSuccess++
}
}
results <- localSuccess
}()
}
success := 0
for i := 0; i < concurrency; i++ {
success += <-results
}
elapsed := time.Since(start).Seconds()
fmt.Printf("\n [并发: %d, 总请求: %d, 成功: %d, 耗时: %.2fs]\n ", concurrency, totalRequests, success, elapsed)
return float64(success) / elapsed
}
+554
View File
@@ -0,0 +1,554 @@
// SSE Streaming Demo - Server-Sent Events Example for v-hono
// This example demonstrates all SSE streaming helper functions:
// - stream() - Basic binary streaming
// - stream_text() - Text streaming
// - stream_sse() - Server-Sent Events streaming
//
// Run with: v -enable-globals run examples/sse_demo.v
// Then open a browser and navigate to http://127.0.0.1:3000
// Or use curl to test the endpoints:
// curl http://127.0.0.1:3000/stream
// curl http://127.0.0.1:3000/stream-text
// curl http://127.0.0.1:3000/sse
//
// Features demonstrated:
// - Basic binary streaming with stream()
// - Text streaming with stream_text()
// - SSE event streaming with stream_sse()
// - Error handling with custom error handlers
// - SSE event fields (data, event, id, retry)
// - Multi-line data in SSE events
// - Sleep/delay between stream writes
// - onAbort callback for client disconnection
module main
import net.http
import os
import hono
fn main() {
mut app := hono.Hono.new()
// =========================================================================
// Static HTML page for testing SSE
// =========================================================================
app.get('/', fn (mut c hono.Context) http.Response {
return c.html(get_sse_test_page())
})
// =========================================================================
// Basic Binary Streaming Example
// =========================================================================
// Basic stream - demonstrates binary data streaming
// Sets Transfer-Encoding: chunked header
app.get('/stream', fn (mut c hono.Context) http.Response {
return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
// Register abort callback for client disconnection
stream.on_abort(fn () {
println('[Stream] Client disconnected')
})
// Stream binary data in chunks
for i in 0 .. 5 {
data := 'Chunk ${i + 1}: Hello from binary stream!\n'
stream.write(data.bytes())!
stream.sleep(500) // 500ms delay between chunks
}
stream.write('Stream complete!\n'.bytes())!
})
})
// =========================================================================
// Text Streaming Example
// =========================================================================
// Text stream - demonstrates text data streaming
// Sets Content-Type: text/plain; charset=utf-8
// Sets Transfer-Encoding: chunked
// Sets X-Content-Type-Options: nosniff
app.get('/stream-text', fn (mut c hono.Context) http.Response {
return hono.c_stream_text(mut c, fn (mut stream hono.StreamContext) ! {
// Register abort callback
stream.on_abort(fn () {
println('[StreamText] Client disconnected')
})
// Stream text data with writeln (adds newline automatically)
stream.writeln('=== Text Streaming Demo ===')!
stream.sleep(300)
for i in 0 .. 5 {
stream.write_string('Processing item ${i + 1}... ')!
stream.sleep(200)
stream.writeln('Done!')!
stream.sleep(300)
}
stream.writeln('')!
stream.writeln('All items processed successfully!')!
})
})
// =========================================================================
// SSE (Server-Sent Events) Streaming Example
// =========================================================================
// SSE stream - demonstrates Server-Sent Events
// Sets Content-Type: text/event-stream
// Sets Cache-Control: no-cache
// Sets Connection: keep-alive
app.get('/sse', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Register abort callback
stream.on_abort(fn () {
println('[SSE] Client disconnected')
})
// Send initial connection event
stream.write_sse(hono.SSEEvent{
data: 'Connected to SSE stream'
event: 'connect'
id: '0'
})!
// Send periodic updates
for i in 1 .. 6 {
stream.sleep(1000) // 1 second delay
stream.write_sse(hono.SSEEvent{
data: 'Update ${i}: Current time is ${get_timestamp()}'
event: 'update'
id: '${i}'
})!
}
// Send completion event
stream.write_sse(hono.SSEEvent{
data: 'Stream completed'
event: 'complete'
id: '999'
})!
})
})
// =========================================================================
// SSE with Multi-line Data Example
// =========================================================================
// SSE with multi-line data - demonstrates multi-line data handling
app.get('/sse-multiline', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Send event with multi-line data
stream.write_sse(hono.SSEEvent{
data: 'Line 1: First line of data\nLine 2: Second line of data\nLine 3: Third line of data'
event: 'multiline'
id: '1'
})!
stream.sleep(1000)
// Send JSON data (multi-line formatted)
json_data := '{\n "name": "v-hono",\n "version": "1.0.0",\n "features": ["SSE", "WebSocket", "Streaming"]\n}'
stream.write_sse(hono.SSEEvent{
data: json_data
event: 'json'
id: '2'
})!
})
})
// =========================================================================
// SSE with Retry Field Example
// =========================================================================
// SSE with retry - demonstrates retry field for reconnection
app.get('/sse-retry', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Send event with retry field (tells client to reconnect after 3 seconds)
stream.write_sse(hono.SSEEvent{
data: 'This event includes a retry field'
event: 'message'
id: '1'
retry: 3000 // 3 seconds
})!
stream.sleep(1000)
// Send another event without retry
stream.write_sse(hono.SSEEvent{
data: 'This event does not include retry'
event: 'message'
id: '2'
})!
})
})
// =========================================================================
// SSE with Error Handling Example
// =========================================================================
// SSE with error handling - demonstrates custom error handler
app.get('/sse-error', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// Send initial event
stream.write_sse(hono.SSEEvent{
data: 'Starting stream with potential error...'
event: 'start'
id: '1'
})!
stream.sleep(1000)
// Simulate an error condition
// In real applications, this could be a database error, API failure, etc.
should_error := true
if should_error {
return error('Simulated error for demonstration')
}
// This won't be reached due to the error above
stream.write_sse(hono.SSEEvent{
data: 'This message will not be sent'
event: 'message'
id: '2'
})!
}, fn (err IError, mut stream hono.StreamContext) {
// Custom error handler - send error event to client
println('[SSE Error Handler] Error occurred: ${err.msg()}')
// Try to send error event to client before closing
stream.write_sse(hono.SSEEvent{
data: 'Error: ${err.msg()}'
event: 'error'
id: 'error'
}) or {
println('[SSE Error Handler] Failed to send error event: ${err}')
}
})
})
// =========================================================================
// Real-time Counter Example
// =========================================================================
// Real-time counter - demonstrates continuous SSE updates
app.get('/sse-counter', fn (mut c hono.Context) http.Response {
return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
mut counter := 0
// Send counter updates every 500ms for 20 iterations
for _ in 0 .. 20 {
if !stream.is_open() {
println('[Counter] Stream closed, stopping')
break
}
counter++
stream.write_sse(hono.SSEEvent{
data: '${counter}'
event: 'counter'
id: '${counter}'
})!
stream.sleep(500)
}
// Send completion event
stream.write_sse(hono.SSEEvent{
data: 'Counter finished at ${counter}'
event: 'complete'
id: 'done'
})!
})
})
// =========================================================================
// Pipe Example - Stream file content
// =========================================================================
// Pipe example - demonstrates piping data to stream
app.get('/stream-pipe', fn (mut c hono.Context) http.Response {
return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
// Create some sample data to pipe
sample_data := 'This is sample data that will be piped to the stream.\n'.repeat(5)
// Pipe the data in chunks
chunk_size := 50
mut offset := 0
for offset < sample_data.len {
end := if offset + chunk_size > sample_data.len { sample_data.len } else { offset + chunk_size }
chunk := sample_data[offset..end]
stream.pipe(chunk.bytes())!
offset = end
stream.sleep(100)
}
})
})
// =========================================================================
// REST API endpoints for demonstration
// =========================================================================
app.get('/api/status', fn (mut c hono.Context) http.Response {
return c.json('{"status":"running","streaming_endpoints":["/stream","/stream-text","/sse","/sse-multiline","/sse-retry","/sse-error","/sse-counter","/stream-pipe"]}')
})
// =========================================================================
// Start the server
// =========================================================================
println('============================================================')
println('SSE Streaming Demo Server')
println('============================================================')
println('')
println('Server starting on http://127.0.0.1:3000')
println('')
println('Streaming Endpoints:')
println(' http://127.0.0.1:3000/stream - Basic binary streaming')
println(' http://127.0.0.1:3000/stream-text - Text streaming')
println(' http://127.0.0.1:3000/sse - SSE event streaming')
println(' http://127.0.0.1:3000/sse-multiline - SSE with multi-line data')
println(' http://127.0.0.1:3000/sse-retry - SSE with retry field')
println(' http://127.0.0.1:3000/sse-error - SSE with error handling')
println(' http://127.0.0.1:3000/sse-counter - Real-time counter')
println(' http://127.0.0.1:3000/stream-pipe - Pipe data to stream')
println('')
println('HTTP Endpoints:')
println(' http://127.0.0.1:3000/ - Test page')
println(' http://127.0.0.1:3000/api/status - Server status')
println('')
println('Test with curl:')
println(' curl http://127.0.0.1:3000/stream')
println(' curl http://127.0.0.1:3000/stream-text')
println(' curl http://127.0.0.1:3000/sse')
println('')
println('============================================================')
app.listen(':3000')
}
// get_timestamp - Get current timestamp string
fn get_timestamp() string {
return '${os.execute("echo %TIME%").output.trim_space()}'
}
// get_sse_test_page - Returns HTML test page for SSE streaming
fn get_sse_test_page() string {
sq := "'"
return '<!DOCTYPE html>
<html>
<head>
<title>v-hono SSE Streaming Demo</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 1000px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
h1 { color: #667eea; margin-bottom: 10px; }
h2 { color: #333; font-size: 1.2em; margin: 20px 0 10px; }
.subtitle { color: #666; margin-bottom: 30px; }
.card { background: white; padding: 20px; margin: 15px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.endpoint { display: flex; align-items: center; gap: 10px; margin: 10px 0; flex-wrap: wrap; }
.endpoint-url { font-family: monospace; background: #f0f0f0; padding: 8px 12px; border-radius: 4px; flex: 1; min-width: 200px; }
button { padding: 8px 16px; border-radius: 4px; border: none; cursor: pointer; font-size: 14px; transition: background 0.2s; }
.btn-connect { background: #667eea; color: white; }
.btn-connect:hover { background: #5a6fd6; }
.btn-disconnect { background: #e74c3c; color: white; }
.btn-disconnect:hover { background: #c0392b; }
.btn-clear { background: #95a5a6; color: white; }
.btn-clear:hover { background: #7f8c8d; }
.status { padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold; }
.status-connected { background: #27ae60; color: white; }
.status-disconnected { background: #e74c3c; color: white; }
.status-connecting { background: #f39c12; color: white; }
.output { height: 200px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 4px; font-family: monospace; font-size: 13px; line-height: 1.5; }
.output:empty::before { content: "No messages yet..."; color: #666; }
.msg { margin: 2px 0; padding: 2px 0; }
.msg-event { color: #569cd6; }
.msg-data { color: #ce9178; }
.msg-id { color: #b5cea8; }
.msg-retry { color: #dcdcaa; }
.msg-system { color: #6a9955; font-style: italic; }
.msg-error { color: #f44747; }
.msg-text { color: #d4d4d4; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 20px; }
@media (max-width: 600px) { .grid { grid-template-columns: 1fr; } .endpoint { flex-direction: column; align-items: stretch; } }
</style>
</head>
<body>
<h1>v-hono SSE Streaming Demo</h1>
<p class="subtitle">Test Server-Sent Events and streaming responses</p>
<div class="grid">
<div class="card">
<h2>SSE Event Stream</h2>
<div class="endpoint">
<span class="endpoint-url">/sse</span>
<button class="btn-connect" onclick="connectSSE(${sq}/sse${sq}, ${sq}sse-output${sq}, ${sq}sse-status${sq})">Connect</button>
<button class="btn-disconnect" onclick="disconnectSSE(${sq}sse${sq})">Disconnect</button>
<span id="sse-status" class="status status-disconnected">Disconnected</span>
</div>
<div id="sse-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}sse-output${sq})">Clear</button>
</div>
<div class="card">
<h2>Multi-line SSE</h2>
<div class="endpoint">
<span class="endpoint-url">/sse-multiline</span>
<button class="btn-connect" onclick="connectSSE(${sq}/sse-multiline${sq}, ${sq}multiline-output${sq}, ${sq}multiline-status${sq})">Connect</button>
<button class="btn-disconnect" onclick="disconnectSSE(${sq}multiline${sq})">Disconnect</button>
<span id="multiline-status" class="status status-disconnected">Disconnected</span>
</div>
<div id="multiline-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}multiline-output${sq})">Clear</button>
</div>
<div class="card">
<h2>Real-time Counter</h2>
<div class="endpoint">
<span class="endpoint-url">/sse-counter</span>
<button class="btn-connect" onclick="connectSSE(${sq}/sse-counter${sq}, ${sq}counter-output${sq}, ${sq}counter-status${sq})">Connect</button>
<button class="btn-disconnect" onclick="disconnectSSE(${sq}counter${sq})">Disconnect</button>
<span id="counter-status" class="status status-disconnected">Disconnected</span>
</div>
<div id="counter-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}counter-output${sq})">Clear</button>
</div>
<div class="card">
<h2>Error Handling</h2>
<div class="endpoint">
<span class="endpoint-url">/sse-error</span>
<button class="btn-connect" onclick="connectSSE(${sq}/sse-error${sq}, ${sq}error-output${sq}, ${sq}error-status${sq})">Connect</button>
<button class="btn-disconnect" onclick="disconnectSSE(${sq}error${sq})">Disconnect</button>
<span id="error-status" class="status status-disconnected">Disconnected</span>
</div>
<div id="error-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}error-output${sq})">Clear</button>
</div>
<div class="card">
<h2>Text Stream</h2>
<div class="endpoint">
<span class="endpoint-url">/stream-text</span>
<button class="btn-connect" onclick="fetchStream(${sq}/stream-text${sq}, ${sq}text-output${sq}, ${sq}text-status${sq})">Fetch</button>
<span id="text-status" class="status status-disconnected">Ready</span>
</div>
<div id="text-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}text-output${sq})">Clear</button>
</div>
<div class="card">
<h2>Binary Stream</h2>
<div class="endpoint">
<span class="endpoint-url">/stream</span>
<button class="btn-connect" onclick="fetchStream(${sq}/stream${sq}, ${sq}binary-output${sq}, ${sq}binary-status${sq})">Fetch</button>
<span id="binary-status" class="status status-disconnected">Ready</span>
</div>
<div id="binary-output" class="output"></div>
<button class="btn-clear" onclick="clearOutput(${sq}binary-output${sq})">Clear</button>
</div>
</div>
<script>
var connections = {};
function log(outputId, msg, type) {
var output = document.getElementById(outputId);
var div = document.createElement(${sq}div${sq});
div.className = ${sq}msg msg-${sq} + (type || ${sq}text${sq});
div.textContent = msg;
output.appendChild(div);
output.scrollTop = output.scrollHeight;
}
function setStatus(statusId, status) {
var el = document.getElementById(statusId);
el.textContent = status;
el.className = ${sq}status status-${sq} + status.toLowerCase();
}
function clearOutput(outputId) {
document.getElementById(outputId).innerHTML = ${sq}${sq};
}
function connectSSE(url, outputId, statusId) {
var key = outputId.replace(${sq}-output${sq}, ${sq}${sq});
if (connections[key]) {
connections[key].close();
}
setStatus(statusId, ${sq}Connecting${sq});
log(outputId, ${sq}Connecting to ${sq} + url + ${sq}...${sq}, ${sq}system${sq});
var es = new EventSource(url);
connections[key] = es;
es.onopen = function() {
setStatus(statusId, ${sq}Connected${sq});
log(outputId, ${sq}Connected!${sq}, ${sq}system${sq});
};
es.onmessage = function(e) {
log(outputId, ${sq}data: ${sq} + e.data, ${sq}data${sq});
};
es.onerror = function() {
setStatus(statusId, ${sq}Disconnected${sq});
log(outputId, ${sq}Connection closed${sq}, ${sq}system${sq});
connections[key] = null;
};
// Listen for custom event types
[${sq}connect${sq}, ${sq}update${sq}, ${sq}complete${sq}, ${sq}multiline${sq}, ${sq}json${sq}, ${sq}message${sq}, ${sq}counter${sq}, ${sq}start${sq}, ${sq}error${sq}].forEach(function(type) {
es.addEventListener(type, function(e) {
log(outputId, ${sq}event: ${sq} + type, ${sq}event${sq});
log(outputId, ${sq}data: ${sq} + e.data, ${sq}data${sq});
if (e.lastEventId) log(outputId, ${sq}id: ${sq} + e.lastEventId, ${sq}id${sq});
});
});
}
function disconnectSSE(key) {
if (connections[key]) {
connections[key].close();
connections[key] = null;
setStatus(key + ${sq}-status${sq}, ${sq}Disconnected${sq});
log(key + ${sq}-output${sq}, ${sq}Disconnected by user${sq}, ${sq}system${sq});
}
}
async function fetchStream(url, outputId, statusId) {
setStatus(statusId, ${sq}Connecting${sq});
log(outputId, ${sq}Fetching ${sq} + url + ${sq}...${sq}, ${sq}system${sq});
try {
var response = await fetch(url);
var reader = response.body.getReader();
var decoder = new TextDecoder();
setStatus(statusId, ${sq}Connected${sq});
while (true) {
var result = await reader.read();
if (result.done) break;
var text = decoder.decode(result.value, {stream: true});
log(outputId, text, ${sq}text${sq});
}
setStatus(statusId, ${sq}Disconnected${sq});
log(outputId, ${sq}Stream complete${sq}, ${sq}system${sq});
} catch (err) {
setStatus(statusId, ${sq}Disconnected${sq});
log(outputId, ${sq}Error: ${sq} + err.message, ${sq}error${sq});
}
}
</script>
</body>
</html>'
}
+119
View File
@@ -521,6 +521,13 @@ fn parse_http_method_fast(method string) http.Method {
// 发送 picoev 响应 - 优化版
fn send_picoev_response(mut res picohttpparser.Response, ctx Context, response http.Response, keepalive bool, config PicoevConfig) {
// Check if this is a streaming response
if is_streaming_response(ctx) {
// Handle streaming response
handle_picoev_streaming_response(mut res, ctx, config)
return
}
status_code := if ctx.status_code != 0 { ctx.status_code } else { response.status_code }
if status_code == 200 {
@@ -570,6 +577,118 @@ fn send_picoev_response(mut res picohttpparser.Response, ctx Context, response h
res.end()
}
// ============================================================================
// Streaming Response Handling for Picoev
// ============================================================================
// handle_picoev_streaming_response - Handle a streaming response using PicoevStreamWriter
// This function:
// 1. Sends the HTTP headers for streaming directly to socket fd
// 2. Creates a PicoevStreamWriter with the socket fd
// 3. Executes the streaming callback
// 4. Handles errors and cleanup
fn handle_picoev_streaming_response(mut res picohttpparser.Response, ctx Context, config PicoevConfig) {
// Get stream configuration
stream_config := get_stream_config(ctx) or {
// Fallback to error response if stream config not found
res.raw('HTTP/1.1 500 Internal Server Error\r\n')
res.header('Content-Type', 'text/plain')
res.body('Stream configuration not found')
res.end()
return
}
// Determine status code
status_code := if ctx.status_code != 0 { ctx.status_code } else { 200 }
// Build HTTP headers string for direct socket write
mut headers_str := 'HTTP/1.1 ${status_code} ${get_status_text(status_code)}\r\n'
// Add headers from context
mut has_transfer_encoding := false
mut has_connection := false
for key, value in ctx.headers {
key_len := key.len
// Skip Content-Length for streaming (we use chunked encoding)
if key_len == 14 && eq_ignore_case(key, 'content-length') {
continue
}
headers_str += '${key}: ${value}\r\n'
if key_len == 17 && eq_ignore_case(key, 'transfer-encoding') {
has_transfer_encoding = true
} else if key_len == 10 && eq_ignore_case(key, 'connection') {
has_connection = true
}
}
// Ensure Transfer-Encoding: chunked is set
if !has_transfer_encoding {
headers_str += 'Transfer-Encoding: chunked\r\n'
}
// Ensure Connection header is set for streaming
if !has_connection {
headers_str += 'Connection: keep-alive\r\n'
headers_str += 'Keep-Alive: timeout=${config.timeout_secs}\r\n'
}
// End headers section
headers_str += '\r\n'
// Write headers directly to socket fd for immediate delivery
fd := res.fd
write_to_socket_fd(fd, headers_str.bytes()) or {
eprintln('[Picoev Stream Error] Failed to write headers: ${err.msg()}')
cleanup_stream_config(ctx)
return
}
// Create PicoevStreamWriter for streaming (uses fd directly)
mut writer := PicoevStreamWriter.new(res)
// Create StreamContext with the writer
mut stream_ctx := StreamContext.new(writer)
// Execute the streaming callback
stream_config.callback(mut stream_ctx) or {
// Handle error
if error_handler := stream_config.error_handler {
error_handler(err, mut stream_ctx)
} else {
// Default: log error to console
eprintln('[Picoev Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
stream_ctx.close()
// Cleanup the stored configuration
cleanup_stream_config(ctx)
}
// write_to_socket_fd - Helper function to write bytes directly to socket fd
// Used for sending HTTP headers before streaming begins
fn write_to_socket_fd(fd int, data []u8) ! {
if data.len == 0 {
return
}
mut total_written := 0
for total_written < data.len {
remaining := data.len - total_written
ptr := unsafe { &u8(data.data) + total_written }
written := C.send(fd, ptr, remaining, 0)
if written < 0 {
return error('Failed to write to socket')
}
if written == 0 {
return error('Connection closed by peer')
}
total_written += written
}
}
// 获取状态码文本
fn get_status_text(code int) string {
return match code {
+982
View File
@@ -0,0 +1,982 @@
// streaming.v - SSE Streaming Helper for v-hono
// Provides Server-Sent Events (SSE) and streaming response support
// Reference: Hono.js Streaming Helper API
module hono
import net.http
import time
import picohttpparser
import usockets
// C function declaration for direct socket writes
fn C.send(sockfd int, buf voidptr, len int, flags int) int
// ============================================================================
// SSE Event Structure
// ============================================================================
// SSEEvent - Server-Sent Events event structure
// Used with stream_sse() to send formatted SSE events to clients
pub struct SSEEvent {
pub:
data string // Event data (required) - supports multi-line
event string // Event type name (optional)
id string // Event ID for client reconnection (optional)
retry int // Reconnection time in milliseconds (optional, 0 = not set)
}
// ============================================================================
// StreamWriter Interface
// ============================================================================
// StreamWriter - Abstract interface for stream writing
// Allows different server backends (picoev, uSockets) to implement their own writers
pub interface StreamWriter {
// Check if the connection is still active (immutable)
is_connected() bool
mut:
// Write raw bytes to the stream
write(data []u8) !
// Write string to the stream
write_string(data string) !
// Flush the buffer to ensure data is sent
flush() !
// Close the stream connection
close() !
}
// ============================================================================
// StreamContext Structure
// ============================================================================
// StreamContext - Context provided to streaming callbacks
// Provides methods for writing data, SSE events, and managing the stream
@[heap]
pub struct StreamContext {
mut:
writer &StreamWriter
is_closed bool
abort_fn ?fn ()
}
// StreamContext.new - Create a new StreamContext with the given writer
pub fn StreamContext.new(writer &StreamWriter) StreamContext {
return StreamContext{
writer: unsafe { writer }
is_closed: false
abort_fn: none
}
}
// write - Write raw bytes to the stream
pub fn (mut ctx StreamContext) write(data []u8) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
ctx.writer.write(data)!
}
// write_string - Write a string to the stream
pub fn (mut ctx StreamContext) write_string(data string) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
ctx.writer.write_string(data)!
}
// writeln - Write a string followed by a newline character
pub fn (mut ctx StreamContext) writeln(data string) ! {
ctx.write_string(data)!
ctx.write_string('\n')!
}
// write_sse - Write an SSE event to the stream
// Formats the event according to the SSE specification:
// - event: {type}\n (if event field is set)
// - data: {line}\n (for each line of data)
// - id: {id}\n (if id field is set)
// - retry: {ms}\n (if retry > 0)
// - \n (event separator)
pub fn (mut ctx StreamContext) write_sse(event SSEEvent) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
// Write event type if specified
if event.event.len > 0 {
ctx.writer.write_string('event: ${event.event}\n')!
}
// Write data field - handle multi-line data
lines := event.data.split('\n')
for line in lines {
ctx.writer.write_string('data: ${line}\n')!
}
// Write id if specified
if event.id.len > 0 {
ctx.writer.write_string('id: ${event.id}\n')!
}
// Write retry if specified (must be > 0)
if event.retry > 0 {
ctx.writer.write_string('retry: ${event.retry}\n')!
}
// Write event separator (empty line)
ctx.writer.write_string('\n')!
// Flush to ensure event is sent immediately
ctx.writer.flush()!
}
// sleep - Pause execution for the specified milliseconds
pub fn (ctx StreamContext) sleep(ms int) {
time.sleep(ms * time.millisecond)
}
// pipe - Pipe data from a byte array to the stream
pub fn (mut ctx StreamContext) pipe(data []u8) ! {
ctx.write(data)!
}
// on_abort - Register a callback to be called when the client aborts the connection
pub fn (mut ctx StreamContext) on_abort(callback fn ()) {
ctx.abort_fn = callback
}
// close - Close the stream
pub fn (mut ctx StreamContext) close() {
if !ctx.is_closed {
ctx.is_closed = true
ctx.writer.close() or {}
}
}
// is_open - Check if the stream is still open
pub fn (ctx StreamContext) is_open() bool {
return !ctx.is_closed && ctx.writer.is_connected()
}
// trigger_abort - Internal method to trigger the abort callback
// Called by server backends when client disconnects
pub fn (mut ctx StreamContext) trigger_abort() {
if abort_callback := ctx.abort_fn {
abort_callback()
}
ctx.is_closed = true
}
// ============================================================================
// Picoev StreamWriter Implementation
// ============================================================================
// PicoevStreamWriter - StreamWriter implementation for picoev server
// Supports chunked transfer encoding for streaming responses
// Uses direct socket fd writes for immediate data delivery (true streaming)
@[heap]
pub struct PicoevStreamWriter {
mut:
fd int // Socket file descriptor for direct writes
connected bool
}
// PicoevStreamWriter.new - Create a new PicoevStreamWriter with the given response
// Extracts the fd from picohttpparser.Response for direct socket writes
pub fn PicoevStreamWriter.new(res &picohttpparser.Response) &PicoevStreamWriter {
return &PicoevStreamWriter{
fd: res.fd
connected: true
}
}
// write - Write raw bytes to the stream using chunked transfer encoding
// Format: {hex_size}\r\n{data}\r\n
// Writes directly to socket fd for immediate delivery
pub fn (mut w PicoevStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := format_chunk_size(data.len)
// Write directly to socket fd
w.write_to_fd(chunk_header.bytes())!
w.write_to_fd(data)!
w.write_to_fd('\r\n'.bytes())!
}
// write_string - Write a string to the stream using chunked transfer encoding
pub fn (mut w PicoevStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := format_chunk_size(data.len)
// Write directly to socket fd
w.write_to_fd(chunk_header.bytes())!
w.write_to_fd(data.bytes())!
w.write_to_fd('\r\n'.bytes())!
}
// write_to_fd - Write bytes directly to the socket file descriptor
// This ensures immediate delivery without buffering
@[inline]
fn (mut w PicoevStreamWriter) write_to_fd(data []u8) ! {
if data.len == 0 {
return
}
mut total_written := 0
for total_written < data.len {
remaining := data.len - total_written
ptr := unsafe { &u8(data.data) + total_written }
written := C.send(w.fd, ptr, remaining, 0)
if written < 0 {
w.connected = false
return error('Failed to write to socket')
}
if written == 0 {
w.connected = false
return error('Connection closed by peer')
}
total_written += written
}
}
// flush - Flush the buffer (direct fd writes are already unbuffered)
pub fn (mut w PicoevStreamWriter) flush() ! {
// Direct fd writes are unbuffered, no explicit flush needed
// But we can use TCP_NODELAY to disable Nagle's algorithm if needed
}
// close - Close the stream by sending the final chunk
// Final chunk format: 0\r\n\r\n
pub fn (mut w PicoevStreamWriter) close() ! {
if w.connected {
// Write the final chunk (zero-length chunk) to signal end of stream
w.write_to_fd('0\r\n\r\n'.bytes()) or {}
w.connected = false
// Note: Don't close the fd here, picoev manages the connection lifecycle
}
}
// is_connected - Check if the connection is still active
pub fn (w PicoevStreamWriter) is_connected() bool {
return w.connected
}
// ============================================================================
// uSockets StreamWriter Implementation
// ============================================================================
// UsocketsStreamWriter - StreamWriter implementation for uSockets server
// Supports chunked transfer encoding for streaming responses
@[heap]
pub struct UsocketsStreamWriter {
mut:
socket usockets.Socket
connected bool
}
// UsocketsStreamWriter.new - Create a new UsocketsStreamWriter with the given socket
pub fn UsocketsStreamWriter.new(socket usockets.Socket) &UsocketsStreamWriter {
return &UsocketsStreamWriter{
socket: socket
connected: true
}
}
// write - Write raw bytes to the stream using chunked transfer encoding
// Format: {hex_size}\r\n{data}\r\n
pub fn (mut w UsocketsStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := format_chunk_size(data.len)
w.socket.write_bytes(chunk_header)
w.socket.write_bytes(data.bytestr())
w.socket.write_bytes('\r\n')
}
// write_string - Write a string to the stream using chunked transfer encoding
pub fn (mut w UsocketsStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := format_chunk_size(data.len)
w.socket.write_bytes(chunk_header)
w.socket.write_bytes(data)
w.socket.write_bytes('\r\n')
}
// flush - Flush the buffer (uSockets handles this automatically)
pub fn (mut w UsocketsStreamWriter) flush() ! {
// uSockets automatically flushes data
// No explicit flush needed
}
// close - Close the stream by sending the final chunk
// Final chunk format: 0\r\n\r\n
pub fn (mut w UsocketsStreamWriter) close() ! {
if w.connected {
// Write the final chunk (zero-length chunk) to signal end of stream
w.socket.write_bytes('0\r\n\r\n')
w.connected = false
}
}
// is_connected - Check if the connection is still active
pub fn (w UsocketsStreamWriter) is_connected() bool {
return w.connected
}
// mark_disconnected - Mark the connection as disconnected
// Called by server backends when client disconnects
pub fn (mut w UsocketsStreamWriter) mark_disconnected() {
w.connected = false
}
// ============================================================================
// Helper Functions for Chunked Encoding
// ============================================================================
// format_chunk_size - Format the chunk size as hex followed by CRLF
// This is used for HTTP chunked transfer encoding
@[inline]
fn format_chunk_size(size int) string {
return '${size:x}\r\n'
}
// format_chunk - Format a complete chunk with size, data, and trailing CRLF
// Returns the full chunk in chunked transfer encoding format
pub fn format_chunk(data []u8) string {
if data.len == 0 {
return ''
}
return '${data.len:x}\r\n${data.bytestr()}\r\n'
}
// format_chunk_string - Format a string as a complete chunk
pub fn format_chunk_string(data string) string {
if data.len == 0 {
return ''
}
return '${data.len:x}\r\n${data}\r\n'
}
// format_final_chunk - Return the final chunk marker for chunked encoding
pub fn format_final_chunk() string {
return '0\r\n\r\n'
}
// ============================================================================
// Streaming Helper Functions
// ============================================================================
// StreamCallback - Callback function type for streaming
// The callback receives a mutable StreamContext and can write data to the stream
pub type StreamCallback = fn (mut StreamContext) !
// StreamErrorHandler - Error handler function type for streaming
// Called when an error occurs during streaming
pub type StreamErrorHandler = fn (err IError, mut ctx StreamContext)
// StreamResponse - Response type for streaming operations
// Contains the headers that should be sent before streaming begins
pub struct StreamResponse {
pub:
status_code int = 200
headers map[string]string
is_stream bool = true
}
// stream - Basic binary streaming function
// Sets Transfer-Encoding: chunked and executes the callback with a StreamContext
//
// Parameters:
// writer: The StreamWriter implementation (PicoevStreamWriter or UsocketsStreamWriter)
// callback: The callback function that writes data to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// StreamResponse with the appropriate headers
//
// Example:
// stream(writer, fn (mut stream) ! {
// stream.write('Hello'.bytes())!
// stream.sleep(100)
// stream.write('World'.bytes())!
// })
pub fn stream(mut writer StreamWriter, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
// Create StreamContext with the writer
mut ctx := StreamContext.new(writer)
// Execute the callback and handle errors
callback(mut ctx) or {
// Handle error
if error_handler.len > 0 {
// Call custom error handler
error_handler[0](err, mut ctx)
} else {
// Default: log error to console
eprintln('[SSE Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
ctx.close()
// Return response headers for streaming
return StreamResponse{
status_code: 200
headers: {
'Transfer-Encoding': 'chunked'
'Connection': 'keep-alive'
}
is_stream: true
}
}
// stream_with_headers - Stream with custom initial headers
// Allows setting additional headers before streaming begins
pub fn stream_with_headers(mut writer StreamWriter, headers map[string]string, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
// Create StreamContext with the writer
mut ctx := StreamContext.new(writer)
// Execute the callback and handle errors
callback(mut ctx) or {
// Handle error
if error_handler.len > 0 {
// Call custom error handler
error_handler[0](err, mut ctx)
} else {
// Default: log error to console
eprintln('[SSE Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
ctx.close()
// Merge headers with required streaming headers
mut response_headers := headers.clone()
response_headers['Transfer-Encoding'] = 'chunked'
if 'Connection' !in response_headers {
response_headers['Connection'] = 'keep-alive'
}
return StreamResponse{
status_code: 200
headers: response_headers
is_stream: true
}
}
// get_stream_headers - Get the headers required for basic streaming
// Returns a map of headers that should be set for chunked transfer encoding
pub fn get_stream_headers() map[string]string {
return {
'Transfer-Encoding': 'chunked'
'Connection': 'keep-alive'
}
}
// write_stream_headers - Write HTTP headers for streaming response
// This is a helper function to write the initial HTTP response line and headers
// before starting chunked data transfer
//
// Parameters:
// writer: The StreamWriter to write headers to
// status_code: HTTP status code (default 200)
// headers: Map of header name to value
pub fn write_stream_headers(mut writer StreamWriter, status_code int, headers map[string]string) ! {
// Build HTTP response line
status_text := get_status_text(status_code)
mut response := 'HTTP/1.1 ${status_code} ${status_text}\r\n'
// Add headers
for key, value in headers {
response += '${key}: ${value}\r\n'
}
// Add required streaming headers if not present
if 'Transfer-Encoding' !in headers {
response += 'Transfer-Encoding: chunked\r\n'
}
if 'Connection' !in headers {
response += 'Connection: keep-alive\r\n'
}
// End headers section
response += '\r\n'
// Write headers directly (not chunked)
// Note: Headers are written raw, not using chunked encoding
// The chunked encoding starts after the headers
writer.write_string(response)!
}
// stream_text - Text streaming function
// Sets Content-Type: text/plain; charset=utf-8, Transfer-Encoding: chunked,
// and X-Content-Type-Options: nosniff headers, then executes the callback
// with a StreamContext for text streaming.
//
// Parameters:
// writer: The StreamWriter implementation (PicoevStreamWriter or UsocketsStreamWriter)
// callback: The callback function that writes text data to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// StreamResponse with the appropriate headers for text streaming
//
// Example:
// stream_text(writer, fn (mut stream) ! {
// stream.write_string('Hello ')!
// stream.sleep(100)
// stream.writeln('World')!
// })
pub fn stream_text(mut writer StreamWriter, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
// Create StreamContext with the writer
mut ctx := StreamContext.new(writer)
// Execute the callback and handle errors
callback(mut ctx) or {
// Handle error
if error_handler.len > 0 {
// Call custom error handler
error_handler[0](err, mut ctx)
} else {
// Default: log error to console
eprintln('[SSE StreamText Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
ctx.close()
// Return response headers for text streaming
return StreamResponse{
status_code: 200
headers: {
'Content-Type': 'text/plain; charset=utf-8'
'Transfer-Encoding': 'chunked'
'X-Content-Type-Options': 'nosniff'
'Connection': 'keep-alive'
}
is_stream: true
}
}
// get_stream_text_headers - Get the headers required for text streaming
// Returns a map of headers that should be set for text streaming with chunked transfer encoding
pub fn get_stream_text_headers() map[string]string {
return {
'Content-Type': 'text/plain; charset=utf-8'
'Transfer-Encoding': 'chunked'
'X-Content-Type-Options': 'nosniff'
'Connection': 'keep-alive'
}
}
// stream_sse - Server-Sent Events streaming function
// Sets Content-Type: text/event-stream, Cache-Control: no-cache,
// and Connection: keep-alive headers, then executes the callback
// with a StreamContext for SSE streaming.
//
// Parameters:
// writer: The StreamWriter implementation (PicoevStreamWriter or UsocketsStreamWriter)
// callback: The callback function that writes SSE events to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// StreamResponse with the appropriate headers for SSE streaming
//
// Example:
// stream_sse(writer, fn (mut stream) ! {
// stream.write_sse(SSEEvent{
// data: 'Hello World'
// event: 'message'
// id: '1'
// })!
// stream.sleep(1000)
// stream.write_sse(SSEEvent{
// data: 'Another message'
// event: 'update'
// id: '2'
// })!
// })
pub fn stream_sse(mut writer StreamWriter, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
// Create StreamContext with the writer
mut ctx := StreamContext.new(writer)
// Execute the callback and handle errors
callback(mut ctx) or {
// Handle error
if error_handler.len > 0 {
// Call custom error handler
error_handler[0](err, mut ctx)
} else {
// Default: log error to console
eprintln('[SSE StreamSSE Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
ctx.close()
// Return response headers for SSE streaming
// Requirements 3.1, 3.2, 3.3:
// - Content-Type: text/event-stream
// - Cache-Control: no-cache
// - Connection: keep-alive
return StreamResponse{
status_code: 200
headers: {
'Content-Type': 'text/event-stream'
'Cache-Control': 'no-cache'
'Connection': 'keep-alive'
'Transfer-Encoding': 'chunked'
}
is_stream: true
}
}
// get_stream_sse_headers - Get the headers required for SSE streaming
// Returns a map of headers that should be set for Server-Sent Events streaming
pub fn get_stream_sse_headers() map[string]string {
return {
'Content-Type': 'text/event-stream'
'Cache-Control': 'no-cache'
'Connection': 'keep-alive'
'Transfer-Encoding': 'chunked'
}
}
// ============================================================================
// Context-Based Streaming Functions
// ============================================================================
// These functions are designed to be used in route handlers and integrate
// with the picoev and uSockets server backends.
// StreamType - Type of streaming response
pub enum StreamType {
basic // Basic binary streaming
text // Text streaming with text/plain content type
sse // Server-Sent Events streaming
}
// StreamConfig - Configuration stored in Context for streaming responses
// This is used internally by the server backends to detect and handle streaming
pub struct StreamConfig {
pub:
stream_type StreamType
callback StreamCallback = unsafe { nil }
error_handler ?StreamErrorHandler
}
// Global storage for stream callbacks (workaround for V's limitations with storing functions in maps)
// Key is a unique identifier stored in Context.store['_stream_id']
// Using shared map for thread-safe access
struct StreamCallbackStorage {
mut:
callbacks shared map[string]StreamConfig
}
// Module-level storage instance
const stream_storage = &StreamCallbackStorage{}
// store_stream_config - Store a stream configuration
fn store_stream_config(id string, config StreamConfig) {
lock stream_storage.callbacks {
stream_storage.callbacks[id] = config
}
}
// retrieve_stream_config - Retrieve a stream configuration by ID
fn retrieve_stream_config(id string) ?StreamConfig {
rlock stream_storage.callbacks {
if id in stream_storage.callbacks {
return stream_storage.callbacks[id]
}
}
return none
}
// remove_stream_config - Remove a stream configuration by ID
fn remove_stream_config(id string) {
lock stream_storage.callbacks {
stream_storage.callbacks.delete(id)
}
}
// generate_stream_id - Generate a unique stream ID
fn generate_stream_id() string {
return '${time.now().unix_milli()}_${rand_int()}'
}
// rand_int - Simple random integer generator
fn rand_int() int {
return int(time.now().unix_nano() % 1000000)
}
// c_stream - Basic binary streaming function for use in route handlers
// Sets Transfer-Encoding: chunked and stores the callback for execution by the server backend
//
// Parameters:
// c: The request Context
// callback: The callback function that writes data to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// http.Response with streaming marker headers
//
// Example:
// app.get('/stream', fn (mut c hono.Context) http.Response {
// return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
// stream.write('Hello'.bytes())!
// stream.sleep(100)
// stream.write('World'.bytes())!
// })
// })
pub fn c_stream(mut c Context, callback StreamCallback, error_handler ...StreamErrorHandler) http.Response {
// Generate unique stream ID
stream_id := generate_stream_id()
// Store stream configuration
config := StreamConfig{
stream_type: .basic
callback: callback
error_handler: if error_handler.len > 0 { error_handler[0] } else { none }
}
store_stream_config(stream_id, config)
// Mark context as streaming
c.store['_stream'] = 'true'
c.store['_stream_id'] = stream_id
c.store['_stream_type'] = 'basic'
// Set streaming headers
c.headers['Transfer-Encoding'] = 'chunked'
c.headers['Connection'] = 'keep-alive'
// Return a marker response (body will be ignored, streaming will be handled by server)
mut headers := http.new_header()
headers.add_custom('Transfer-Encoding', 'chunked') or {}
headers.add_custom('Connection', 'keep-alive') or {}
return http.Response{
status_code: 200
header: headers
body: ''
}
}
// c_stream_text - Text streaming function for use in route handlers
// Sets Content-Type: text/plain; charset=utf-8, Transfer-Encoding: chunked,
// and X-Content-Type-Options: nosniff headers
//
// Parameters:
// c: The request Context
// callback: The callback function that writes text data to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// http.Response with streaming marker headers
//
// Example:
// app.get('/stream-text', fn (mut c hono.Context) http.Response {
// return hono.c_stream_text(mut c, fn (mut stream hono.StreamContext) ! {
// stream.write_string('Hello ')!
// stream.sleep(100)
// stream.writeln('World')!
// })
// })
pub fn c_stream_text(mut c Context, callback StreamCallback, error_handler ...StreamErrorHandler) http.Response {
// Generate unique stream ID
stream_id := generate_stream_id()
// Store stream configuration
config := StreamConfig{
stream_type: .text
callback: callback
error_handler: if error_handler.len > 0 { error_handler[0] } else { none }
}
store_stream_config(stream_id, config)
// Mark context as streaming
c.store['_stream'] = 'true'
c.store['_stream_id'] = stream_id
c.store['_stream_type'] = 'text'
// Set streaming headers
c.headers['Content-Type'] = 'text/plain; charset=utf-8'
c.headers['Transfer-Encoding'] = 'chunked'
c.headers['X-Content-Type-Options'] = 'nosniff'
c.headers['Connection'] = 'keep-alive'
// Return a marker response
mut headers := http.new_header()
headers.add_custom('Content-Type', 'text/plain; charset=utf-8') or {}
headers.add_custom('Transfer-Encoding', 'chunked') or {}
headers.add_custom('X-Content-Type-Options', 'nosniff') or {}
headers.add_custom('Connection', 'keep-alive') or {}
return http.Response{
status_code: 200
header: headers
body: ''
}
}
// c_stream_sse - Server-Sent Events streaming function for use in route handlers
// Sets Content-Type: text/event-stream, Cache-Control: no-cache,
// and Connection: keep-alive headers
//
// Parameters:
// c: The request Context
// callback: The callback function that writes SSE events to the stream
// error_handler: Optional error handler called if an error occurs
//
// Returns:
// http.Response with streaming marker headers
//
// Example:
// app.get('/sse', fn (mut c hono.Context) http.Response {
// return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
// stream.write_sse(hono.SSEEvent{
// data: 'Hello World'
// event: 'message'
// id: '1'
// })!
// stream.sleep(1000)
// stream.write_sse(hono.SSEEvent{
// data: 'Another message'
// event: 'update'
// id: '2'
// })!
// })
// })
pub fn c_stream_sse(mut c Context, callback StreamCallback, error_handler ...StreamErrorHandler) http.Response {
// Generate unique stream ID
stream_id := generate_stream_id()
// Store stream configuration
config := StreamConfig{
stream_type: .sse
callback: callback
error_handler: if error_handler.len > 0 { error_handler[0] } else { none }
}
store_stream_config(stream_id, config)
// Mark context as streaming
c.store['_stream'] = 'true'
c.store['_stream_id'] = stream_id
c.store['_stream_type'] = 'sse'
// Set SSE headers (Requirements 3.1, 3.2, 3.3)
c.headers['Content-Type'] = 'text/event-stream'
c.headers['Cache-Control'] = 'no-cache'
c.headers['Connection'] = 'keep-alive'
c.headers['Transfer-Encoding'] = 'chunked'
// Return a marker response
mut headers := http.new_header()
headers.add_custom('Content-Type', 'text/event-stream') or {}
headers.add_custom('Cache-Control', 'no-cache') or {}
headers.add_custom('Connection', 'keep-alive') or {}
headers.add_custom('Transfer-Encoding', 'chunked') or {}
return http.Response{
status_code: 200
header: headers
body: ''
}
}
// is_streaming_response - Check if a Context represents a streaming response
// Used by server backends to detect streaming responses
pub fn is_streaming_response(ctx Context) bool {
return '_stream' in ctx.store && ctx.store['_stream'] == 'true'
}
// get_stream_config - Get the stream configuration from Context
// Returns the StreamConfig if this is a streaming response, none otherwise
pub fn get_stream_config(ctx Context) ?StreamConfig {
if !is_streaming_response(ctx) {
return none
}
stream_id := ctx.store['_stream_id'] or { return none }
return retrieve_stream_config(stream_id)
}
// cleanup_stream_config - Remove stream configuration after use
// Should be called by server backends after streaming is complete
pub fn cleanup_stream_config(ctx Context) {
if stream_id := ctx.store['_stream_id'] {
remove_stream_config(stream_id)
}
}
// execute_stream - Execute a streaming response with the given writer
// This is the main entry point for server backends to handle streaming
//
// Parameters:
// ctx: The request Context with streaming configuration
// writer: The StreamWriter implementation for the server backend
//
// Returns:
// true if streaming was executed, false if not a streaming response
pub fn execute_stream(ctx Context, mut writer StreamWriter) bool {
config := get_stream_config(ctx) or { return false }
// Create StreamContext with the writer
mut stream_ctx := StreamContext.new(writer)
// Execute the callback and handle errors
config.callback(mut stream_ctx) or {
// Handle error
if error_handler := config.error_handler {
error_handler(err, mut stream_ctx)
} else {
// Default: log error to console
eprintln('[SSE Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
stream_ctx.close()
// Cleanup the stored configuration
cleanup_stream_config(ctx)
return true
}
@@ -0,0 +1,434 @@
// test_picoev_stream_writer_properties.v
// Property-Based Testing for PicoevStreamWriter chunked encoding
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
// Validates: Requirements 6.2
module main
import rand
import time
const test_iterations = 100
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
stats.total_tests++
print('🔬 ${test_name}... ')
if test_func() {
stats.passed_tests++
println('✅')
} else {
stats.failed_tests++
println('❌')
}
}
fn (stats PropertyTestStats) print_summary() {
println('\n=== PicoevStreamWriter 属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// ============================================================================
// Chunked Encoding Functions (copied from streaming.v for testing)
// ============================================================================
// format_chunk_size - Format the chunk size as hex followed by CRLF
@[inline]
fn format_chunk_size(size int) string {
return '${size:x}\r\n'
}
// format_chunk - Format a complete chunk with size, data, and trailing CRLF
fn format_chunk(data []u8) string {
if data.len == 0 {
return ''
}
return '${data.len:x}\r\n${data.bytestr()}\r\n'
}
// format_chunk_string - Format a string as a complete chunk
fn format_chunk_string(data string) string {
if data.len == 0 {
return ''
}
return '${data.len:x}\r\n${data}\r\n'
}
// format_final_chunk - Return the final chunk marker for chunked encoding
fn format_final_chunk() string {
return '0\r\n\r\n'
}
// ============================================================================
// Random Data Generators
// ============================================================================
// Generate random bytes for testing
fn generate_random_bytes(max_len int) []u8 {
len := rand.int_in_range(1, max_len) or { 10 }
mut data := []u8{len: len}
for i in 0 .. len {
data[i] = u8(rand.int_in_range(0, 256) or { 0 })
}
return data
}
// Generate random string for testing (printable ASCII only to avoid encoding issues)
fn generate_random_string(max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'
len := rand.int_in_range(1, max_len) or { 10 }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result
}
// ============================================================================
// Chunk Parsing Helper
// ============================================================================
// Parse a chunked encoded string and extract the data
// Returns the extracted data and whether parsing was successful
fn parse_chunked_data(chunked string) !([]u8, bool) {
// Find the size part (before first \r\n)
mut crlf_pos := -1
for i := 0; i < chunked.len - 1; i++ {
if chunked[i] == `\r` && chunked[i + 1] == `\n` {
crlf_pos = i
break
}
}
if crlf_pos == -1 {
return error('No CRLF found in chunk header')
}
size_hex := chunked[..crlf_pos]
size := parse_hex(size_hex) or {
return error('Invalid hex size: ${size_hex}')
}
// Extract data after the first \r\n
data_start := crlf_pos + 2
data_end := data_start + size
if data_end > chunked.len {
return error('Chunk data incomplete')
}
data := chunked[data_start..data_end].bytes()
// Verify trailing \r\n
if data_end + 2 > chunked.len {
return error('Missing trailing CRLF')
}
trailing := chunked[data_end..data_end + 2]
if trailing != '\r\n' {
return error('Invalid trailing CRLF: got "${trailing}"')
}
return data, true
}
// Parse hex string to int
fn parse_hex(hex_str string) !int {
mut result := 0
for c in hex_str {
result *= 16
if c >= `0` && c <= `9` {
result += int(c - `0`)
} else if c >= `a` && c <= `f` {
result += int(c - `a` + 10)
} else if c >= `A` && c <= `F` {
result += int(c - `A` + 10)
} else {
return error('Invalid hex character: ${c.ascii_str()}')
}
}
return result
}
// ============================================================================
// Property 10: Chunked 编码格式正确性
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
// Validates: Requirements 6.2
//
// *For any* data written to the stream, the output SHALL follow HTTP chunked
// transfer encoding format:
// - Each chunk starts with the hex size followed by \r\n
// - Chunk data followed by \r\n
// - Final chunk is 0\r\n\r\n
// ============================================================================
// Test 10a: format_chunk produces valid chunked encoding for any byte array
fn test_property_10a_format_chunk_bytes() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate random data
data := generate_random_bytes(1000)
// Format as chunk
chunk := format_chunk(data)
// Verify chunk format
// 1. Should start with hex size
mut crlf_pos := -1
for j := 0; j < chunk.len - 1; j++ {
if chunk[j] == `\r` && chunk[j + 1] == `\n` {
crlf_pos = j
break
}
}
if crlf_pos == -1 {
println(' Iteration ${i}: No CRLF found in chunk header')
return false
}
size_hex := chunk[..crlf_pos]
parsed_size := parse_hex(size_hex) or {
println(' Iteration ${i}: Invalid hex size: ${size_hex}')
return false
}
// 2. Size should match data length
if parsed_size != data.len {
println(' Iteration ${i}: Size mismatch - expected ${data.len}, got ${parsed_size}')
return false
}
// 3. Data should be extractable and match original
extracted_data, _ := parse_chunked_data(chunk) or {
println(' Iteration ${i}: Failed to parse chunk: ${err}')
return false
}
if extracted_data != data {
println(' Iteration ${i}: Data mismatch after extraction')
return false
}
}
return true
}
// Test 10b: format_chunk_string produces valid chunked encoding for any string
fn test_property_10b_format_chunk_string() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
// Generate random string
data := generate_random_string(500)
// Format as chunk
chunk := format_chunk_string(data)
// Verify chunk format
mut crlf_pos := -1
for j := 0; j < chunk.len - 1; j++ {
if chunk[j] == `\r` && chunk[j + 1] == `\n` {
crlf_pos = j
break
}
}
if crlf_pos == -1 {
println(' Iteration ${i}: No CRLF found in chunk header')
return false
}
size_hex := chunk[..crlf_pos]
parsed_size := parse_hex(size_hex) or {
println(' Iteration ${i}: Invalid hex size: ${size_hex}')
return false
}
// Size should match string byte length
if parsed_size != data.len {
println(' Iteration ${i}: Size mismatch - expected ${data.len}, got ${parsed_size}')
return false
}
// Extract and verify data
extracted_data, _ := parse_chunked_data(chunk) or {
println(' Iteration ${i}: Failed to parse chunk: ${err}')
return false
}
if extracted_data.bytestr() != data {
println(' Iteration ${i}: String mismatch after extraction')
return false
}
}
return true
}
// Test 10c: format_final_chunk produces correct terminator
fn test_property_10c_final_chunk_format() bool {
final_chunk := format_final_chunk()
// Final chunk must be exactly "0\r\n\r\n"
expected := '0\r\n\r\n'
if final_chunk != expected {
println(' Final chunk mismatch - expected "${expected}", got "${final_chunk}"')
return false
}
// Verify it starts with zero size
if !final_chunk.starts_with('0\r\n') {
println(' Final chunk should start with "0\\r\\n"')
return false
}
// Verify it ends with empty line (double CRLF after size)
if !final_chunk.ends_with('\r\n\r\n') {
println(' Final chunk should end with "\\r\\n\\r\\n"')
return false
}
return true
}
// Test 10d: Empty data produces empty string (no chunk)
fn test_property_10d_empty_data_handling() bool {
// Empty byte array
empty_bytes := []u8{}
chunk_bytes := format_chunk(empty_bytes)
if chunk_bytes != '' {
println(' Empty bytes should produce empty string, got "${chunk_bytes}"')
return false
}
// Empty string
chunk_string := format_chunk_string('')
if chunk_string != '' {
println(' Empty string should produce empty string, got "${chunk_string}"')
return false
}
return true
}
// Test 10e: Chunk size is correctly formatted as lowercase hex
fn test_property_10e_hex_format() bool {
rand.seed([u32(time.now().unix()), u32(98765)])
// Test various sizes including edge cases
test_sizes := [1, 15, 16, 255, 256, 4095, 4096, 65535]
for size in test_sizes {
// Create data of specific size
mut data := []u8{len: size}
for i in 0 .. size {
data[i] = u8(i % 256)
}
chunk := format_chunk(data)
// Extract hex size
mut crlf_pos := -1
for j := 0; j < chunk.len - 1; j++ {
if chunk[j] == `\r` && chunk[j + 1] == `\n` {
crlf_pos = j
break
}
}
if crlf_pos == -1 {
println(' Size ${size}: No CRLF found')
return false
}
size_hex := chunk[..crlf_pos]
// Verify it's valid lowercase hex
for c in size_hex {
is_digit := c >= `0` && c <= `9`
is_lower_hex := c >= `a` && c <= `f`
if !is_digit && !is_lower_hex {
println(' Size ${size}: Invalid hex character "${c.ascii_str()}" in "${size_hex}"')
return false
}
}
// Verify parsed value matches
parsed := parse_hex(size_hex) or {
println(' Size ${size}: Failed to parse hex "${size_hex}"')
return false
}
if parsed != size {
println(' Size ${size}: Hex "${size_hex}" parsed as ${parsed}')
return false
}
}
return true
}
// Test 10f: Round-trip property - chunk then parse returns original data
fn test_property_10f_roundtrip() bool {
rand.seed([u32(time.now().unix()), u32(11111)])
for i in 0 .. test_iterations {
// Generate random data
original := generate_random_bytes(500)
// Format as chunk
chunk := format_chunk(original)
// Parse back
recovered, _ := parse_chunked_data(chunk) or {
println(' Iteration ${i}: Failed to parse: ${err}')
return false
}
// Verify round-trip
if recovered != original {
println(' Iteration ${i}: Round-trip failed - data mismatch')
return false
}
}
return true
}
fn main() {
println('🚀 开始 PicoevStreamWriter 属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 10 tests
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
// Validates: Requirements 6.2
stats.run_property_test('Property 10a: format_chunk bytes encoding', test_property_10a_format_chunk_bytes)
stats.run_property_test('Property 10b: format_chunk_string encoding', test_property_10b_format_chunk_string)
stats.run_property_test('Property 10c: Final chunk format', test_property_10c_final_chunk_format)
stats.run_property_test('Property 10d: Empty data handling', test_property_10d_empty_data_handling)
stats.run_property_test('Property 10e: Hex format correctness', test_property_10e_hex_format)
stats.run_property_test('Property 10f: Round-trip property', test_property_10f_roundtrip)
// Print summary
stats.print_summary()
}
@@ -0,0 +1,761 @@
// test_stream_error_handling_properties.v
// Property-Based Testing for Error Handling in SSE Streaming
// Feature: sse-streaming-helper
// Property 8: 错误处理器调用
// Property 9: 流自动关闭
// Validates: Requirements 4.1, 1.5, 5.2
module main
import rand
import time
const test_iterations = 100
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
stats.total_tests++
print('🔬 ${test_name}... ')
if test_func() {
stats.passed_tests++
println('✅')
} else {
stats.failed_tests++
println('❌')
}
}
fn (stats PropertyTestStats) print_summary() {
println('\n=== 错误处理属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// ============================================================================
// Mock StreamWriter for Testing
// ============================================================================
// MockStreamWriter - A mock implementation of StreamWriter for testing
// Captures all written data for verification
struct MockStreamWriter {
mut:
written_data []u8
connected bool
closed bool
flush_count int
}
fn MockStreamWriter.new() &MockStreamWriter {
return &MockStreamWriter{
written_data: []u8{}
connected: true
closed: false
flush_count: 0
}
}
fn (mut w MockStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data.bytes()
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) flush() ! {
w.flush_count++
}
fn (mut w MockStreamWriter) close() ! {
if w.connected {
// Write the final chunk (zero-length chunk) to signal end of stream
w.written_data << '0\r\n\r\n'.bytes()
w.connected = false
w.closed = true
}
}
fn (w MockStreamWriter) is_connected() bool {
return w.connected
}
fn (w MockStreamWriter) get_written_string() string {
return w.written_data.bytestr()
}
// ============================================================================
// StreamContext for Testing (simplified version)
// ============================================================================
// StreamContext - Context provided to streaming callbacks
@[heap]
struct StreamContext {
mut:
writer &MockStreamWriter
is_closed bool
abort_fn ?fn ()
}
// StreamContext.new - Create a new StreamContext with the given writer
fn StreamContext.new(writer &MockStreamWriter) StreamContext {
return StreamContext{
writer: unsafe { writer }
is_closed: false
abort_fn: none
}
}
// write - Write raw bytes to the stream
fn (mut ctx StreamContext) write(data []u8) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
ctx.writer.write(data)!
}
// write_string - Write a string to the stream
fn (mut ctx StreamContext) write_string(data string) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
ctx.writer.write_string(data)!
}
// close - Close the stream
fn (mut ctx StreamContext) close() {
if !ctx.is_closed {
ctx.is_closed = true
ctx.writer.close() or {}
}
}
// is_open - Check if the stream is still open
fn (ctx StreamContext) is_open() bool {
return !ctx.is_closed && ctx.writer.is_connected()
}
// ============================================================================
// Callback and Error Handler Types
// ============================================================================
// StreamCallback - Callback function type for streaming
type StreamCallback = fn (mut StreamContext) !
// StreamErrorHandler - Error handler function type for streaming
type StreamErrorHandler = fn (err IError, mut ctx StreamContext)
// ============================================================================
// Stream Response Type
// ============================================================================
struct StreamResponse {
pub:
status_code int = 200
headers map[string]string
is_stream bool = true
}
// ============================================================================
// Streaming Helper Functions (for testing)
// ============================================================================
// stream - Basic binary streaming function with error handling
fn stream(mut writer MockStreamWriter, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
// Create StreamContext with the writer
mut ctx := StreamContext.new(writer)
// Execute the callback and handle errors
callback(mut ctx) or {
// Handle error
if error_handler.len > 0 {
// Call custom error handler
error_handler[0](err, mut ctx)
} else {
// Default: log error to console
eprintln('[SSE Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
ctx.close()
// Return response headers for streaming
return StreamResponse{
status_code: 200
headers: {
'Transfer-Encoding': 'chunked'
'Connection': 'keep-alive'
}
is_stream: true
}
}
// ============================================================================
// Error Tracking for Tests
// ============================================================================
// Global variables to track error handler calls
__global error_handler_called = false
__global error_handler_error_msg = ''
__global error_handler_stream_open = false
__global error_handler_write_success = false
fn reset_error_tracking() {
error_handler_called = false
error_handler_error_msg = ''
error_handler_stream_open = false
error_handler_write_success = false
}
// ============================================================================
// Helper Functions
// ============================================================================
// Generate random error message
fn generate_random_error_message(max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-.'
len := rand.int_in_range(5, max_len) or { 20 }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result
}
// Generate random string for testing
fn generate_random_string(max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'
len := rand.int_in_range(1, max_len) or { 10 }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result
}
// ============================================================================
// Property 8: 错误处理器调用
// Feature: sse-streaming-helper, Property 8
// Validates: Requirements 4.1
//
// *For any* callback that throws an error, if an error handler is provided,
// the error handler SHALL be called with the error and stream context.
// ============================================================================
// Test 8a: Custom error handler is called when callback throws error
fn test_property_8a_error_handler_called() bool {
rand.seed([u32(time.now().unix()), u32(88881)])
for i in 0 .. test_iterations {
reset_error_tracking()
// Generate random error message
error_msg := generate_random_error_message(50)
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that throws an error
callback := fn [error_msg] (mut ctx StreamContext) ! {
return error(error_msg)
}
// Define error handler that tracks the call
error_handler := fn (err IError, mut ctx StreamContext) {
error_handler_called = true
error_handler_error_msg = err.msg()
}
// Call stream with error handler
_ := stream(mut writer, callback, error_handler)
// Verify error handler was called
if !error_handler_called {
println(' Iteration ${i}: Error handler was not called')
return false
}
// Verify error message was passed correctly
if error_handler_error_msg != error_msg {
println(' Iteration ${i}: Error message mismatch')
println(' Expected: ${error_msg}')
println(' Got: ${error_handler_error_msg}')
return false
}
}
return true
}
// Test 8b: Error handler receives valid stream context
fn test_property_8b_error_handler_receives_context() bool {
rand.seed([u32(time.now().unix()), u32(88882)])
for i in 0 .. test_iterations {
reset_error_tracking()
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that throws an error
callback := fn (mut ctx StreamContext) ! {
return error('Test error')
}
// Define error handler that checks stream context
error_handler := fn (err IError, mut ctx StreamContext) {
error_handler_called = true
error_handler_stream_open = ctx.is_open()
}
// Call stream with error handler
_ := stream(mut writer, callback, error_handler)
// Verify error handler was called
if !error_handler_called {
println(' Iteration ${i}: Error handler was not called')
return false
}
// Verify stream was still open when error handler was called
if !error_handler_stream_open {
println(' Iteration ${i}: Stream should be open when error handler is called')
return false
}
}
return true
}
// Test 8c: Error handler can write to stream before it closes
fn test_property_8c_error_handler_can_write() bool {
rand.seed([u32(time.now().unix()), u32(88883)])
for i in 0 .. test_iterations {
reset_error_tracking()
// Generate random error response message
error_response := generate_random_string(30)
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that throws an error
callback := fn (mut ctx StreamContext) ! {
return error('Test error')
}
// Define error handler that writes to stream
error_handler := fn [error_response] (err IError, mut ctx StreamContext) {
error_handler_called = true
ctx.write_string(error_response) or {
error_handler_write_success = false
return
}
error_handler_write_success = true
}
// Call stream with error handler
_ := stream(mut writer, callback, error_handler)
// Verify error handler was called
if !error_handler_called {
println(' Iteration ${i}: Error handler was not called')
return false
}
// Verify write was successful
if !error_handler_write_success {
println(' Iteration ${i}: Error handler failed to write to stream')
return false
}
// Verify the error response was written
written := writer.get_written_string()
if !written.contains(error_response) {
println(' Iteration ${i}: Error response not found in written data')
return false
}
}
return true
}
// Test 8d: Without error handler, error is logged (no crash)
fn test_property_8d_default_error_handling() bool {
rand.seed([u32(time.now().unix()), u32(88884)])
for i in 0 .. test_iterations {
// Generate random error message
error_msg := generate_random_error_message(50)
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that throws an error
callback := fn [error_msg] (mut ctx StreamContext) ! {
return error(error_msg)
}
// Call stream WITHOUT error handler - should not crash
// Default behavior logs to console via eprintln
response := stream(mut writer, callback)
// Verify stream completed (no crash)
if response.status_code != 200 {
println(' Iteration ${i}: Unexpected status code ${response.status_code}')
return false
}
// Verify stream was closed
if !writer.closed {
println(' Iteration ${i}: Stream should be closed after error')
return false
}
}
return true
}
// Test 8e: Error handler called with correct error type
fn test_property_8e_error_type_preserved() bool {
rand.seed([u32(time.now().unix()), u32(88885)])
for i in 0 .. test_iterations {
reset_error_tracking()
// Generate random error message
error_msg := generate_random_error_message(50)
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that throws an error
callback := fn [error_msg] (mut ctx StreamContext) ! {
return error(error_msg)
}
// Define error handler that checks error type
error_handler := fn [error_msg] (err IError, mut ctx StreamContext) {
error_handler_called = true
// Verify error message matches
if err.msg() == error_msg {
error_handler_write_success = true
}
}
// Call stream with error handler
_ := stream(mut writer, callback, error_handler)
// Verify error handler was called with correct error
if !error_handler_called {
println(' Iteration ${i}: Error handler was not called')
return false
}
if !error_handler_write_success {
println(' Iteration ${i}: Error message was not preserved correctly')
return false
}
}
return true
}
// ============================================================================
// Property 9: 流自动关闭
// Feature: sse-streaming-helper, Property 9
// Validates: Requirements 1.5, 5.2
//
// *For any* stream where the callback completes normally, the stream SHALL be
// automatically closed after callback execution.
// ============================================================================
// Test 9a: Stream is closed after successful callback completion
fn test_property_9a_stream_closed_after_success() bool {
rand.seed([u32(time.now().unix()), u32(99991)])
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Generate random data to write
data := generate_random_string(50)
// Define callback that completes successfully
callback := fn [data] (mut ctx StreamContext) ! {
ctx.write_string(data)!
}
// Call stream
_ := stream(mut writer, callback)
// Verify stream was closed
if !writer.closed {
println(' Iteration ${i}: Stream should be closed after callback completes')
return false
}
// Verify writer is disconnected
if writer.connected {
println(' Iteration ${i}: Writer should be disconnected after close')
return false
}
}
return true
}
// Test 9b: Stream is closed after callback throws error
fn test_property_9b_stream_closed_after_error() bool {
rand.seed([u32(time.now().unix()), u32(99992)])
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Generate random error message
error_msg := generate_random_error_message(30)
// Define callback that throws an error
callback := fn [error_msg] (mut ctx StreamContext) ! {
return error(error_msg)
}
// Call stream (with or without error handler)
_ := stream(mut writer, callback)
// Verify stream was closed even after error
if !writer.closed {
println(' Iteration ${i}: Stream should be closed after error')
return false
}
// Verify writer is disconnected
if writer.connected {
println(' Iteration ${i}: Writer should be disconnected after close')
return false
}
}
return true
}
// Test 9c: Final chunk marker is written when stream closes
fn test_property_9c_final_chunk_written() bool {
rand.seed([u32(time.now().unix()), u32(99993)])
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Generate random data to write
data := generate_random_string(50)
// Define callback that completes successfully
callback := fn [data] (mut ctx StreamContext) ! {
ctx.write_string(data)!
}
// Call stream
_ := stream(mut writer, callback)
// Verify final chunk marker is present
written := writer.get_written_string()
if !written.ends_with('0\r\n\r\n') {
println(' Iteration ${i}: Missing final chunk marker')
return false
}
}
return true
}
// Test 9d: Stream closed only once (idempotent close)
fn test_property_9d_idempotent_close() bool {
rand.seed([u32(time.now().unix()), u32(99994)])
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Define callback that manually closes the stream
callback := fn (mut ctx StreamContext) ! {
ctx.write_string('test')!
ctx.close() // Manual close
}
// Call stream (which will also try to close)
_ := stream(mut writer, callback)
// Verify stream was closed
if !writer.closed {
println(' Iteration ${i}: Stream should be closed')
return false
}
// Count final chunk markers - should be exactly one
written := writer.get_written_string()
mut final_chunk_count := 0
mut pos := 0
for {
idx := written[pos..].index('0\r\n\r\n') or { break }
final_chunk_count++
pos += idx + 5
}
if final_chunk_count != 1 {
println(' Iteration ${i}: Expected 1 final chunk marker, got ${final_chunk_count}')
return false
}
}
return true
}
// Test 9e: Stream closed after error handler writes
fn test_property_9e_closed_after_error_handler_writes() bool {
rand.seed([u32(time.now().unix()), u32(99995)])
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Generate random error response
error_response := generate_random_string(30)
// Define callback that throws an error
callback := fn (mut ctx StreamContext) ! {
return error('Test error')
}
// Define error handler that writes to stream
error_handler := fn [error_response] (err IError, mut ctx StreamContext) {
ctx.write_string(error_response) or {}
}
// Call stream with error handler
_ := stream(mut writer, callback, error_handler)
// Verify stream was closed after error handler
if !writer.closed {
println(' Iteration ${i}: Stream should be closed after error handler')
return false
}
// Verify error response was written before close
written := writer.get_written_string()
if !written.contains(error_response) {
println(' Iteration ${i}: Error response should be written before close')
return false
}
// Verify final chunk marker is present
if !written.ends_with('0\r\n\r\n') {
println(' Iteration ${i}: Missing final chunk marker')
return false
}
}
return true
}
// Test 9f: Empty callback still closes stream
fn test_property_9f_empty_callback_closes() bool {
for i in 0 .. test_iterations {
// Create mock writer
mut writer := MockStreamWriter.new()
// Define empty callback
callback := fn (mut ctx StreamContext) ! {
// Do nothing
}
// Call stream
_ := stream(mut writer, callback)
// Verify stream was closed
if !writer.closed {
println(' Iteration ${i}: Stream should be closed even with empty callback')
return false
}
}
return true
}
fn main() {
println('🚀 开始错误处理属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 8 tests
// Feature: sse-streaming-helper, Property 8: 错误处理器调用
// Validates: Requirements 4.1
println('--- Property 8: 错误处理器调用 ---')
stats.run_property_test('Property 8a: Error handler called on error', test_property_8a_error_handler_called)
stats.run_property_test('Property 8b: Error handler receives context', test_property_8b_error_handler_receives_context)
stats.run_property_test('Property 8c: Error handler can write', test_property_8c_error_handler_can_write)
stats.run_property_test('Property 8d: Default error handling', test_property_8d_default_error_handling)
stats.run_property_test('Property 8e: Error type preserved', test_property_8e_error_type_preserved)
println('')
// Run Property 9 tests
// Feature: sse-streaming-helper, Property 9: 流自动关闭
// Validates: Requirements 1.5, 5.2
println('--- Property 9: 流自动关闭 ---')
stats.run_property_test('Property 9a: Stream closed after success', test_property_9a_stream_closed_after_success)
stats.run_property_test('Property 9b: Stream closed after error', test_property_9b_stream_closed_after_error)
stats.run_property_test('Property 9c: Final chunk written', test_property_9c_final_chunk_written)
stats.run_property_test('Property 9d: Idempotent close', test_property_9d_idempotent_close)
stats.run_property_test('Property 9e: Closed after error handler writes', test_property_9e_closed_after_error_handler_writes)
stats.run_property_test('Property 9f: Empty callback closes stream', test_property_9f_empty_callback_closes)
// Print summary
stats.print_summary()
}
+586
View File
@@ -0,0 +1,586 @@
// test_stream_function_properties.v
// Property-Based Testing for stream() function
// Feature: sse-streaming-helper
// Property 1: stream() 设置正确的 Transfer-Encoding 头
// Property 2: write() 正确输出数据
// Validates: Requirements 1.1, 1.2
module main
import rand
import time
const test_iterations = 100
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
stats.total_tests++
print('🔬 ${test_name}... ')
if test_func() {
stats.passed_tests++
println('✅')
} else {
stats.failed_tests++
println('❌')
}
}
fn (stats PropertyTestStats) print_summary() {
println('\n=== stream() 函数属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// ============================================================================
// Mock StreamWriter for Testing
// ============================================================================
// MockStreamWriter - A mock implementation of StreamWriter for testing
// Captures all written data for verification
struct MockStreamWriter {
mut:
written_data []u8
connected bool
closed bool
flush_count int
}
fn MockStreamWriter.new() &MockStreamWriter {
return &MockStreamWriter{
written_data: []u8{}
connected: true
closed: false
flush_count: 0
}
}
fn (mut w MockStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data.bytes()
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) flush() ! {
w.flush_count++
}
fn (mut w MockStreamWriter) close() ! {
if w.connected {
// Write the final chunk (zero-length chunk) to signal end of stream
w.written_data << '0\r\n\r\n'.bytes()
w.connected = false
w.closed = true
}
}
fn (w MockStreamWriter) is_connected() bool {
return w.connected
}
fn (w MockStreamWriter) get_written_string() string {
return w.written_data.bytestr()
}
// ============================================================================
// Helper Functions
// ============================================================================
// get_stream_headers - Get the headers required for basic streaming
fn get_stream_headers() map[string]string {
return {
'Transfer-Encoding': 'chunked'
'Connection': 'keep-alive'
}
}
// Generate random bytes for testing
fn generate_random_bytes(max_len int) []u8 {
len := rand.int_in_range(1, max_len) or { 10 }
mut data := []u8{len: len}
for i in 0 .. len {
data[i] = u8(rand.int_in_range(0, 256) or { 0 })
}
return data
}
// Generate random string for testing (printable ASCII only)
fn generate_random_string(max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'
len := rand.int_in_range(1, max_len) or { 10 }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result
}
// Parse hex string to int
fn parse_hex(hex_str string) !int {
mut result := 0
for c in hex_str {
result *= 16
if c >= `0` && c <= `9` {
result += int(c - `0`)
} else if c >= `a` && c <= `f` {
result += int(c - `a` + 10)
} else if c >= `A` && c <= `F` {
result += int(c - `A` + 10)
} else {
return error('Invalid hex character: ${c.ascii_str()}')
}
}
return result
}
// Parse a chunked encoded string and extract all chunks
fn parse_all_chunks(chunked string) !([][]u8, bool) {
mut chunks := [][]u8{}
mut pos := 0
for pos < chunked.len {
// Find the size part (before \r\n)
mut crlf_pos := -1
for i := pos; i < chunked.len - 1; i++ {
if chunked[i] == `\r` && chunked[i + 1] == `\n` {
crlf_pos = i
break
}
}
if crlf_pos == -1 {
return error('No CRLF found in chunk header at position ${pos}')
}
size_hex := chunked[pos..crlf_pos]
size := parse_hex(size_hex) or {
return error('Invalid hex size: ${size_hex}')
}
// Check for final chunk
if size == 0 {
// Verify final chunk format
remaining := chunked[pos..]
if remaining != '0\r\n\r\n' {
return error('Invalid final chunk format')
}
break
}
// Extract data after the \r\n
data_start := crlf_pos + 2
data_end := data_start + size
if data_end > chunked.len {
return error('Chunk data incomplete')
}
data := chunked[data_start..data_end].bytes()
chunks << data
// Verify trailing \r\n
if data_end + 2 > chunked.len {
return error('Missing trailing CRLF')
}
trailing := chunked[data_end..data_end + 2]
if trailing != '\r\n' {
return error('Invalid trailing CRLF')
}
pos = data_end + 2
}
return chunks, true
}
// ============================================================================
// Property 1: stream() 设置正确的 Transfer-Encoding 头
// Feature: sse-streaming-helper, Property 1
// Validates: Requirements 1.1
//
// *For any* call to stream(c, callback), the response SHALL contain
// Transfer-Encoding: chunked header.
// ============================================================================
// Test 1a: get_stream_headers returns Transfer-Encoding: chunked
fn test_property_1a_transfer_encoding_header() bool {
headers := get_stream_headers()
// Verify Transfer-Encoding header exists
if 'Transfer-Encoding' !in headers {
println(' Transfer-Encoding header missing')
return false
}
// Verify value is 'chunked'
if headers['Transfer-Encoding'] != 'chunked' {
println(' Transfer-Encoding should be "chunked", got "${headers['Transfer-Encoding']}"')
return false
}
return true
}
// Test 1b: get_stream_headers returns Connection: keep-alive
fn test_property_1b_connection_header() bool {
headers := get_stream_headers()
// Verify Connection header exists
if 'Connection' !in headers {
println(' Connection header missing')
return false
}
// Verify value is 'keep-alive'
if headers['Connection'] != 'keep-alive' {
println(' Connection should be "keep-alive", got "${headers['Connection']}"')
return false
}
return true
}
// Test 1c: Headers are consistent across multiple calls
fn test_property_1c_headers_consistency() bool {
for _ in 0 .. test_iterations {
headers1 := get_stream_headers()
headers2 := get_stream_headers()
// Headers should be identical
if headers1['Transfer-Encoding'] != headers2['Transfer-Encoding'] {
println(' Transfer-Encoding inconsistent between calls')
return false
}
if headers1['Connection'] != headers2['Connection'] {
println(' Connection inconsistent between calls')
return false
}
}
return true
}
// ============================================================================
// Property 2: write() 正确输出数据
// Feature: sse-streaming-helper, Property 2
// Validates: Requirements 1.2
//
// *For any* byte array data, when stream.write(data) is called, the output
// stream SHALL contain the exact bytes from data in chunked transfer encoding format.
// ============================================================================
// Test 2a: write() outputs data in chunked format for any byte array
fn test_property_2a_write_bytes_chunked() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate random data
data := generate_random_bytes(500)
// Create mock writer and write data
mut writer := MockStreamWriter.new()
writer.write(data) or {
println(' Iteration ${i}: write failed: ${err}')
return false
}
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written + '0\r\n\r\n') or {
println(' Iteration ${i}: Failed to parse chunks: ${err}')
return false
}
// Should have exactly one chunk
if chunks.len != 1 {
println(' Iteration ${i}: Expected 1 chunk, got ${chunks.len}')
return false
}
// Chunk data should match original
if chunks[0] != data {
println(' Iteration ${i}: Data mismatch')
return false
}
}
return true
}
// Test 2b: write_string() outputs data in chunked format for any string
fn test_property_2b_write_string_chunked() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
// Generate random string
data := generate_random_string(500)
// Create mock writer and write data
mut writer := MockStreamWriter.new()
writer.write_string(data) or {
println(' Iteration ${i}: write_string failed: ${err}')
return false
}
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written + '0\r\n\r\n') or {
println(' Iteration ${i}: Failed to parse chunks: ${err}')
return false
}
// Should have exactly one chunk
if chunks.len != 1 {
println(' Iteration ${i}: Expected 1 chunk, got ${chunks.len}')
return false
}
// Chunk data should match original string
if chunks[0].bytestr() != data {
println(' Iteration ${i}: String mismatch')
return false
}
}
return true
}
// Test 2c: Multiple writes produce multiple chunks
fn test_property_2c_multiple_writes() bool {
rand.seed([u32(time.now().unix()), u32(98765)])
for i in 0 .. test_iterations {
// Generate multiple random data pieces
num_writes := rand.int_in_range(2, 10) or { 3 }
mut original_data := [][]u8{}
mut writer := MockStreamWriter.new()
for _ in 0 .. num_writes {
data := generate_random_bytes(100)
original_data << data
writer.write(data) or {
println(' Iteration ${i}: write failed: ${err}')
return false
}
}
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written + '0\r\n\r\n') or {
println(' Iteration ${i}: Failed to parse chunks: ${err}')
return false
}
// Should have same number of chunks as writes
if chunks.len != num_writes {
println(' Iteration ${i}: Expected ${num_writes} chunks, got ${chunks.len}')
return false
}
// Each chunk should match original data
for j, chunk in chunks {
if chunk != original_data[j] {
println(' Iteration ${i}: Chunk ${j} data mismatch')
return false
}
}
}
return true
}
// Test 2d: close() writes final chunk marker
fn test_property_2d_close_final_chunk() bool {
for _ in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
// Write some data
data := generate_random_bytes(50)
writer.write(data) or {
println(' write failed')
return false
}
// Close the writer
writer.close() or {
println(' close failed')
return false
}
// Verify final chunk marker is present
written := writer.get_written_string()
if !written.ends_with('0\r\n\r\n') {
println(' Missing final chunk marker')
return false
}
// Verify writer is marked as closed
if writer.connected {
println(' Writer should be disconnected after close')
return false
}
if !writer.closed {
println(' Writer should be marked as closed')
return false
}
}
return true
}
// Test 2e: Empty data is handled correctly (no chunk written)
fn test_property_2e_empty_data() bool {
mut writer := MockStreamWriter.new()
// Write empty byte array
writer.write([]u8{}) or {
println(' write empty bytes failed')
return false
}
// Write empty string
writer.write_string('') or {
println(' write empty string failed')
return false
}
// No data should be written for empty inputs
if writer.written_data.len != 0 {
println(' Empty data should not produce output, got ${writer.written_data.len} bytes')
return false
}
return true
}
// Test 2f: Round-trip property - write then parse returns original data
fn test_property_2f_roundtrip() bool {
rand.seed([u32(time.now().unix()), u32(11111)])
for i in 0 .. test_iterations {
// Generate random data
original := generate_random_bytes(500)
// Write to mock writer
mut writer := MockStreamWriter.new()
writer.write(original) or {
println(' Iteration ${i}: write failed')
return false
}
writer.close() or {
println(' Iteration ${i}: close failed')
return false
}
// Parse back
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written) or {
println(' Iteration ${i}: Failed to parse: ${err}')
return false
}
// Verify round-trip
if chunks.len != 1 {
println(' Iteration ${i}: Expected 1 chunk, got ${chunks.len}')
return false
}
if chunks[0] != original {
println(' Iteration ${i}: Round-trip failed - data mismatch')
return false
}
}
return true
}
// Test 2g: Writing to closed connection returns error
fn test_property_2g_write_after_close() bool {
mut writer := MockStreamWriter.new()
// Close the writer
writer.close() or {
println(' close failed')
return false
}
// Try to write - should fail
writer.write('test'.bytes()) or {
// Expected error
return true
}
println(' Writing to closed connection should fail')
return false
}
fn main() {
println('🚀 开始 stream() 函数属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 1 tests
// Feature: sse-streaming-helper, Property 1: stream() 设置正确的 Transfer-Encoding 头
// Validates: Requirements 1.1
println('--- Property 1: stream() 设置正确的 Transfer-Encoding 头 ---')
stats.run_property_test('Property 1a: Transfer-Encoding header', test_property_1a_transfer_encoding_header)
stats.run_property_test('Property 1b: Connection header', test_property_1b_connection_header)
stats.run_property_test('Property 1c: Headers consistency', test_property_1c_headers_consistency)
println('')
// Run Property 2 tests
// Feature: sse-streaming-helper, Property 2: write() 正确输出数据
// Validates: Requirements 1.2
println('--- Property 2: write() 正确输出数据 ---')
stats.run_property_test('Property 2a: write() bytes chunked format', test_property_2a_write_bytes_chunked)
stats.run_property_test('Property 2b: write_string() chunked format', test_property_2b_write_string_chunked)
stats.run_property_test('Property 2c: Multiple writes produce chunks', test_property_2c_multiple_writes)
stats.run_property_test('Property 2d: close() writes final chunk', test_property_2d_close_final_chunk)
stats.run_property_test('Property 2e: Empty data handling', test_property_2e_empty_data)
stats.run_property_test('Property 2f: Round-trip property', test_property_2f_roundtrip)
stats.run_property_test('Property 2g: Write after close fails', test_property_2g_write_after_close)
// Print summary
stats.print_summary()
}
+988
View File
@@ -0,0 +1,988 @@
// test_stream_sse_properties.v
// Property-Based Testing for stream_sse() function
// Feature: sse-streaming-helper
// Property 6: streamSSE() 设置正确的 HTTP 头
// Property 7: SSE 事件格式化正确性
// Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9
module main
import rand
const test_iterations = 100
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
stats.total_tests++
print('🔬 ${test_name}... ')
if test_func() {
stats.passed_tests++
println('✅')
} else {
stats.failed_tests++
println('❌')
}
}
fn (stats PropertyTestStats) print_summary() {
println('\n=== stream_sse() 函数属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// ============================================================================
// Mock StreamWriter for Testing
// ============================================================================
// MockStreamWriter - A mock implementation of StreamWriter for testing
// Captures all written data for verification
struct MockStreamWriter {
mut:
written_data []u8
connected bool
closed bool
flush_count int
}
fn MockStreamWriter.new() &MockStreamWriter {
return &MockStreamWriter{
written_data: []u8{}
connected: true
closed: false
flush_count: 0
}
}
fn (mut w MockStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data.bytes()
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) flush() ! {
w.flush_count++
}
fn (mut w MockStreamWriter) close() ! {
if w.connected {
// Write the final chunk (zero-length chunk) to signal end of stream
w.written_data << '0\r\n\r\n'.bytes()
w.connected = false
w.closed = true
}
}
fn (w MockStreamWriter) is_connected() bool {
return w.connected
}
fn (w MockStreamWriter) get_written_string() string {
return w.written_data.bytestr()
}
// extract_sse_content - Extract the actual SSE content from chunked encoding
// Removes the chunk size headers and CRLF markers to get the raw SSE data
fn (w MockStreamWriter) extract_sse_content() string {
raw := w.written_data.bytestr()
mut result := []u8{}
mut i := 0
for i < raw.len {
// Find the chunk size (hex number before \r\n)
mut size_end := i
for size_end < raw.len && raw[size_end] != `\r` {
size_end++
}
if size_end >= raw.len {
break
}
// Parse chunk size from hex string
size_str := raw[i..size_end]
chunk_size := parse_hex_int(size_str)
if chunk_size == 0 {
// Final chunk
break
}
// Skip \r\n after size
i = size_end + 2
// Extract chunk data
if i + chunk_size <= raw.len {
result << raw[i..i + chunk_size].bytes()
}
// Skip chunk data and trailing \r\n
i = i + chunk_size + 2
}
return result.bytestr()
}
// parse_hex_int - Parse a hex string to integer
fn parse_hex_int(s string) int {
mut result := 0
for c in s {
result *= 16
if c >= `0` && c <= `9` {
result += int(c - `0`)
} else if c >= `a` && c <= `f` {
result += int(c - `a` + 10)
} else if c >= `A` && c <= `F` {
result += int(c - `A` + 10)
}
}
return result
}
// ============================================================================
// SSEEvent Structure for Testing
// ============================================================================
// SSEEvent - Server-Sent Events event structure
// Mirrors the implementation in streaming.v
struct SSEEvent {
data string // Event data (required) - supports multi-line
event string // Event type name (optional)
id string // Event ID for client reconnection (optional)
retry int // Reconnection time in milliseconds (optional, 0 = not set)
}
// ============================================================================
// StreamContext for Testing
// ============================================================================
// TestStreamContext - A test implementation of StreamContext
// Provides write_sse method for testing SSE event formatting
struct TestStreamContext {
mut:
writer &MockStreamWriter
is_closed bool
}
fn TestStreamContext.new(writer &MockStreamWriter) TestStreamContext {
return TestStreamContext{
writer: unsafe { writer }
is_closed: false
}
}
fn (mut ctx TestStreamContext) write_string(data string) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
ctx.writer.write_string(data)!
}
// write_sse - Write an SSE event to the stream
// Formats the event according to the SSE specification:
// - event: {type}\n (if event field is set)
// - data: {line}\n (for each line of data)
// - id: {id}\n (if id field is set)
// - retry: {ms}\n (if retry > 0)
// - \n (event separator)
fn (mut ctx TestStreamContext) write_sse(event SSEEvent) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
// Write event type if specified
if event.event.len > 0 {
ctx.writer.write_string('event: ${event.event}\n')!
}
// Write data field - handle multi-line data
lines := event.data.split('\n')
for line in lines {
ctx.writer.write_string('data: ${line}\n')!
}
// Write id if specified
if event.id.len > 0 {
ctx.writer.write_string('id: ${event.id}\n')!
}
// Write retry if specified (must be > 0)
if event.retry > 0 {
ctx.writer.write_string('retry: ${event.retry}\n')!
}
// Write event separator (empty line)
ctx.writer.write_string('\n')!
// Flush to ensure event is sent immediately
ctx.writer.flush()!
}
fn (mut ctx TestStreamContext) close() {
if !ctx.is_closed {
ctx.is_closed = true
ctx.writer.close() or {}
}
}
// ============================================================================
// Random Data Generators for Property Testing
// ============================================================================
// generate_random_string - Generate a random alphanumeric string
fn generate_random_string(min_len int, max_len int) string {
len := rand.int_in_range(min_len, max_len + 1) or { min_len }
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
mut result := []u8{cap: len}
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result << chars[idx]
}
return result.bytestr()
}
// generate_random_multiline_string - Generate a random string with newlines
fn generate_random_multiline_string(min_lines int, max_lines int) string {
num_lines := rand.int_in_range(min_lines, max_lines + 1) or { min_lines }
mut lines := []string{cap: num_lines}
for _ in 0 .. num_lines {
lines << generate_random_string(1, 20)
}
return lines.join('\n')
}
// generate_random_sse_event - Generate a random SSE event
fn generate_random_sse_event() SSEEvent {
// Randomly decide which optional fields to include
include_event := rand.int_in_range(0, 2) or { 0 } == 1
include_id := rand.int_in_range(0, 2) or { 0 } == 1
include_retry := rand.int_in_range(0, 2) or { 0 } == 1
is_multiline := rand.int_in_range(0, 3) or { 0 } == 0 // 1/3 chance of multiline
mut data := ''
if is_multiline {
data = generate_random_multiline_string(2, 5)
} else {
data = generate_random_string(1, 50)
}
return SSEEvent{
data: data
event: if include_event { generate_random_string(3, 15) } else { '' }
id: if include_id { generate_random_string(1, 10) } else { '' }
retry: if include_retry { rand.int_in_range(100, 10000) or { 1000 } } else { 0 }
}
}
// ============================================================================
// Helper Functions
// ============================================================================
// get_stream_sse_headers - Get the headers required for SSE streaming
// This mirrors the implementation in streaming.v
fn get_stream_sse_headers() map[string]string {
return {
'Content-Type': 'text/event-stream'
'Cache-Control': 'no-cache'
'Connection': 'keep-alive'
'Transfer-Encoding': 'chunked'
}
}
// ============================================================================
// Property 6: streamSSE() 设置正确的 HTTP 头
// Feature: sse-streaming-helper, Property 6
// Validates: Requirements 3.1, 3.2, 3.3
//
// *For any* call to streamSSE(c, callback), the response SHALL contain:
// - Content-Type: text/event-stream
// - Cache-Control: no-cache
// - Connection: keep-alive
// ============================================================================
// Test 6a: Content-Type header is set correctly for SSE
fn test_property_6a_content_type_header() bool {
headers := get_stream_sse_headers()
// Verify Content-Type header exists
if 'Content-Type' !in headers {
println(' Content-Type header missing')
return false
}
// Verify value is 'text/event-stream' (Requirement 3.1)
if headers['Content-Type'] != 'text/event-stream' {
println(' Content-Type should be "text/event-stream", got "${headers['Content-Type']}"')
return false
}
return true
}
// Test 6b: Cache-Control header is set correctly for SSE
fn test_property_6b_cache_control_header() bool {
headers := get_stream_sse_headers()
// Verify Cache-Control header exists
if 'Cache-Control' !in headers {
println(' Cache-Control header missing')
return false
}
// Verify value is 'no-cache' (Requirement 3.2)
if headers['Cache-Control'] != 'no-cache' {
println(' Cache-Control should be "no-cache", got "${headers['Cache-Control']}"')
return false
}
return true
}
// Test 6c: Connection header is set correctly for SSE
fn test_property_6c_connection_header() bool {
headers := get_stream_sse_headers()
// Verify Connection header exists
if 'Connection' !in headers {
println(' Connection header missing')
return false
}
// Verify value is 'keep-alive' (Requirement 3.3)
if headers['Connection'] != 'keep-alive' {
println(' Connection should be "keep-alive", got "${headers['Connection']}"')
return false
}
return true
}
// Test 6d: Transfer-Encoding header is set for chunked transfer
fn test_property_6d_transfer_encoding_header() bool {
headers := get_stream_sse_headers()
// Verify Transfer-Encoding header exists
if 'Transfer-Encoding' !in headers {
println(' Transfer-Encoding header missing')
return false
}
// Verify value is 'chunked'
if headers['Transfer-Encoding'] != 'chunked' {
println(' Transfer-Encoding should be "chunked", got "${headers['Transfer-Encoding']}"')
return false
}
return true
}
// Test 6e: Headers are consistent across multiple calls
fn test_property_6e_headers_consistency() bool {
for _ in 0 .. test_iterations {
headers1 := get_stream_sse_headers()
headers2 := get_stream_sse_headers()
// Headers should be identical
if headers1['Content-Type'] != headers2['Content-Type'] {
println(' Content-Type inconsistent between calls')
return false
}
if headers1['Cache-Control'] != headers2['Cache-Control'] {
println(' Cache-Control inconsistent between calls')
return false
}
if headers1['Connection'] != headers2['Connection'] {
println(' Connection inconsistent between calls')
return false
}
if headers1['Transfer-Encoding'] != headers2['Transfer-Encoding'] {
println(' Transfer-Encoding inconsistent between calls')
return false
}
}
return true
}
// Test 6f: All required SSE headers are present
fn test_property_6f_all_required_headers_present() bool {
headers := get_stream_sse_headers()
required_headers := ['Content-Type', 'Cache-Control', 'Connection', 'Transfer-Encoding']
for header in required_headers {
if header !in headers {
println(' Required header "${header}" is missing')
return false
}
}
return true
}
// Test 6g: SSE headers differ from text streaming headers
fn test_property_6g_sse_headers_differ_from_text() bool {
sse_headers := get_stream_sse_headers()
// SSE should use text/event-stream, not text/plain
if sse_headers['Content-Type'] == 'text/plain; charset=utf-8' {
println(' SSE Content-Type should not be text/plain')
return false
}
// SSE should have Cache-Control: no-cache
if 'Cache-Control' !in sse_headers {
println(' SSE should have Cache-Control header')
return false
}
return true
}
// ============================================================================
// Property 7: SSE 事件格式化正确性
// Feature: sse-streaming-helper, Property 7
// Validates: Requirements 3.4, 3.5, 3.6, 3.7, 3.8, 3.9
//
// *For any* SSEEvent with fields (data, event, id, retry), when stream.writeSSE(event) is called:
// - If event is non-empty, output SHALL start with event: {event}\n
// - Output SHALL contain data: {data}\n for each line of data
// - If id is non-empty, output SHALL contain id: {id}\n
// - If retry > 0, output SHALL contain retry: {retry}\n
// - Output SHALL end with an additional \n to separate events
// ============================================================================
// Test 7a: Data field is always written correctly (Requirement 3.5)
// *For any* SSE event with data, the output SHALL contain "data: {value}\n"
fn test_property_7a_data_field_formatting() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
// Generate random single-line data
data := generate_random_string(1, 50)
event := SSEEvent{
data: data
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
expected := 'data: ${data}\n'
if !content.contains(expected) {
println(' Iteration ${i}: Expected data line "${expected}" not found in output')
println(' Got: "${content}"')
return false
}
}
return true
}
// Test 7b: Multi-line data is split correctly (Requirement 3.5)
// *For any* SSE event with multi-line data, each line SHALL be prefixed with "data: "
fn test_property_7b_multiline_data_formatting() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
// Generate random multi-line data
data := generate_random_multiline_string(2, 5)
event := SSEEvent{
data: data
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
lines := data.split('\n')
// Each line of data should be prefixed with "data: "
for line in lines {
expected := 'data: ${line}\n'
if !content.contains(expected) {
println(' Iteration ${i}: Expected data line "${expected}" not found')
println(' Original data: "${data}"')
println(' Got: "${content}"')
return false
}
}
}
return true
}
// Test 7c: Event field is written before data (Requirement 3.6)
// *For any* SSE event with event field, output SHALL start with "event: {value}\n"
fn test_property_7c_event_field_before_data() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
event_type := generate_random_string(3, 15)
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
event: event_type
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
event_line := 'event: ${event_type}\n'
data_line := 'data: ${data}\n'
// Event line should exist
if !content.contains(event_line) {
println(' Iteration ${i}: Event line "${event_line}" not found')
return false
}
// Event line should come before data line
event_pos := content.index(event_line) or { -1 }
data_pos := content.index(data_line) or { -1 }
if event_pos >= data_pos {
println(' Iteration ${i}: Event line should come before data line')
println(' Event pos: ${event_pos}, Data pos: ${data_pos}')
return false
}
}
return true
}
// Test 7d: Event field is omitted when empty (Requirement 3.6)
// *For any* SSE event with empty event field, output SHALL NOT contain "event: "
fn test_property_7d_empty_event_field_omitted() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
event: '' // Empty event field
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Should not contain "event: " prefix
if content.contains('event: ') {
println(' Iteration ${i}: Empty event field should not produce "event: " line')
println(' Got: "${content}"')
return false
}
}
return true
}
// Test 7e: ID field is written after data (Requirement 3.7)
// *For any* SSE event with id field, output SHALL contain "id: {value}\n" after data
fn test_property_7e_id_field_after_data() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
id := generate_random_string(1, 10)
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
id: id
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
id_line := 'id: ${id}\n'
data_line := 'data: ${data}\n'
// ID line should exist
if !content.contains(id_line) {
println(' Iteration ${i}: ID line "${id_line}" not found')
return false
}
// ID line should come after data line
id_pos := content.index(id_line) or { -1 }
data_pos := content.index(data_line) or { -1 }
if id_pos <= data_pos {
println(' Iteration ${i}: ID line should come after data line')
println(' ID pos: ${id_pos}, Data pos: ${data_pos}')
return false
}
}
return true
}
// Test 7f: ID field is omitted when empty (Requirement 3.7)
// *For any* SSE event with empty id field, output SHALL NOT contain "id: "
fn test_property_7f_empty_id_field_omitted() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
id: '' // Empty id field
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Should not contain "id: " prefix
if content.contains('id: ') {
println(' Iteration ${i}: Empty id field should not produce "id: " line')
println(' Got: "${content}"')
return false
}
}
return true
}
// Test 7g: Retry field is written when positive (Requirement 3.8)
// *For any* SSE event with retry > 0, output SHALL contain "retry: {value}\n"
fn test_property_7g_retry_field_when_positive() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
retry := rand.int_in_range(100, 10000) or { 1000 }
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
retry: retry
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
retry_line := 'retry: ${retry}\n'
// Retry line should exist
if !content.contains(retry_line) {
println(' Iteration ${i}: Retry line "${retry_line}" not found')
println(' Got: "${content}"')
return false
}
}
return true
}
// Test 7h: Retry field is omitted when zero (Requirement 3.8)
// *For any* SSE event with retry = 0, output SHALL NOT contain "retry: "
fn test_property_7h_zero_retry_field_omitted() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
data := generate_random_string(1, 30)
event := SSEEvent{
data: data
retry: 0 // Zero retry
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Should not contain "retry: " prefix
if content.contains('retry: ') {
println(' Iteration ${i}: Zero retry should not produce "retry: " line')
println(' Got: "${content}"')
return false
}
}
return true
}
// Test 7i: Event separator (empty line) is always present (Requirement 3.9)
// *For any* SSE event, output SHALL end with an additional "\n" to separate events
fn test_property_7i_event_separator_present() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
event := generate_random_sse_event()
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Content should end with double newline (data line + separator)
// The last line before separator should be data:, id:, or retry:
// followed by \n\n (the separator)
if !content.ends_with('\n\n') {
println(' Iteration ${i}: Event should end with double newline (separator)')
println(' Got: "${content}"')
println(' Last chars: ${content.bytes()[content.len - 4..]}')
return false
}
}
return true
}
// Test 7j: Complete SSE event with all fields (Requirements 3.4-3.9)
// *For any* SSE event with all fields set, output SHALL follow correct format and order
fn test_property_7j_complete_event_format() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
event_type := generate_random_string(3, 15)
data := generate_random_string(1, 30)
id := generate_random_string(1, 10)
retry := rand.int_in_range(100, 10000) or { 1000 }
event := SSEEvent{
data: data
event: event_type
id: id
retry: retry
}
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Verify all fields are present
if !content.contains('event: ${event_type}\n') {
println(' Iteration ${i}: Event field missing')
return false
}
if !content.contains('data: ${data}\n') {
println(' Iteration ${i}: Data field missing')
return false
}
if !content.contains('id: ${id}\n') {
println(' Iteration ${i}: ID field missing')
return false
}
if !content.contains('retry: ${retry}\n') {
println(' Iteration ${i}: Retry field missing')
return false
}
// Verify order: event -> data -> id -> retry -> separator
event_pos := content.index('event: ') or { -1 }
data_pos := content.index('data: ') or { -1 }
id_pos := content.index('id: ') or { -1 }
retry_pos := content.index('retry: ') or { -1 }
if event_pos >= data_pos {
println(' Iteration ${i}: Event should come before data')
return false
}
if data_pos >= id_pos {
println(' Iteration ${i}: Data should come before id')
return false
}
if id_pos >= retry_pos {
println(' Iteration ${i}: ID should come before retry')
return false
}
// Verify ends with separator
if !content.ends_with('\n\n') {
println(' Iteration ${i}: Should end with event separator')
return false
}
}
return true
}
// Test 7k: Random SSE events are formatted correctly (Requirements 3.4-3.9)
// *For any* randomly generated SSE event, the formatting SHALL be correct
fn test_property_7k_random_events_formatted_correctly() bool {
for i in 0 .. test_iterations {
mut writer := MockStreamWriter.new()
mut ctx := TestStreamContext.new(writer)
event := generate_random_sse_event()
ctx.write_sse(event) or {
println(' Iteration ${i}: write_sse failed: ${err}')
return false
}
content := writer.extract_sse_content()
// Data field should always be present
lines := event.data.split('\n')
for line in lines {
if !content.contains('data: ${line}\n') {
println(' Iteration ${i}: Data line missing: "data: ${line}"')
return false
}
}
// Event field should be present only if non-empty
if event.event.len > 0 {
if !content.contains('event: ${event.event}\n') {
println(' Iteration ${i}: Event field should be present')
return false
}
} else {
if content.contains('event: ') {
println(' Iteration ${i}: Event field should not be present when empty')
return false
}
}
// ID field should be present only if non-empty
if event.id.len > 0 {
if !content.contains('id: ${event.id}\n') {
println(' Iteration ${i}: ID field should be present')
return false
}
} else {
if content.contains('id: ') {
println(' Iteration ${i}: ID field should not be present when empty')
return false
}
}
// Retry field should be present only if > 0
if event.retry > 0 {
if !content.contains('retry: ${event.retry}\n') {
println(' Iteration ${i}: Retry field should be present')
return false
}
} else {
if content.contains('retry: ') {
println(' Iteration ${i}: Retry field should not be present when zero')
return false
}
}
// Should always end with separator
if !content.ends_with('\n\n') {
println(' Iteration ${i}: Should end with event separator')
return false
}
}
return true
}
fn main() {
println('🚀 开始 stream_sse() 函数属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 6 tests
// Feature: sse-streaming-helper, Property 6: streamSSE() 设置正确的 HTTP 头
// Validates: Requirements 3.1, 3.2, 3.3
println('--- Property 6: streamSSE() 设置正确的 HTTP 头 ---')
stats.run_property_test('Property 6a: Content-Type header (text/event-stream)', test_property_6a_content_type_header)
stats.run_property_test('Property 6b: Cache-Control header (no-cache)', test_property_6b_cache_control_header)
stats.run_property_test('Property 6c: Connection header (keep-alive)', test_property_6c_connection_header)
stats.run_property_test('Property 6d: Transfer-Encoding header (chunked)', test_property_6d_transfer_encoding_header)
stats.run_property_test('Property 6e: Headers consistency', test_property_6e_headers_consistency)
stats.run_property_test('Property 6f: All required headers present', test_property_6f_all_required_headers_present)
stats.run_property_test('Property 6g: SSE headers differ from text', test_property_6g_sse_headers_differ_from_text)
// Run Property 7 tests
// Feature: sse-streaming-helper, Property 7: SSE 事件格式化正确性
// Validates: Requirements 3.4, 3.5, 3.6, 3.7, 3.8, 3.9
println('\n--- Property 7: SSE 事件格式化正确性 ---')
stats.run_property_test('Property 7a: Data field formatting', test_property_7a_data_field_formatting)
stats.run_property_test('Property 7b: Multi-line data formatting', test_property_7b_multiline_data_formatting)
stats.run_property_test('Property 7c: Event field before data', test_property_7c_event_field_before_data)
stats.run_property_test('Property 7d: Empty event field omitted', test_property_7d_empty_event_field_omitted)
stats.run_property_test('Property 7e: ID field after data', test_property_7e_id_field_after_data)
stats.run_property_test('Property 7f: Empty ID field omitted', test_property_7f_empty_id_field_omitted)
stats.run_property_test('Property 7g: Retry field when positive', test_property_7g_retry_field_when_positive)
stats.run_property_test('Property 7h: Zero retry field omitted', test_property_7h_zero_retry_field_omitted)
stats.run_property_test('Property 7i: Event separator present', test_property_7i_event_separator_present)
stats.run_property_test('Property 7j: Complete event format', test_property_7j_complete_event_format)
stats.run_property_test('Property 7k: Random events formatted correctly', test_property_7k_random_events_formatted_correctly)
// Print summary
stats.print_summary()
}
+648
View File
@@ -0,0 +1,648 @@
// test_stream_text_properties.v
// Property-Based Testing for stream_text() function
// Feature: sse-streaming-helper
// Property 3: streamText() 设置正确的 HTTP 头
// Property 4: writeln() 添加换行符
// Property 5: sleep() 暂停正确的时间
// Validates: Requirements 2.1, 2.2, 2.3, 2.5, 2.6
module main
import rand
import time
const test_iterations = 100
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
stats.total_tests++
print('🔬 ${test_name}... ')
if test_func() {
stats.passed_tests++
println('✅')
} else {
stats.failed_tests++
println('❌')
}
}
fn (stats PropertyTestStats) print_summary() {
println('\n=== stream_text() 函数属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// ============================================================================
// Mock StreamWriter for Testing
// ============================================================================
// MockStreamWriter - A mock implementation of StreamWriter for testing
// Captures all written data for verification
struct MockStreamWriter {
mut:
written_data []u8
connected bool
closed bool
flush_count int
}
fn MockStreamWriter.new() &MockStreamWriter {
return &MockStreamWriter{
written_data: []u8{}
connected: true
closed: false
flush_count: 0
}
}
fn (mut w MockStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
// Write chunked format: size in hex, CRLF, data, CRLF
chunk_header := '${data.len:x}\r\n'
w.written_data << chunk_header.bytes()
w.written_data << data.bytes()
w.written_data << '\r\n'.bytes()
}
fn (mut w MockStreamWriter) flush() ! {
w.flush_count++
}
fn (mut w MockStreamWriter) close() ! {
if w.connected {
// Write the final chunk (zero-length chunk) to signal end of stream
w.written_data << '0\r\n\r\n'.bytes()
w.connected = false
w.closed = true
}
}
fn (w MockStreamWriter) is_connected() bool {
return w.connected
}
fn (w MockStreamWriter) get_written_string() string {
return w.written_data.bytestr()
}
// ============================================================================
// StreamContext for Testing (simplified version)
// ============================================================================
@[heap]
struct StreamContext {
mut:
writer &MockStreamWriter
is_closed bool
}
fn StreamContext.new(writer &MockStreamWriter) StreamContext {
return StreamContext{
writer: unsafe { writer }
is_closed: false
}
}
fn (mut ctx StreamContext) write_string(data string) ! {
if ctx.is_closed {
return error('Stream is closed')
}
if !ctx.writer.is_connected() {
ctx.is_closed = true
return error('Connection lost')
}
ctx.writer.write_string(data)!
}
fn (mut ctx StreamContext) writeln(data string) ! {
ctx.write_string(data)!
ctx.write_string('\n')!
}
fn (ctx StreamContext) sleep(ms int) {
time.sleep(ms * time.millisecond)
}
fn (mut ctx StreamContext) close() {
if !ctx.is_closed {
ctx.is_closed = true
ctx.writer.close() or {}
}
}
// ============================================================================
// Helper Functions
// ============================================================================
// get_stream_text_headers - Get the headers required for text streaming
fn get_stream_text_headers() map[string]string {
return {
'Content-Type': 'text/plain; charset=utf-8'
'Transfer-Encoding': 'chunked'
'X-Content-Type-Options': 'nosniff'
'Connection': 'keep-alive'
}
}
// Generate random string for testing (printable ASCII only)
fn generate_random_string(max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-. '
len := rand.int_in_range(1, max_len) or { 10 }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result
}
// Parse hex string to int
fn parse_hex(hex_str string) !int {
mut result := 0
for c in hex_str {
result *= 16
if c >= `0` && c <= `9` {
result += int(c - `0`)
} else if c >= `a` && c <= `f` {
result += int(c - `a` + 10)
} else if c >= `A` && c <= `F` {
result += int(c - `A` + 10)
} else {
return error('Invalid hex character: ${c.ascii_str()}')
}
}
return result
}
// Parse a chunked encoded string and extract all chunks
fn parse_all_chunks(chunked string) !([]string, bool) {
mut chunks := []string{}
mut pos := 0
for pos < chunked.len {
// Find the size part (before \r\n)
mut crlf_pos := -1
for i := pos; i < chunked.len - 1; i++ {
if chunked[i] == `\r` && chunked[i + 1] == `\n` {
crlf_pos = i
break
}
}
if crlf_pos == -1 {
return error('No CRLF found in chunk header at position ${pos}')
}
size_hex := chunked[pos..crlf_pos]
size := parse_hex(size_hex) or {
return error('Invalid hex size: ${size_hex}')
}
// Check for final chunk
if size == 0 {
// Verify final chunk format
remaining := chunked[pos..]
if remaining != '0\r\n\r\n' {
return error('Invalid final chunk format')
}
break
}
// Extract data after the \r\n
data_start := crlf_pos + 2
data_end := data_start + size
if data_end > chunked.len {
return error('Chunk data incomplete')
}
data := chunked[data_start..data_end]
chunks << data
// Verify trailing \r\n
if data_end + 2 > chunked.len {
return error('Missing trailing CRLF')
}
trailing := chunked[data_end..data_end + 2]
if trailing != '\r\n' {
return error('Invalid trailing CRLF')
}
pos = data_end + 2
}
return chunks, true
}
// ============================================================================
// Property 3: streamText() 设置正确的 HTTP 头
// Feature: sse-streaming-helper, Property 3
// Validates: Requirements 2.1, 2.2, 2.3
//
// *For any* call to streamText(c, callback), the response SHALL contain:
// - Content-Type: text/plain; charset=utf-8
// - Transfer-Encoding: chunked
// - X-Content-Type-Options: nosniff
// ============================================================================
// Test 3a: Content-Type header is set correctly
fn test_property_3a_content_type_header() bool {
headers := get_stream_text_headers()
// Verify Content-Type header exists
if 'Content-Type' !in headers {
println(' Content-Type header missing')
return false
}
// Verify value is 'text/plain; charset=utf-8'
if headers['Content-Type'] != 'text/plain; charset=utf-8' {
println(' Content-Type should be "text/plain; charset=utf-8", got "${headers['Content-Type']}"')
return false
}
return true
}
// Test 3b: Transfer-Encoding header is set correctly
fn test_property_3b_transfer_encoding_header() bool {
headers := get_stream_text_headers()
// Verify Transfer-Encoding header exists
if 'Transfer-Encoding' !in headers {
println(' Transfer-Encoding header missing')
return false
}
// Verify value is 'chunked'
if headers['Transfer-Encoding'] != 'chunked' {
println(' Transfer-Encoding should be "chunked", got "${headers['Transfer-Encoding']}"')
return false
}
return true
}
// Test 3c: X-Content-Type-Options header is set correctly
fn test_property_3c_x_content_type_options_header() bool {
headers := get_stream_text_headers()
// Verify X-Content-Type-Options header exists
if 'X-Content-Type-Options' !in headers {
println(' X-Content-Type-Options header missing')
return false
}
// Verify value is 'nosniff'
if headers['X-Content-Type-Options'] != 'nosniff' {
println(' X-Content-Type-Options should be "nosniff", got "${headers['X-Content-Type-Options']}"')
return false
}
return true
}
// Test 3d: Headers are consistent across multiple calls
fn test_property_3d_headers_consistency() bool {
for _ in 0 .. test_iterations {
headers1 := get_stream_text_headers()
headers2 := get_stream_text_headers()
// Headers should be identical
if headers1['Content-Type'] != headers2['Content-Type'] {
println(' Content-Type inconsistent between calls')
return false
}
if headers1['Transfer-Encoding'] != headers2['Transfer-Encoding'] {
println(' Transfer-Encoding inconsistent between calls')
return false
}
if headers1['X-Content-Type-Options'] != headers2['X-Content-Type-Options'] {
println(' X-Content-Type-Options inconsistent between calls')
return false
}
}
return true
}
// ============================================================================
// Property 4: writeln() 添加换行符
// Feature: sse-streaming-helper, Property 4
// Validates: Requirements 2.5
//
// *For any* string text, when stream.writeln(text) is called, the output
// SHALL be text followed by a newline character \n.
// ============================================================================
// Test 4a: writeln() appends newline for any string
fn test_property_4a_writeln_appends_newline() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
// Generate random text
text := generate_random_string(200)
// Create mock writer and context
mut writer := MockStreamWriter.new()
mut ctx := StreamContext.new(writer)
// Call writeln
ctx.writeln(text) or {
println(' Iteration ${i}: writeln failed: ${err}')
return false
}
// Close to finalize
ctx.close()
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written) or {
println(' Iteration ${i}: Failed to parse chunks: ${err}')
return false
}
// Should have exactly 2 chunks (text and newline)
if chunks.len != 2 {
println(' Iteration ${i}: Expected 2 chunks, got ${chunks.len}')
return false
}
// First chunk should be the text
if chunks[0] != text {
println(' Iteration ${i}: First chunk should be text')
return false
}
// Second chunk should be newline
if chunks[1] != '\n' {
println(' Iteration ${i}: Second chunk should be newline, got "${chunks[1]}"')
return false
}
}
return true
}
// Test 4b: writeln() with empty string still adds newline
fn test_property_4b_writeln_empty_string() bool {
// Create mock writer and context
mut writer := MockStreamWriter.new()
mut ctx := StreamContext.new(writer)
// Call writeln with empty string - this should fail because write_string skips empty
// Actually, writeln calls write_string twice: once for data, once for \n
// Empty string write is skipped, but \n should still be written
ctx.writeln('') or {
println(' writeln empty string failed: ${err}')
return false
}
// Close to finalize
ctx.close()
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written) or {
println(' Failed to parse chunks: ${err}')
return false
}
// Should have exactly 1 chunk (just the newline, empty string is skipped)
if chunks.len != 1 {
println(' Expected 1 chunk (newline only), got ${chunks.len}')
return false
}
// The chunk should be newline
if chunks[0] != '\n' {
println(' Chunk should be newline, got "${chunks[0]}"')
return false
}
return true
}
// Test 4c: Multiple writeln calls produce correct output
fn test_property_4c_multiple_writeln() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
// Generate multiple random lines
num_lines := rand.int_in_range(2, 5) or { 3 }
mut lines := []string{}
mut writer := MockStreamWriter.new()
mut ctx := StreamContext.new(writer)
for _ in 0 .. num_lines {
line := generate_random_string(50)
lines << line
ctx.writeln(line) or {
println(' Iteration ${i}: writeln failed: ${err}')
return false
}
}
// Close to finalize
ctx.close()
// Parse the written chunked data
written := writer.get_written_string()
chunks, _ := parse_all_chunks(written) or {
println(' Iteration ${i}: Failed to parse chunks: ${err}')
return false
}
// Should have 2 chunks per line (text + newline)
expected_chunks := num_lines * 2
if chunks.len != expected_chunks {
println(' Iteration ${i}: Expected ${expected_chunks} chunks, got ${chunks.len}')
return false
}
// Verify each line and newline pair
for j, line in lines {
text_idx := j * 2
newline_idx := j * 2 + 1
if chunks[text_idx] != line {
println(' Iteration ${i}: Line ${j} text mismatch')
return false
}
if chunks[newline_idx] != '\n' {
println(' Iteration ${i}: Line ${j} newline missing')
return false
}
}
}
return true
}
// ============================================================================
// Property 5: sleep() 暂停正确的时间
// Feature: sse-streaming-helper, Property 5
// Validates: Requirements 2.6
//
// *For any* positive integer ms, when stream.sleep(ms) is called, the execution
// SHALL pause for approximately ms milliseconds (within ±10% tolerance).
// ============================================================================
// Test 5a: sleep() pauses for approximately the specified time
fn test_property_5a_sleep_duration() bool {
// Test with a few specific durations to avoid long test times
test_durations := [10, 20, 50]
for duration in test_durations {
mut writer := MockStreamWriter.new()
ctx := StreamContext.new(writer)
// Measure time before and after sleep
start := time.now()
ctx.sleep(duration)
elapsed := time.now() - start
elapsed_ms := elapsed.milliseconds()
// Allow generous tolerance for system overhead (±50% plus overhead)
// Sleep timing can vary significantly depending on system load
min_expected := int(f64(duration) * 0.5)
max_expected := int(f64(duration) * 2.0) + 20
if elapsed_ms < min_expected || elapsed_ms > max_expected {
println(' sleep(${duration}) took ${elapsed_ms}ms, expected ${min_expected}-${max_expected}ms')
return false
}
}
return true
}
// Test 5b: sleep(0) returns immediately
fn test_property_5b_sleep_zero() bool {
mut writer := MockStreamWriter.new()
ctx := StreamContext.new(writer)
// Measure time for sleep(0)
start := time.now()
ctx.sleep(0)
elapsed := time.now() - start
elapsed_ms := elapsed.milliseconds()
// sleep(0) should return very quickly (within 10ms)
if elapsed_ms > 10 {
println(' sleep(0) took ${elapsed_ms}ms, expected < 10ms')
return false
}
return true
}
// Test 5c: Multiple sleeps accumulate correctly
fn test_property_5c_multiple_sleeps() bool {
mut writer := MockStreamWriter.new()
ctx := StreamContext.new(writer)
// Sleep multiple times
sleep_duration := 10
num_sleeps := 3
start := time.now()
for _ in 0 .. num_sleeps {
ctx.sleep(sleep_duration)
}
elapsed := time.now() - start
elapsed_ms := elapsed.milliseconds()
expected_total := sleep_duration * num_sleeps
// Allow generous tolerance for system overhead (±50% plus overhead)
// Sleep timing can vary significantly depending on system load
min_expected := int(f64(expected_total) * 0.5)
max_expected := int(f64(expected_total) * 2.0) + 30
if elapsed_ms < min_expected || elapsed_ms > max_expected {
println(' ${num_sleeps} sleeps of ${sleep_duration}ms took ${elapsed_ms}ms, expected ${min_expected}-${max_expected}ms')
return false
}
return true
}
fn main() {
println('🚀 开始 stream_text() 函数属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 3 tests
// Feature: sse-streaming-helper, Property 3: streamText() 设置正确的 HTTP 头
// Validates: Requirements 2.1, 2.2, 2.3
println('--- Property 3: streamText() 设置正确的 HTTP 头 ---')
stats.run_property_test('Property 3a: Content-Type header', test_property_3a_content_type_header)
stats.run_property_test('Property 3b: Transfer-Encoding header', test_property_3b_transfer_encoding_header)
stats.run_property_test('Property 3c: X-Content-Type-Options header', test_property_3c_x_content_type_options_header)
stats.run_property_test('Property 3d: Headers consistency', test_property_3d_headers_consistency)
println('')
// Run Property 4 tests
// Feature: sse-streaming-helper, Property 4: writeln() 添加换行符
// Validates: Requirements 2.5
println('--- Property 4: writeln() 添加换行符 ---')
stats.run_property_test('Property 4a: writeln() appends newline', test_property_4a_writeln_appends_newline)
stats.run_property_test('Property 4b: writeln() empty string', test_property_4b_writeln_empty_string)
stats.run_property_test('Property 4c: Multiple writeln calls', test_property_4c_multiple_writeln)
println('')
// Run Property 5 tests
// Feature: sse-streaming-helper, Property 5: sleep() 暂停正确的时间
// Validates: Requirements 2.6
println('--- Property 5: sleep() 暂停正确的时间 ---')
stats.run_property_test('Property 5a: sleep() duration', test_property_5a_sleep_duration)
stats.run_property_test('Property 5b: sleep(0) returns immediately', test_property_5b_sleep_zero)
stats.run_property_test('Property 5c: Multiple sleeps accumulate', test_property_5c_multiple_sleeps)
// Print summary
stats.print_summary()
}
@@ -0,0 +1,624 @@
// test_usockets_stream_writer_properties.v
// Property-Based Testing for UsocketsStreamWriter chunked encoding
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
// Validates: Requirements 7.2
module main
import rand
import time
const test_iterations = 100
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
stats.total_tests++
print('🔬 ${test_name}... ')
if test_func() {
stats.passed_tests++
println('✅')
} else {
stats.failed_tests++
println('❌')
}
}
fn (stats PropertyTestStats) print_summary() {
println('\n=== UsocketsStreamWriter 属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
// ============================================================================
// Chunked Encoding Functions (copied from streaming.v for testing)
// ============================================================================
// format_chunk_size - Format the chunk size as hex followed by CRLF
@[inline]
fn format_chunk_size(size int) string {
return '${size:x}\r\n'
}
// format_chunk - Format a complete chunk with size, data, and trailing CRLF
fn format_chunk(data []u8) string {
if data.len == 0 {
return ''
}
return '${data.len:x}\r\n${data.bytestr()}\r\n'
}
// format_chunk_string - Format a string as a complete chunk
fn format_chunk_string(data string) string {
if data.len == 0 {
return ''
}
return '${data.len:x}\r\n${data}\r\n'
}
// format_final_chunk - Return the final chunk marker for chunked encoding
fn format_final_chunk() string {
return '0\r\n\r\n'
}
// ============================================================================
// Random Data Generators
// ============================================================================
// Generate random bytes for testing
fn generate_random_bytes(max_len int) []u8 {
len := rand.int_in_range(1, max_len) or { 10 }
mut data := []u8{len: len}
for i in 0 .. len {
data[i] = u8(rand.int_in_range(0, 256) or { 0 })
}
return data
}
// Generate random string for testing (printable ASCII only)
fn generate_random_string(max_len int) string {
chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'
len := rand.int_in_range(1, max_len) or { 10 }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result
}
// ============================================================================
// Chunk Parsing Helper
// ============================================================================
// Parse a chunked encoded string and extract the data
fn parse_chunked_data(chunked string) !([]u8, bool) {
mut crlf_pos := -1
for i := 0; i < chunked.len - 1; i++ {
if chunked[i] == `\r` && chunked[i + 1] == `\n` {
crlf_pos = i
break
}
}
if crlf_pos == -1 {
return error('No CRLF found in chunk header')
}
size_hex := chunked[..crlf_pos]
size := parse_hex(size_hex) or {
return error('Invalid hex size: ${size_hex}')
}
data_start := crlf_pos + 2
data_end := data_start + size
if data_end > chunked.len {
return error('Chunk data incomplete')
}
data := chunked[data_start..data_end].bytes()
if data_end + 2 > chunked.len {
return error('Missing trailing CRLF')
}
trailing := chunked[data_end..data_end + 2]
if trailing != '\r\n' {
return error('Invalid trailing CRLF: got "${trailing}"')
}
return data, true
}
// Parse hex string to int
fn parse_hex(hex_str string) !int {
mut result := 0
for c in hex_str {
result *= 16
if c >= `0` && c <= `9` {
result += int(c - `0`)
} else if c >= `a` && c <= `f` {
result += int(c - `a` + 10)
} else if c >= `A` && c <= `F` {
result += int(c - `A` + 10)
} else {
return error('Invalid hex character: ${c.ascii_str()}')
}
}
return result
}
// ============================================================================
// Mock Socket for Testing UsocketsStreamWriter
// ============================================================================
// MockSocket - A mock socket that captures written data for testing
struct MockSocket {
mut:
buffer string
is_closed bool
}
fn MockSocket.new() MockSocket {
return MockSocket{
buffer: ''
is_closed: false
}
}
fn (mut s MockSocket) write_bytes(data string) int {
if s.is_closed {
return 0
}
s.buffer += data
return data.len
}
fn (s MockSocket) get_buffer() string {
return s.buffer
}
fn (mut s MockSocket) clear() {
s.buffer = ''
}
// ============================================================================
// UsocketsStreamWriter Simulation for Testing
// Uses the same chunked encoding logic as the real implementation
// ============================================================================
struct TestUsocketsStreamWriter {
mut:
socket &MockSocket
connected bool
}
fn TestUsocketsStreamWriter.new(socket &MockSocket) TestUsocketsStreamWriter {
return TestUsocketsStreamWriter{
socket: unsafe { socket }
connected: true
}
}
fn (mut w TestUsocketsStreamWriter) write(data []u8) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
chunk_header := format_chunk_size(data.len)
w.socket.write_bytes(chunk_header)
w.socket.write_bytes(data.bytestr())
w.socket.write_bytes('\r\n')
}
fn (mut w TestUsocketsStreamWriter) write_string(data string) ! {
if !w.connected {
return error('Connection closed')
}
if data.len == 0 {
return
}
chunk_header := format_chunk_size(data.len)
w.socket.write_bytes(chunk_header)
w.socket.write_bytes(data)
w.socket.write_bytes('\r\n')
}
fn (mut w TestUsocketsStreamWriter) close() ! {
if w.connected {
w.socket.write_bytes('0\r\n\r\n')
w.connected = false
}
}
fn (w TestUsocketsStreamWriter) is_connected() bool {
return w.connected
}
// ============================================================================
// Property 10: Chunked 编码格式正确性
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
// Validates: Requirements 7.2
//
// *For any* data written to the stream, the output SHALL follow HTTP chunked
// transfer encoding format:
// - Each chunk starts with the hex size followed by \r\n
// - Chunk data followed by \r\n
// - Final chunk is 0\r\n\r\n
// ============================================================================
// Test 10a: UsocketsStreamWriter.write produces valid chunked encoding for bytes
fn test_property_10a_usockets_write_bytes() bool {
rand.seed([u32(time.now().unix()), u32(12345)])
for i in 0 .. test_iterations {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Generate random data
data := generate_random_bytes(1000)
// Write using the writer
writer.write(data) or {
println(' Iteration ${i}: Write failed: ${err}')
return false
}
// Get the output
output := mock_socket.get_buffer()
// Verify chunk format
mut crlf_pos := -1
for j := 0; j < output.len - 1; j++ {
if output[j] == `\r` && output[j + 1] == `\n` {
crlf_pos = j
break
}
}
if crlf_pos == -1 {
println(' Iteration ${i}: No CRLF found in chunk header')
return false
}
size_hex := output[..crlf_pos]
parsed_size := parse_hex(size_hex) or {
println(' Iteration ${i}: Invalid hex size: ${size_hex}')
return false
}
if parsed_size != data.len {
println(' Iteration ${i}: Size mismatch - expected ${data.len}, got ${parsed_size}')
return false
}
// Extract and verify data
extracted_data, _ := parse_chunked_data(output) or {
println(' Iteration ${i}: Failed to parse chunk: ${err}')
return false
}
if extracted_data != data {
println(' Iteration ${i}: Data mismatch after extraction')
return false
}
}
return true
}
// Test 10b: UsocketsStreamWriter.write_string produces valid chunked encoding
fn test_property_10b_usockets_write_string() bool {
rand.seed([u32(time.now().unix()), u32(54321)])
for i in 0 .. test_iterations {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Generate random string
data := generate_random_string(500)
// Write using the writer
writer.write_string(data) or {
println(' Iteration ${i}: Write failed: ${err}')
return false
}
// Get the output
output := mock_socket.get_buffer()
// Verify chunk format
mut crlf_pos := -1
for j := 0; j < output.len - 1; j++ {
if output[j] == `\r` && output[j + 1] == `\n` {
crlf_pos = j
break
}
}
if crlf_pos == -1 {
println(' Iteration ${i}: No CRLF found in chunk header')
return false
}
size_hex := output[..crlf_pos]
parsed_size := parse_hex(size_hex) or {
println(' Iteration ${i}: Invalid hex size: ${size_hex}')
return false
}
if parsed_size != data.len {
println(' Iteration ${i}: Size mismatch - expected ${data.len}, got ${parsed_size}')
return false
}
// Extract and verify data
extracted_data, _ := parse_chunked_data(output) or {
println(' Iteration ${i}: Failed to parse chunk: ${err}')
return false
}
if extracted_data.bytestr() != data {
println(' Iteration ${i}: String mismatch after extraction')
return false
}
}
return true
}
// Test 10c: UsocketsStreamWriter.close produces correct final chunk
fn test_property_10c_usockets_close_final_chunk() bool {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Close the writer
writer.close() or {
println(' Close failed: ${err}')
return false
}
// Get the output
output := mock_socket.get_buffer()
// Final chunk must be exactly "0\r\n\r\n"
expected := '0\r\n\r\n'
if output != expected {
println(' Final chunk mismatch - expected "${expected}", got "${output}"')
return false
}
// Verify writer is now disconnected
if writer.is_connected() {
println(' Writer should be disconnected after close')
return false
}
return true
}
// Test 10d: UsocketsStreamWriter handles empty data correctly
fn test_property_10d_usockets_empty_data() bool {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Write empty byte array
writer.write([]u8{}) or {
println(' Empty write failed: ${err}')
return false
}
// Buffer should be empty (no chunk for empty data)
if mock_socket.get_buffer() != '' {
println(' Empty bytes should produce no output, got "${mock_socket.get_buffer()}"')
return false
}
// Write empty string
writer.write_string('') or {
println(' Empty string write failed: ${err}')
return false
}
// Buffer should still be empty
if mock_socket.get_buffer() != '' {
println(' Empty string should produce no output, got "${mock_socket.get_buffer()}"')
return false
}
return true
}
// Test 10e: UsocketsStreamWriter rejects writes after close
fn test_property_10e_usockets_write_after_close() bool {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Close the writer
writer.close() or {
println(' Close failed: ${err}')
return false
}
mock_socket.clear()
// Try to write after close - should fail
writer.write([u8(1), 2, 3]) or {
// Expected error
return true
}
println(' Write after close should have failed')
return false
}
// Test 10f: UsocketsStreamWriter multiple writes produce valid chunks
fn test_property_10f_usockets_multiple_writes() bool {
rand.seed([u32(time.now().unix()), u32(99999)])
for i in 0 .. test_iterations {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Generate multiple random data chunks
num_chunks := rand.int_in_range(2, 10) or { 3 }
mut all_data := [][]u8{}
for _ in 0 .. num_chunks {
data := generate_random_bytes(100)
all_data << data
writer.write(data) or {
println(' Iteration ${i}: Write failed: ${err}')
return false
}
}
// Close to add final chunk
writer.close() or {
println(' Iteration ${i}: Close failed: ${err}')
return false
}
// Parse all chunks from output
output := mock_socket.get_buffer()
mut pos := 0
mut chunk_idx := 0
for pos < output.len && chunk_idx < all_data.len {
// Find CRLF for size
mut crlf_pos := -1
for j := pos; j < output.len - 1; j++ {
if output[j] == `\r` && output[j + 1] == `\n` {
crlf_pos = j
break
}
}
if crlf_pos == -1 {
println(' Iteration ${i}: No CRLF found at chunk ${chunk_idx}')
return false
}
size_hex := output[pos..crlf_pos]
size := parse_hex(size_hex) or {
println(' Iteration ${i}: Invalid hex at chunk ${chunk_idx}')
return false
}
// Check for final chunk
if size == 0 {
break
}
// Verify size matches expected
if size != all_data[chunk_idx].len {
println(' Iteration ${i}: Size mismatch at chunk ${chunk_idx}')
return false
}
// Extract data
data_start := crlf_pos + 2
data_end := data_start + size
extracted := output[data_start..data_end].bytes()
if extracted != all_data[chunk_idx] {
println(' Iteration ${i}: Data mismatch at chunk ${chunk_idx}')
return false
}
// Move to next chunk (skip trailing CRLF)
pos = data_end + 2
chunk_idx++
}
// Verify all chunks were found
if chunk_idx != all_data.len {
println(' Iteration ${i}: Expected ${all_data.len} chunks, found ${chunk_idx}')
return false
}
// Verify final chunk exists
if !output.ends_with('0\r\n\r\n') {
println(' Iteration ${i}: Missing final chunk')
return false
}
}
return true
}
// Test 10g: Round-trip property for UsocketsStreamWriter
fn test_property_10g_usockets_roundtrip() bool {
rand.seed([u32(time.now().unix()), u32(77777)])
for i in 0 .. test_iterations {
mut mock_socket := MockSocket.new()
mut writer := TestUsocketsStreamWriter.new(&mock_socket)
// Generate random data
original := generate_random_bytes(500)
// Write using the writer
writer.write(original) or {
println(' Iteration ${i}: Write failed: ${err}')
return false
}
// Parse back
output := mock_socket.get_buffer()
recovered, _ := parse_chunked_data(output) or {
println(' Iteration ${i}: Failed to parse: ${err}')
return false
}
// Verify round-trip
if recovered != original {
println(' Iteration ${i}: Round-trip failed - data mismatch')
return false
}
}
return true
}
fn main() {
println('🚀 开始 UsocketsStreamWriter 属性测试...')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
mut stats := PropertyTestStats{}
// Run Property 10 tests for UsocketsStreamWriter
// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
// Validates: Requirements 7.2
stats.run_property_test('Property 10a: UsocketsStreamWriter.write bytes encoding', test_property_10a_usockets_write_bytes)
stats.run_property_test('Property 10b: UsocketsStreamWriter.write_string encoding', test_property_10b_usockets_write_string)
stats.run_property_test('Property 10c: UsocketsStreamWriter.close final chunk', test_property_10c_usockets_close_final_chunk)
stats.run_property_test('Property 10d: UsocketsStreamWriter empty data handling', test_property_10d_usockets_empty_data)
stats.run_property_test('Property 10e: UsocketsStreamWriter write after close', test_property_10e_usockets_write_after_close)
stats.run_property_test('Property 10f: UsocketsStreamWriter multiple writes', test_property_10f_usockets_multiple_writes)
stats.run_property_test('Property 10g: UsocketsStreamWriter round-trip', test_property_10g_usockets_roundtrip)
// Print summary
stats.print_summary()
}
+93 -26
View File
@@ -1,13 +1,36 @@
#!/bin/bash
# uSockets 编译脚本
# 用于重新编译 libusockets_full.a
# 用于重新编译 libusockets_full.a (包含 uSockets + libuv)
# 支持: Windows (Git Bash/MSYS2), macOS (Intel/Apple Silicon), Linux
# 输出到: usockets/lib/{platform}/libusockets_full.a
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
USOCKETS_DIR="/tmp/uSockets"
LIBUV_DIR="/tmp/libuv"
MERGE_DIR="/tmp/merge_libs"
echo "=== uSockets 编译脚本 ==="
echo "检测到系统: $OSTYPE"
# 确定输出目录
if [[ "$OSTYPE" == "darwin"* ]]; then
if [[ $(uname -m) == "arm64" ]]; then
OUTPUT_DIR="$SCRIPT_DIR/lib/macos-arm64"
else
OUTPUT_DIR="$SCRIPT_DIR/lib/macos-x64"
fi
elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "mingw"* ]]; then
OUTPUT_DIR="$SCRIPT_DIR/lib/windows"
elif [[ "$OSTYPE" == "linux"* ]]; then
OUTPUT_DIR="$SCRIPT_DIR/lib/linux"
else
OUTPUT_DIR="$SCRIPT_DIR/lib/linux"
fi
echo "输出目录: $OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
# 检查是否有 uSockets 源码
if [ ! -d "$USOCKETS_DIR" ]; then
@@ -15,41 +38,85 @@ if [ ! -d "$USOCKETS_DIR" ]; then
git clone --depth 1 https://github.com/uNetworking/uSockets.git "$USOCKETS_DIR"
fi
# 检查是否有 libuv 源码
if [ ! -d "$LIBUV_DIR" ]; then
echo "克隆 libuv..."
git clone --depth 1 --branch v1.48.0 https://github.com/libuv/libuv.git "$LIBUV_DIR"
fi
# 应用 backlog 修改
echo "应用 backlog=16384 修改..."
if [ -f "$SCRIPT_DIR/src/bsd.c" ]; then
cp "$SCRIPT_DIR/src/bsd.c" "$USOCKETS_DIR/src/bsd.c"
else
# 如果没有本地修改的源码,直接修改
sed -i.bak 's/listen(listenFd, 512)/listen(listenFd, 16384)/g' "$USOCKETS_DIR/src/bsd.c"
fi
# 编译
echo "编译 uSockets..."
cd "$USOCKETS_DIR"
make clean
# 编译 libuv
compile_libuv() {
echo "编译 libuv..."
cd "$LIBUV_DIR"
rm -rf build 2>/dev/null || true
mkdir -p build
cd build
# 根据系统选择编译参数
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
if [[ $(uname -m) == "arm64" ]]; then
# Apple Silicon
WITH_LIBUV=1 CFLAGS="-I/opt/homebrew/include" make
else
# Intel Mac
WITH_LIBUV=1 CFLAGS="-I/usr/local/include" make
if ! command -v cmake &> /dev/null; then
echo "错误: 需要安装 cmake"
echo " macOS: brew install cmake"
echo " Linux: sudo apt install cmake 或 sudo yum install cmake"
echo " Windows: scoop install cmake 或 choco install cmake"
exit 1
fi
else
# Linux
WITH_LIBUV=1 make
fi
# 复制到 v-hono
echo "复制库文件..."
cp "$USOCKETS_DIR/uSockets.a" "$SCRIPT_DIR/../lib/libusockets_full.a"
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "mingw"* ]]; then
cmake .. -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DLIBUV_BUILD_SHARED=OFF
else
cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DLIBUV_BUILD_SHARED=OFF
fi
echo "=== 编译完成 ==="
echo "库文件: $SCRIPT_DIR/../lib/libusockets_full.a"
cmake --build . --config Release
}
# 编译 uSockets
compile_usockets() {
echo "编译 uSockets..."
cd "$USOCKETS_DIR"
rm -f *.o *.a 2>/dev/null || true
gcc -DLIBUS_NO_SSL -DLIBUS_USE_LIBUV -std=c11 -Isrc -I"$LIBUV_DIR/include" -O3 -c src/*.c src/eventing/*.c src/crypto/*.c
ar rcs uSockets.a *.o
rm -f *.o
}
# 合并库文件
merge_libraries() {
echo "合并库文件..."
rm -rf "$MERGE_DIR"
mkdir -p "$MERGE_DIR"
cd "$MERGE_DIR"
ar x "$USOCKETS_DIR/uSockets.a"
ar x "$LIBUV_DIR/build/libuv.a"
ar rcs "$OUTPUT_DIR/libusockets_full.a" *.o
rm -rf "$MERGE_DIR"
}
# 执行编译
compile_libuv
compile_usockets
merge_libraries
# 显示结果
LIB_SIZE=$(ls -lh "$OUTPUT_DIR/libusockets_full.a" | awk '{print $5}')
echo ""
echo "重新编译 v-hono 应用:"
echo " v -enable-globals -prod -o your_app your_app.v"
echo "=== 编译完成 ==="
echo "库文件: $OUTPUT_DIR/libusockets_full.a ($LIB_SIZE)"
echo ""
echo "编译 v-hono 应用:"
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "mingw"* ]]; then
echo " v -enable-globals -cc gcc -ldflags \"-ldbghelp\" -o app.exe app.v"
else
echo " v -enable-globals -prod -o app app.v"
fi
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16 -23
View File
@@ -2,41 +2,36 @@ module usockets
// 条件编译:根据操作系统选择正确的路径和库
// @VMODROOT 指向包含 v.mod 的模块根目录 (v-hono/)
// 预编译库按平台存放在 usockets/lib/{platform}/ 目录
$if windows {
#flag -DLIBUS_USE_LIBUV
#flag -DLIBUS_NO_SSL
#flag -I@VMODROOT/usockets/include
#flag -L@VMODROOT/lib
#flag @VMODROOT/lib/libusockets_full.a
#flag -lws2_32 -liphlpapi -lpsapi -luserenv -lole32
#flag -L@VMODROOT/usockets/lib/windows
#flag @VMODROOT/usockets/lib/windows/libusockets_full.a
#flag -lws2_32 -liphlpapi -lpsapi -luserenv -lole32 -ldbghelp
} $else $if macos {
// macOS: 支持 Apple Silicon (M1/M2/M3) 和 Intel Mac
#flag -DLIBUS_USE_LIBUV
#flag -DLIBUS_NO_SSL
// libuv 路径 - 根据架构选择 Homebrew 路径
$if arm64 {
#flag -I/opt/homebrew/include
#flag -L/opt/homebrew/lib
} $else {
#flag -I/usr/local/include
#flag -L/usr/local/lib
}
// uSockets 库路径
#flag -I@VMODROOT/usockets/include
#flag -L@VMODROOT/lib
#flag @VMODROOT/lib/libusockets_full.a
#flag -luv
$if arm64 {
// Apple Silicon (M1/M2/M3)
#flag -L@VMODROOT/usockets/lib/macos-arm64
#flag @VMODROOT/usockets/lib/macos-arm64/libusockets_full.a
} $else {
// Intel Mac
#flag -L@VMODROOT/usockets/lib/macos-x64
#flag @VMODROOT/usockets/lib/macos-x64/libusockets_full.a
}
} $else {
// Linux 和其他平台
#flag -DLIBUS_USE_LIBUV
#flag -DLIBUS_NO_SSL
#flag -I@VMODROOT/usockets/include
#flag -L@VMODROOT/lib
#flag @VMODROOT/lib/libusockets_full.a
#flag -luv
#flag -L@VMODROOT/usockets/lib/linux
#flag @VMODROOT/usockets/lib/linux/libusockets_full.a
}
#include "libusockets.h"
@@ -105,8 +100,6 @@ pub fn (ctx SocketContext) on_writable(h voidptr) { C.us_socket_context_on_writa
pub fn (ctx SocketContext) on_timeout(h voidptr) { C.us_socket_context_on_timeout(0, ctx, h) }
pub fn (ctx SocketContext) on_end(h voidptr) { C.us_socket_context_on_end(0, ctx, h) }
pub fn (ctx SocketContext) listen(port int) ListenSocket {
// 参数: ssl=0, context, host=nil, port, options=LIBUS_LISTEN_DEFAULT(0), socket_ext_size=0
// 注意: backlog 是在 uSockets 库内部硬编码的,不通过此函数传递
return C.us_socket_context_listen(0, ctx, unsafe { nil }, port, 0, 0)
}
+101
View File
@@ -577,6 +577,13 @@ fn exec_middlewares_usockets(idx int, middlewares []ContextMiddleware, mut ctx C
// 发送 uSockets 响应 - 优化版
fn send_usockets_response(s usockets.Socket, ctx Context, response http.Response) {
// Check if this is a streaming response
if is_streaming_response(ctx) {
// Handle streaming response
handle_usockets_streaming_response(s, ctx)
return
}
status_code := if ctx.status_code != 0 { ctx.status_code } else { response.status_code }
mut resp := strings.new_builder(512)
@@ -633,6 +640,100 @@ fn send_usockets_response(s usockets.Socket, ctx Context, response http.Response
s.write_bytes(resp.str())
}
// ============================================================================
// Streaming Response Handling for uSockets
// ============================================================================
// handle_usockets_streaming_response - Handle a streaming response using UsocketsStreamWriter
// This function:
// 1. Sends the HTTP headers for streaming
// 2. Creates a UsocketsStreamWriter
// 3. Executes the streaming callback
// 4. Handles errors and cleanup
//
// Requirements: 7.1, 7.2, 7.3, 7.4
fn handle_usockets_streaming_response(s usockets.Socket, ctx Context) {
// Get stream configuration
stream_config := get_stream_config(ctx) or {
// Fallback to error response if stream config not found
s.write_bytes('HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\nContent-Length: 32\r\n\r\nStream configuration not found')
return
}
// Determine status code
status_code := if ctx.status_code != 0 { ctx.status_code } else { 200 }
// Build HTTP response headers
mut resp := strings.new_builder(512)
// Status line
resp.write_string('HTTP/1.1 ')
resp.write_string(status_code.str())
resp.write_string(' ')
resp.write_string(get_status_text_usockets(status_code))
resp.write_string('\r\n')
// Send headers from context
mut has_transfer_encoding := false
mut has_connection := false
for key, value in ctx.headers {
key_len := key.len
// Skip Content-Length for streaming (we use chunked encoding)
if key_len == 14 && eq_ignore_case_usockets(key, 'content-length') {
continue
}
resp.write_string(key)
resp.write_string(': ')
resp.write_string(value)
resp.write_string('\r\n')
if key_len == 17 && eq_ignore_case_usockets(key, 'transfer-encoding') {
has_transfer_encoding = true
} else if key_len == 10 && eq_ignore_case_usockets(key, 'connection') {
has_connection = true
}
}
// Ensure Transfer-Encoding: chunked is set (Requirement 7.2)
if !has_transfer_encoding {
resp.write_string('Transfer-Encoding: chunked\r\n')
}
// Ensure Connection header is set for streaming
if !has_connection {
resp.write_string('Connection: keep-alive\r\n')
}
// End headers section
resp.write_string('\r\n')
// Send headers to client
s.write_bytes(resp.str())
// Create UsocketsStreamWriter for streaming (Requirement 7.1)
mut writer := UsocketsStreamWriter.new(s)
// Create StreamContext with the writer
mut stream_ctx := StreamContext.new(writer)
// Execute the streaming callback (Requirement 7.4 - compatibility with callback architecture)
stream_config.callback(mut stream_ctx) or {
// Handle error (Requirement 7.3 - handle connection close events)
if error_handler := stream_config.error_handler {
error_handler(err, mut stream_ctx)
} else {
// Default: log error to console
eprintln('[uSockets Stream Error] ${err.msg()}')
}
}
// Auto-close the stream when callback completes
stream_ctx.close()
// Cleanup the stored configuration
cleanup_stream_config(ctx)
}
// 大小写不敏感字符串比较(避免分配)
@[inline]
fn eq_ignore_case_usockets(a string, b string) bool {
+1 -1
View File
@@ -1,7 +1,7 @@
Module {
name: 'hono'
description: 'A high-performance V language web framework inspired by Hono.js, featuring hybrid routing, LRU cache, middleware support, and more.'
version: '0.6.0'
version: '0.7.6'
license: 'MIT'
dependencies: []
}