Fix SSE stream closing issue and improve file monitoring

- Add periodic heartbeats (every 15 seconds) to keep SSE connections alive
- Enhance response headers to prevent proxy buffering (X-Accel-Buffering: no)
- Fix monitorFileChanges to read entire file from beginning like tail -f
- Process lines synchronously to maintain order
- Send initial connection message to establish stream immediately
- Remove empty line filtering to match tail -f behavior exactly

This ensures streams stay open indefinitely and don't timeout due to inactivity.
This commit is contained in:
Peter Steinberger 2025-06-16 07:45:02 +02:00
parent aae68479ee
commit ac13030b52
2 changed files with 52 additions and 36 deletions

View file

@ -768,8 +768,10 @@ public final class TunnelServer {
// Create SSE response with proper headers
let headers: HTTPFields = [
.contentType: "text/event-stream",
.cacheControl: "no-cache",
.connection: "keep-alive"
.cacheControl: "no-cache, no-store, must-revalidate",
.connection: "keep-alive",
.init("X-Accel-Buffering")!: "no", // Disable proxy buffering
.init("Access-Control-Allow-Origin")!: "*"
]
// Create async sequence for streaming
@ -806,6 +808,11 @@ public final class TunnelServer {
fileMonitor?.cancel()
}
// Send initial connection established message
var initialMessage = ByteBuffer()
initialMessage.writeString(": connected\n\n")
continuation.yield(initialMessage)
// Send existing content first
do {
let content = try String(contentsOfFile: streamOutPath, encoding: .utf8)
@ -866,11 +873,21 @@ public final class TunnelServer {
continuation: continuation
)
// Wait for cancellation
// Keep the stream open until cancelled with periodic heartbeats
await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
// This will suspend until cancelled
continuation.resume()
// Send heartbeat every 15 seconds to keep connection alive
while !Task.isCancelled {
do {
try await Task.sleep(nanoseconds: 15_000_000_000) // 15 seconds
// Send SSE comment as heartbeat (comments start with ':')
var heartbeat = ByteBuffer()
heartbeat.writeString(": heartbeat\n\n")
continuation.yield(heartbeat)
} catch {
// Task was cancelled
break
}
}
} onCancel: { [fileMonitor] in
fileMonitor?.cancel()
@ -894,41 +911,41 @@ public final class TunnelServer {
// Store buffer for incomplete lines
var lineBuffer = ""
// Read existing file content first
// Read entire file content from the beginning
let fileSize = lseek(fileDescriptor, 0, SEEK_END)
if fileSize > 0 {
// Read the entire file (or last portion if very large)
let maxInitialRead: Int64 = 1024 * 1024 // 1MB max initial read
let readSize = min(fileSize, maxInitialRead)
let startOffset = max(0, fileSize - readSize)
// Seek to beginning
lseek(fileDescriptor, 0, SEEK_SET)
lseek(fileDescriptor, startOffset, SEEK_SET)
let buffer = UnsafeMutablePointer<CChar>.allocate(capacity: Int(readSize) + 1)
// Read entire file content
let buffer = UnsafeMutablePointer<CChar>.allocate(capacity: Int(fileSize) + 1)
defer { buffer.deallocate() }
let bytesRead = read(fileDescriptor, buffer, Int(readSize))
if bytesRead > 0 {
let data = Data(bytes: buffer, count: bytesRead)
var totalBytesRead = 0
while totalBytesRead < fileSize {
let bytesRead = read(fileDescriptor, buffer + totalBytesRead, Int(fileSize) - totalBytesRead)
if bytesRead <= 0 { break }
totalBytesRead += bytesRead
}
if totalBytesRead > 0 {
let data = Data(bytes: buffer, count: totalBytesRead)
if let initialContent = String(data: data, encoding: .utf8) {
lineBuffer = initialContent
let lines = lineBuffer.components(separatedBy: .newlines)
// Process all complete lines from existing content
if lines.count > 1 {
for i in 0..<(lines.count - 1) {
let line = lines[i]
Task { @MainActor in
await self.processNewLine(
line: line,
startTime: startTime,
continuation: continuation
)
}
}
// Keep the last incomplete line in buffer
lineBuffer = lines.last ?? ""
// Process all complete lines synchronously to maintain order
for i in 0..<lines.count - 1 {
let line = lines[i]
await processNewLine(
line: line,
startTime: startTime,
continuation: continuation
)
}
// Keep the last incomplete line in buffer
lineBuffer = lines.last ?? ""
}
}
}
@ -978,11 +995,11 @@ public final class TunnelServer {
lineBuffer += contentString
let lines = lineBuffer.components(separatedBy: .newlines)
// Process all complete lines
// Process all complete lines synchronously to maintain order
if lines.count > 1 {
for i in 0..<(lines.count - 1) {
let line = lines[i]
Task { @MainActor in
Task { @MainActor in
for i in 0..<(lines.count - 1) {
let line = lines[i]
await self.processNewLine(
line: line,
startTime: startTime,
@ -1011,7 +1028,6 @@ public final class TunnelServer {
continuation: AsyncStream<ByteBuffer>.Continuation
) async {
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
guard !trimmedLine.isEmpty else { return }
if let data = trimmedLine.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data) {