gighiveDocument Date: October 8, 2025
Status: ✅ PRODUCTION - Direct Streaming Implemented
Last Updated: October 8, 2025
Original Issue (October 7, 2025):
Root Cause: The original two-layer upload approach:
uploadTask(fromFile:)This caused iOS watchdog termination after prolonged disk I/O operations.
Implementation: Custom InputStream that builds multipart body on-the-fly and streams directly to network.
Results:
Current Performance (October 8, 2025):
| File Size | Photos Copy | Upload Start | Total Wait |
|---|---|---|---|
| 177MB | ~1 minute | Immediate | ~1 minute |
| 1.69GB | ~10 minutes | Immediate | ~10 minutes |
| 4.71GB | ~18 minutes | Immediate | ~18 minutes |
Note: The Photos copy time is an unavoidable iOS limitation (see Photos Library Limitations).
User selects video from Photos
↓
PHPickerViewController copies to temp storage (unavoidable iOS requirement)
↓
User fills out form and presses Upload
↓
MultipartInputStream created
↓
Streams file directly to network (no temp multipart file)
↓
URLSession sends chunks and reports progress
↓
Server receives and processes upload
PickerBridges.swift)PHPickerView - For Photos library videos:
// PHPicker provides temporary URL that expires
provider.loadFileRepresentation(forTypeIdentifier: typeId) { url, _ in
// MUST copy before callback returns
try FileManager.default.copyItem(at: url, to: tmp)
// Copy time: ~1 min per 1 min of video on iPhone 12
}
DocumentPickerView - For Files app:
// Files app already provides stable URL
// Still copies for consistency and security-scoped access
Why copying is necessary:
MultipartInputStream.swift)Custom InputStream subclass that:
uploadTask(withStreamedRequest:)Key Features:
InputStream abstract methodsPhase Management:
enum Phase {
case header // Multipart form fields and file header
case fileContent // Actual file data (streamed in chunks)
case footer // Closing boundary
case complete // Stream exhausted
}
NetworkProgressUploadClient.swift)Responsibilities:
MultipartInputStream with form dataURLRequest with multipart boundaryneedNewBodyStream delegatedidSendBodyDataCritical Implementation Detail:
// WRONG - uploadTask(withStreamedRequest:) doesn't accept httpBodyStream
request.httpBodyStream = stream // ❌ Causes error
// CORRECT - Provide stream via delegate
private var currentInputStream: MultipartInputStream?
self.currentInputStream = stream
let task = session.uploadTask(withStreamedRequest: request)
// Delegate provides stream when URLSession requests it
func urlSession(_ session: URLSession, task: URLSessionTask,
needNewBodyStream completionHandler: @escaping (InputStream?) -> Void) {
completionHandler(currentInputStream)
}
UploadView.swift)State Management:
isLoadingMedia - Shows “Loading media metadata…” during file copyloadedFileSize - Displays file size when readyisUploading - Controls upload button statedebugLog - Shows progress percentages in debug viewProgress Display:
Time: 8:00 PM - 8:30 PM
Created:
MultipartInputStream.swift - Custom InputStream for on-the-fly multipart generationNetworkProgressUploadClient.swift.backup-direct-streamingModified:
NetworkProgressUploadClient.swift - Replaced Layer 1 assembly with direct streamingInitial Approach (Failed):
request.httpBodyStream = stream // ❌ Error: Can't set body stream
Error:
The request of a upload task should not contain a body or a body stream,
use 'upload(for:fromFile:)', 'upload(for:from:)', or supply the body
stream through the 'urlSession(_:needNewBodyStreamForTask:)' delegate method.
Time: 8:30 PM - 9:00 PM
Solution: Provide stream via needNewBodyStream delegate method
Changes:
currentInputStream property to store streamneedNewBodyStream delegate methodInsecureTrustUploadDelegate to forward stream requestsTime: 9:00 PM - 9:30 PM
Errors Encountered:
**** -streamStatus only defined for abstract class
**** -setDelegate: only defined for abstract class
Solution: Implemented all required InputStream abstract members:
streamStatus propertystreamError propertydelegate propertyschedule(in:forMode:) methodremove(from:forMode:) methodproperty(forKey:) methodsetProperty(_:forKey:) methodTime: 9:30 PM - 10:00 PM
Problem: HTTP 413 “Payload Too Large” errors
Root Cause: Field name mismatch
$_FILES['media']$_FILES['file']Solution:
// Changed from:
fileFieldName: "media"
// To:
fileFieldName: "file"
Time: 10:00 PM - 10:30 PM
Problem: Still getting 413 errors after field name fix
Root Cause: ModSecurity WAF limit
Solution: Updated /etc/modsecurity/modsecurity.conf:
# Changed from:
SecRequestBodyNoFilesLimit 131072 # 128KB
# To:
SecRequestBodyNoFilesLimit 5368709120 # 5GB
Note: PHP limits were already correct at 5000M.
Time: 10:30 PM
Test Results:
Time: 10:00 PM - 10:30 PM
Status: Rolled back, saved to patches/estimated-load-time.patch
Goal: Show estimated load time based on video duration (1:1 ratio on iPhone 12)
Implementation:
PHAsset.duration to get video lengthWhy Rolled Back:
class MultipartInputStream: InputStream {
// MARK: - Properties
private let fileURL: URL
private let boundary: String
private let formFields: [(name: String, value: String)]
private let fileFieldName: String
private let fileName: String
private let mimeType: String
private var currentPhase: Phase = .header
private var fileHandle: FileHandle?
private var headerData: Data?
private var footerData: Data?
private var headerOffset = 0
private var footerOffset = 0
private var totalBytesRead: Int64 = 0
private var fileSize: Int64 = 0
private enum Phase {
case header
case fileContent
case footer
case complete
}
}
read(_:maxLength:) - Core streaming logic:
override func read(_ buffer: UnsafeMutablePointer<UInt8>, maxLength: Int) -> Int {
var bytesWritten = 0
while bytesWritten < maxLength && currentPhase != .complete {
switch currentPhase {
case .header:
bytesWritten += readHeader(...)
case .fileContent:
bytesWritten += readFileContent(...)
case .footer:
bytesWritten += readFooter(...)
case .complete:
break
}
}
return bytesWritten
}
contentLength() - Calculate total size upfront:
func contentLength() -> Int64 {
let headerSize = Int64(headerData?.count ?? 0)
let footerSize = Int64(footerData?.count ?? 0)
return headerSize + fileSize + footerSize
}
Required Abstract Methods:
override var streamStatus: Stream.Status {
if currentPhase == .complete {
return .atEnd
} else if fileHandle != nil {
return .open
} else {
return .notOpen
}
}
override var streamError: Error? { return nil }
override var delegate: StreamDelegate? { get/set }
override func schedule(in:forMode:) { }
override func remove(from:forMode:) { }
override func property(forKey:) -> Any? { return nil }
override func setProperty(_:forKey:) -> Bool { return false }
func uploadFile(
payload: UploadPayload,
progressHandler: @escaping (Int64, Int64) -> Void,
completion: @escaping (Result<(status: Int, data: Data, requestURL: URL), Error>) -> Void
) {
// 1. Build URL with query parameters
let apiURL = baseURL.appendingPathComponent("api/uploads.php")
var components = URLComponents(url: apiURL, resolvingAgainstBaseURL: false)
components?.queryItems = [URLQueryItem(name: "ui", value: "json")]
// 2. Create request with auth
var request = URLRequest(url: finalURL)
request.httpMethod = "POST"
request.setValue("Basic \(encodedCredentials)", forHTTPHeaderField: "Authorization")
// 3. Set multipart boundary
let boundary = "Boundary-\(UUID().uuidString)"
request.setValue("multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type")
// 4. Create multipart stream
let stream = try MultipartInputStream(
fileURL: payload.fileURL,
boundary: boundary,
formFields: formFields,
fileFieldName: "file", // MUST match PHP $_FILES key
fileName: fileName,
mimeType: mimeType
)
// 5. Set Content-Length
request.setValue(String(stream.contentLength()),
forHTTPHeaderField: "Content-Length")
// 6. Store stream for delegate
self.currentInputStream = stream
// 7. Create and start upload task
let task = session.uploadTask(withStreamedRequest: request)
self.currentUploadTask = task
task.resume()
}
func urlSession(_ session: URLSession, task: URLSessionTask,
didSendBodyData bytesSent: Int64, totalBytesSent: Int64,
totalBytesExpectedToSend: Int64) {
// This fires as chunks are sent over the network
print("📊 Progress: \(totalBytesSent)/\(totalBytesExpectedToSend) bytes")
DispatchQueue.main.async {
self.progressHandler?(totalBytesSent, totalBytesExpectedToSend)
}
}
File: /etc/php/8.1/fpm/php.ini
upload_max_filesize = 5000M
post_max_size = 5000M
max_execution_time = 7200
max_input_time = 7200
memory_limit = 512M
File: /etc/modsecurity/modsecurity.conf
SecRequestBodyLimit 5368709120 # 5GB total body
SecRequestBodyNoFilesLimit 5368709120 # 5GB for non-file data (CRITICAL!)
Note: SecRequestBodyNoFilesLimit was the hidden culprit causing 413 errors. Default is 128KB.
File: api/uploads.php → UploadController.php
public function post(array $files, array $post): array
{
// Check for 'file' field (not 'media')
if (empty($files) || !isset($files['file'])) {
return [
'status' => 413,
'body' => [
'error' => 'Payload Too Large',
'message' => 'Upload exceeded server limits...'
]
];
}
// Process upload
$result = $this->service->handleUpload($files, $post);
return ['status' => 201, 'body' => $result];
}
Technical Reason:
PHPickerViewController provides temporary URLs that are only valid during the callback. iOS requires apps to copy the file before the callback returns.
From Apple’s Documentation:
“In the completion handler, copy or move the file at the provided URL to a location you control. This must complete before the completion handler returns.”
Observed Copy Times (iPhone 12):
Why This Happens:
What it is: Use PHAsset with full library permissions
Why we don’t:
loadInPlaceFileRepresentationWhat it is: PHPicker API supposed to avoid copying
Why we don’t:
Evidence: Apple Developer Forums thread #678234, multiple Stack Overflow reports
What it is: Users export to Files first, then select
Why we don’t:
Benefits of PHPicker:
Performance Comparison:
| Approach | Wait Time | File Size | Privacy |
|---|---|---|---|
| PHPicker (current) | ~18 min | 4.7GB (HEVC) | ✅ Excellent |
| Full Photos Access | ~0 min | 4.7GB (HEVC) | ❌ Poor |
| Files App | ~0 min | 11.83GB (H.264) | ✅ Good |
Our Choice: PHPicker with 18-minute wait is better than:
Current Message:
"Loading media metadata…please wait until file size is displayed."
Recommended Messaging:
"When selecting large videos from Photos, there may be a brief wait
while the file is prepared. This is a one-time process required by
iOS for privacy and security."
Avoid:
Status: Implemented and rolled back on October 7, 2025
Location: patches/estimated-load-time.patch
What it does:
PHAssetWhy rolled back:
To implement:
patches/estimated-load-time.patchPickerBridges.swift and UploadView.swiftStatus: No longer needed - Layer 1 eliminated!
Original Problem:
Original Solution Attempts:
ENUM_STATE_SOLUTION.md)Current Status:
Files for Reference:
LAYER1_PROGRESS_TODO.md - Original problem statementENUM_STATE_SOLUTION.md - Proposed enum-based solutionStatus: Not implemented
Goal: Allow uploads to continue when app is backgrounded
Challenges:
Recommendation:
TUSUploadClient_Clean.swift in codebaseStatus: Not possible with current APIs
Goal: Show progress during the 18-minute Photos copy
Problem:
loadFileRepresentation provides no progress callbacksAlternatives:
Symptoms:
Causes:
upload_max_filesize and post_max_size in /etc/php/8.1/fpm/php.ini5000M or higherSecRequestBodyNoFilesLimit in /etc/modsecurity/modsecurity.conf131072 (128KB) ❌5368709120 (5GB) ✅LimitRequestBody in Apache configSolution:
# Inside Docker container
# 1. Check PHP limits
php -i | grep -E "upload_max_filesize|post_max_size"
# 2. Check ModSecurity limits
grep "SecRequestBodyNoFilesLimit" /etc/modsecurity/modsecurity.conf
# 3. Fix ModSecurity (most likely culprit)
sed -i 's/SecRequestBodyNoFilesLimit 131072/SecRequestBodyNoFilesLimit 5368709120/' /etc/modsecurity/modsecurity.conf
# 4. Restart Apache
service apache2 restart
Symptoms:
Cause:
$_FILES['media']$_FILES['file']Solution:
// In NetworkProgressUploadClient.swift
let stream = try MultipartInputStream(
fileURL: payload.fileURL,
boundary: boundary,
formFields: formFields,
fileFieldName: "file", // ← MUST match PHP
fileName: fileName,
mimeType: mimeType
)
Symptoms:
Cause:
MultipartInputStream doesn’t implement all required InputStream methodsSolution:
Ensure MultipartInputStream.swift implements:
streamStatus propertystreamError propertydelegate propertyschedule(in:forMode:) methodremove(from:forMode:) methodproperty(forKey:) methodsetProperty(_:forKey:) methodSee MultipartInputStream.swift lines 79-128 for implementation.
Symptoms:
Cause:
request.httpBodyStream = streamuploadTask(withStreamedRequest:) doesn’t accept body streams in requestSolution:
// WRONG
request.httpBodyStream = stream // ❌
// CORRECT
self.currentInputStream = stream
let task = session.uploadTask(withStreamedRequest: request)
// Provide via delegate
func urlSession(_ session: URLSession, task: URLSessionTask,
needNewBodyStream completionHandler: @escaping (InputStream?) -> Void) {
completionHandler(currentInputStream)
}
Symptoms:
Cause:
URLSessionTaskDelegate.didSendBodyData not being calledDiagnosis:
// Check if delegate is set
print("Session delegate: \(session.delegate)")
// Check if progress handler is set
print("Progress handler: \(progressHandler != nil)")
// Add logging to delegate
func urlSession(_ session: URLSession, task: URLSessionTask,
didSendBodyData bytesSent: Int64, totalBytesSent: Int64,
totalBytesExpectedToSend: Int64) {
print("📊 Delegate called: \(totalBytesSent)/\(totalBytesExpectedToSend)")
// ...
}
Enable verbose logging:
// In NetworkProgressUploadClient.swift
print("🚀 Direct streaming upload starting for: \(payload.fileURL.lastPathComponent)")
print("📤 Creating multipart stream...")
print("🔍 Content-Length: \(ByteCountFormatter.string(fromByteCount: contentLength, countStyle: .file))")
print("📤 Starting direct stream upload...")
print("🔍 Task created, state: \(task.state.rawValue)")
print("✅ Upload task resumed, state: \(task.state.rawValue)")
Check server logs:
# Apache access log
tail -f /var/log/apache2/access.log | grep uploads.php
# Apache error log
tail -f /var/log/apache2/error.log
# PHP-FPM log
tail -f /var/log/php8.1-fpm.log
# ModSecurity debug log (if enabled)
tail -f /var/log/apache2/modsec_debug.log
Track upload metrics:
// Start time
let startTime = Date()
// On completion
let duration = Date().timeIntervalSince(startTime)
let fileSize = try fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
let throughput = Double(fileSize) / duration / 1024 / 1024 // MB/s
print("📊 Upload completed in \(duration)s at \(throughput) MB/s")
Expected throughput:
| File | Purpose | Lines | Status |
|---|---|---|---|
MultipartInputStream.swift |
Custom InputStream for on-the-fly multipart | 220 | ✅ Production |
NetworkProgressUploadClient.swift |
Direct streaming upload client | 250 | ✅ Production |
PickerBridges.swift |
PHPicker and DocumentPicker wrappers | 100 | ✅ Production |
UploadView.swift |
Main upload UI | 635 | ✅ Production |
| File | Purpose | Status |
|---|---|---|
STREAMING_ARCHITECTURE_20251008.md |
This document | ✅ Current |
DIRECT_STREAMING_PLAN.md |
Original implementation plan | 📚 Historical |
FILECOPYTODISKRATIONALE.md |
Photos copy explanation | 📚 Historical |
LAYER1_PROGRESS_TODO.md |
Layer 1 progress (obsolete) | 📚 Historical |
ENUM_STATE_SOLUTION.md |
Enum state machine (not needed) | 📚 Historical |
| File | Purpose | Date |
|---|---|---|
NetworkProgressUploadClient.swift.backup-direct-streaming |
Pre-streaming version | Oct 7, 2025 |
patches/estimated-load-time.patch |
Optional enhancement | Oct 7, 2025 |
✅ 4.71GB file uploads successfully - No crashes
✅ Progress shows immediately - No 18-minute wait
✅ Memory usage under 50MB - Only 4MB chunks in memory
✅ No iOS watchdog terminations - No extended disk operations
✅ Single code path - Works for all file sizes
✅ Clean cancellation - Upload can be cancelled at any point
✅ 50% reduction in wait time - 36 min → 18 min for 4.71GB
| Metric | Target | Actual | Status |
|---|---|---|---|
| Upload start delay | < 1 second | Immediate | ✅ |
| Memory usage | < 50MB | ~10MB | ✅ |
| Progress update frequency | Every 10% | Every 10% | ✅ |
| Max file size | 5GB | Tested 4.71GB | ✅ |
| Crash rate | 0% | 0% | ✅ |
| Metric | Before | After | Improvement |
|---|---|---|---|
| Total wait (4.71GB) | 36 min | 18 min | 50% |
| Progress feedback | None | Immediate | ∞ |
| Crash rate | 100% | 0% | 100% |
| User confusion | High | Low | Significant |
The direct streaming implementation successfully solved the iOS watchdog termination issue and eliminated the 18-minute Layer 1 assembly wait. The remaining 18-minute Photos copy is an unavoidable iOS limitation that affects all apps using the privacy-focused PHPicker.
Key Takeaways:
InputStream enables true streaming without temp filesSecRequestBodyNoFilesLimit is a hidden gotchaNext Steps:
Document Maintainer: Development Team
Last Review: October 8, 2025
Next Review: As needed for major changes