Skip to content

Commit 6adbfcd

Browse files
committed
proper streaming plus tests
1 parent 8f4fec7 commit 6adbfcd

4 files changed

Lines changed: 114 additions & 18 deletions

File tree

vulcan/lib/remote_manager.rb

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,56 @@ def read_file_to_memory(remote_file_path)
4141
invoke_ssh_command(command.to_s)[:stdout]
4242
end
4343

44-
def stream_file_simple(remote_path, chunk: 64 * 1024)
45-
@ssh_pool.with_conn do |ssh|
46-
ssh.sftp.file.open(remote_path, 'r') do |file|
47-
loop do
48-
data = file.read(chunk)
49-
break if data.nil? || data.empty?
50-
yield data
44+
def stream_file_simple(remote_path, chunk: 256 * 1024, max_duration: 300)
45+
bytes_read = 0
46+
start_time = Time.now
47+
48+
# Wrap the entire SFTP operation in a timeout to prevent hanging
49+
Timeout.timeout(max_duration) do
50+
@ssh_pool.with_conn do |ssh|
51+
sftp = ssh.sftp
52+
sftp.file.open(remote_path, 'r') do |file|
53+
loop do
54+
# Check timeout
55+
elapsed = Time.now - start_time
56+
if elapsed > max_duration
57+
Vulcan.instance.logger.warn(
58+
"Stream timeout after #{elapsed.round(1)}s, #{bytes_read} bytes read from #{remote_path}"
59+
)
60+
raise Timeout::Error, "Stream exceeded #{max_duration}s limit"
61+
end
62+
63+
data = file.read(chunk)
64+
break if data.nil? || data.empty?
65+
66+
bytes_read += data.bytesize
67+
yield data
68+
69+
# Log progress every 100MB for large files
70+
if bytes_read % (100 * 1024 * 1024) < chunk
71+
Vulcan.instance.logger.info(
72+
"Streaming progress: #{(bytes_read / 1024.0 / 1024.0).round(1)}MB from #{remote_path}"
73+
)
74+
end
75+
end
5176
end
5277
end
5378
end
79+
rescue Timeout::Error => e
80+
Vulcan.instance.logger.error("Stream timeout after #{(Time.now - start_time).round(1)}s, #{bytes_read} bytes read")
81+
raise "Stream timeout: #{e.message}"
5482
rescue Net::SFTP::StatusException => e
83+
Vulcan.instance.logger.error("SFTP error: #{e.description}")
5584
raise "Remote file error: #{e.description}"
85+
rescue IOError, Errno::EPIPE => e
86+
# Client likely disconnected
87+
Vulcan.instance.logger.warn(
88+
"Client disconnected after #{bytes_read} bytes: #{e.message}"
89+
)
90+
# Don't re-raise - stream was interrupted by client
91+
rescue Net::SSH::Disconnect, Errno::ECONNRESET => e
92+
Vulcan.instance.logger.error("SSH connection error: #{e.message}")
93+
raise "SSH connection lost during streaming: #{e.message}"
5694
end
5795

5896
def touch(remote_file_path)

vulcan/lib/server/controllers/vulcan_v2_controller.rb

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -442,14 +442,20 @@ def stream_download_file
442442
file_path = File.join(Vulcan::Path.workspace_output_dir(workspace.path), file_name)
443443
raise Etna::BadRequest, "File not found" unless @remote_manager.file_exists?(file_path)
444444

445-
response['Content-Type'] = 'application/octet-stream'
446-
response['Content-Disposition'] = "attachment; filename=#{File.basename(file_name)}"
447-
response['Cache-Control'] = 'no-cache'
448-
response.delete_header('Content-Length') if response.respond_to?(:delete_header)
449-
450-
response.body = Enumerator.new do |yielder|
451-
@remote_manager.stream_file_simple(file_path) { |chunk| yielder << chunk }
445+
# Create a lazy streaming body that yields chunks on demand
446+
# This streams to the frontend without loading the entire file into memory
447+
streaming_body = Enumerator.new do |yielder|
448+
@remote_manager.stream_file_simple(file_path) do |chunk|
449+
yielder << chunk
450+
end
452451
end
452+
453+
# Return response with streaming body
454+
[200, {
455+
'Content-Type' => 'application/octet-stream',
456+
'Content-Disposition' => "attachment; filename=#{File.basename(file_name)}",
457+
'Cache-Control' => 'no-cache'
458+
}, streaming_body]
453459
end
454460

455461
def cluster_latency

vulcan/spec/spec_helper.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,23 @@ def cp_file(src, dest)
151151
invoke_ssh_command(command)
152152
end
153153

154+
def create_large_file(remote_file_path, size_mb:)
155+
Vulcan.instance.logger.info("Creating #{size_mb}MB file at #{remote_file_path}...")
156+
157+
# Create parent directory first
158+
command = build_command.add('mkdir', '-p', File.dirname(remote_file_path))
159+
invoke_ssh_command(command.to_s)
160+
161+
Vulcan.instance.logger.info("Generating file content with dd...")
162+
# Use dd to create a file with random content of specified size
163+
# bs=1M count=size_mb creates a file of size_mb megabytes
164+
dd_command = "dd if=/dev/urandom of=#{Shellwords.escape(remote_file_path)} bs=1M count=#{size_mb} 2>/dev/null"
165+
invoke_ssh_command(dd_command, timeout: 30)
166+
167+
Vulcan.instance.logger.info("Large file created successfully")
168+
remote_file_path
169+
end
170+
154171
end
155172

156173

vulcan/spec/workflow_v2_spec.rb

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,17 +1360,52 @@ def run_entire_test_workflow(workspace)
13601360

13611361
get "/api/v2/#{PROJECT}/workspace/#{workspace.id}/file/stream-download/poem.txt"
13621362

1363-
# --- force the body to be consumed (two equivalent ways) ---
1363+
# Force the body to be consumed
13641364
streamed = +""
1365-
last_response.body.each { |chunk| streamed << chunk } # explicit
1366-
# streamed = last_response.body # implicit join
1365+
last_response.body.each { |chunk| streamed << chunk }
13671366

13681367
expect(last_response.status).to eq(200)
13691368

1370-
# headers: rack sets canonical case
1369+
# Verify headers
13711370
expect(last_response.headers['Content-Type']).to eq('application/octet-stream')
13721371
expect(last_response.headers['Content-Disposition']).to eq('attachment; filename=poem.txt')
13731372
expect(last_response.headers['Cache-Control']).to eq('no-cache')
1373+
1374+
# Verify content matches the expected file
1375+
expect(streamed).to_not be_empty
1376+
expect(streamed).to eq(poem_1_text)
1377+
end
1378+
1379+
it 'streams larger files in multiple chunks' do
1380+
auth_header(:editor)
1381+
1382+
workspace = Vulcan::Workspace.first
1383+
file_name = "large_file.txt"
1384+
file_path = "#{workspace.path}/output/#{file_name}"
1385+
1386+
# Create a 1MB file directly on the remote server (bypassing echo limits)
1387+
remote_manager.create_large_file(file_path, size_mb: 1)
1388+
1389+
# Get the actual file size for verification
1390+
stat_result = remote_manager.invoke_ssh_command("stat -c %s #{file_path}")
1391+
expected_size = stat_result[:stdout].strip.to_i
1392+
1393+
get "/api/v2/#{PROJECT}/workspace/#{workspace.id}/file/stream-download/#{file_name}"
1394+
1395+
expect(last_response.status).to eq(200)
1396+
1397+
# Rack::Test joins all body chunks into a single string
1398+
# The server streams the file in 256KB chunks (4 chunks for 1MB file)
1399+
streamed = last_response.body
1400+
1401+
# Verify the complete size matches (1MB = 1048576 bytes)
1402+
expect(streamed.bytesize).to eq(expected_size)
1403+
expect(streamed.bytesize).to eq(1024 * 1024) # 1MB
1404+
1405+
# Verify headers
1406+
expect(last_response.headers['Content-Type']).to eq('application/octet-stream')
1407+
expect(last_response.headers['Content-Disposition']).to eq("attachment; filename=#{file_name}")
1408+
expect(last_response.headers['Cache-Control']).to eq('no-cache')
13741409
end
13751410

13761411
it 'raises an error if the file does not exist' do

0 commit comments

Comments
 (0)