Skip to content

Commit 4b81bb4

Browse files
authored
Allow in progress recording downloads (#21)
### Description We also want to be able to show recordings of the session so far. The way we've hooked up `ffmpeg` allows this already! The work in this pr is to: 1. Relax constraints around downloading in progress recordings 2. Instead, we'll check the file size and return a `202` if the recording doesn't have a reasonable amount of data. For the moment we've picked 100 bytes, which is substantially smaller than what `ffmpeg` will flush to disk Also lurked in some minor config validation + cleaned up the `Start` logic as recommended by my cursor agents ### Testing Via the local docker set up: 1. Build and run the container with the recording server 2. Start recording 3. Immediately hit the download -> confirm a `202` response 4. (wait for a bit) 5. Hit the download again -> confirm valid video output 6. Stop recording 7. Download -> confirm valid video output
1 parent 0d2d48f commit 4b81bb4

File tree

7 files changed

+187
-33
lines changed

7 files changed

+187
-33
lines changed

server/cmd/api/api/api.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,6 @@ func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFa
2727
func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecordingRequestObject) (oapi.StartRecordingResponseObject, error) {
2828
log := logger.FromContext(ctx)
2929

30-
if rec, exists := s.recordManager.GetRecorder(s.mainRecorderID); exists && rec.IsRecording(ctx) {
31-
log.Error("attempted to start recording while one is already active")
32-
return oapi.StartRecording409JSONResponse{ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{Message: "recording already in progress"}}, nil
33-
}
34-
3530
var params recorder.FFmpegRecordingParams
3631
if req.Body != nil {
3732
params.FrameRate = req.Body.Framerate
@@ -45,6 +40,10 @@ func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecording
4540
return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to create recording"}}, nil
4641
}
4742
if err := s.recordManager.RegisterRecorder(ctx, rec); err != nil {
43+
if rec, exists := s.recordManager.GetRecorder(s.mainRecorderID); exists && rec.IsRecording(ctx) {
44+
log.Error("attempted to start recording while one is already active")
45+
return oapi.StartRecording409JSONResponse{ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{Message: "recording already in progress"}}, nil
46+
}
4847
log.Error("failed to register recorder", "err", err)
4948
return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to register recording"}}, nil
5049
}
@@ -90,6 +89,10 @@ func (s *ApiService) StopRecording(ctx context.Context, req oapi.StopRecordingRe
9089
return oapi.StopRecording200Response{}, nil
9190
}
9291

92+
const (
93+
minRecordingSizeInBytes = 100
94+
)
95+
9396
func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRecordingRequestObject) (oapi.DownloadRecordingResponseObject, error) {
9497
log := logger.FromContext(ctx)
9598

@@ -100,17 +103,21 @@ func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRec
100103
return oapi.DownloadRecording404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "no recording found"}}, nil
101104
}
102105

103-
if rec.IsRecording(ctx) {
104-
log.Warn("attempted to download recording while is still in progress")
105-
return oapi.DownloadRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "recording still in progress, please stop first"}}, nil
106-
}
107-
108106
out, meta, err := rec.Recording(ctx)
109107
if err != nil {
110108
log.Error("failed to get recording", "err", err)
111109
return oapi.DownloadRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to get recording"}}, nil
112110
}
113111

112+
// short-circuit if the recording is still in progress and the file is arbitrary small
113+
if rec.IsRecording(ctx) && meta.Size <= minRecordingSizeInBytes {
114+
return oapi.DownloadRecording202Response{
115+
Headers: oapi.DownloadRecording202ResponseHeaders{
116+
RetryAfter: 300,
117+
},
118+
}, nil
119+
}
120+
114121
log.Info("serving recording file for download", "size", meta.Size)
115122
return oapi.DownloadRecording200Videomp4Response{
116123
Body: out,

server/cmd/api/api/api_test.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"io"
7+
"math/rand"
78
"testing"
89

910
oapi "github.com/onkernel/kernel-images/server/lib/oapi"
@@ -92,15 +93,40 @@ func TestApiService_DownloadRecording(t *testing.T) {
9293
require.IsType(t, oapi.DownloadRecording404JSONResponse{}, resp)
9394
})
9495

96+
randomBytes := func(n int) []byte {
97+
data := make([]byte, n)
98+
for i := range data {
99+
data[i] = byte(rand.Intn(256))
100+
}
101+
return data
102+
}
103+
95104
t.Run("still recording", func(t *testing.T) {
96105
mgr := recorder.NewFFmpegManager()
97-
rec := &mockRecorder{id: "main", isRecordingFlag: true}
106+
rec := &mockRecorder{id: "main", isRecordingFlag: true, recordingData: randomBytes(minRecordingSizeInBytes - 1)}
98107
require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder")
99108

100109
svc := New(mgr, newMockFactory())
110+
// will return a 202 when the recording is too small
101111
resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{})
102112
require.NoError(t, err)
103-
require.IsType(t, oapi.DownloadRecording400JSONResponse{}, resp)
113+
require.IsType(t, oapi.DownloadRecording202Response{}, resp)
114+
115+
// mimic writing more data to the recording
116+
data := randomBytes(minRecordingSizeInBytes * 2)
117+
rec.recordingData = data
118+
119+
// now that the recording is larger than the minimum size, it should return a 200
120+
resp, err = svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{})
121+
require.NoError(t, err)
122+
require.IsType(t, oapi.DownloadRecording200Videomp4Response{}, resp)
123+
r, ok := resp.(oapi.DownloadRecording200Videomp4Response)
124+
require.True(t, ok, "expected 200 mp4 response, got %T", resp)
125+
buf := new(bytes.Buffer)
126+
_, copyErr := io.Copy(buf, r.Body)
127+
require.NoError(t, copyErr)
128+
require.Equal(t, data, buf.Bytes(), "response body mismatch")
129+
require.Equal(t, int64(len(data)), r.ContentLength, "content length mismatch")
104130
})
105131

106132
t.Run("success", func(t *testing.T) {

server/cmd/config/config.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ func validate(config *Config) error {
4141
if config.DisplayNum < 0 {
4242
return fmt.Errorf("DISPLAY_NUM must be greater than 0")
4343
}
44-
if config.FrameRate < 0 {
45-
return fmt.Errorf("FRAME_RATE must be greater than 0")
44+
if config.FrameRate < 0 || config.FrameRate > 120 {
45+
return fmt.Errorf("FRAME_RATE must be greater than 0 and less than 120")
4646
}
47-
if config.MaxSizeInMB < 0 {
48-
return fmt.Errorf("MAX_SIZE_MB must be greater than 0")
47+
if config.MaxSizeInMB < 0 || config.MaxSizeInMB > 1000 {
48+
return fmt.Errorf("MAX_SIZE_MB must be greater than 0 and less than 1000")
4949
}
5050
if config.PathToFFmpeg == "" {
5151
return fmt.Errorf("FFMPEG_PATH is required")

server/cmd/config/config_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package config
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestLoad(t *testing.T) {
10+
testCases := []struct {
11+
name string
12+
env map[string]string
13+
wantErr bool
14+
wantCfg *Config
15+
}{
16+
{
17+
name: "defaults (no env set)",
18+
env: map[string]string{},
19+
wantCfg: &Config{
20+
Port: 10001,
21+
FrameRate: 10,
22+
DisplayNum: 1,
23+
MaxSizeInMB: 500,
24+
OutputDir: ".",
25+
PathToFFmpeg: "ffmpeg",
26+
},
27+
},
28+
{
29+
name: "custom valid env",
30+
env: map[string]string{
31+
"PORT": "12345",
32+
"FRAME_RATE": "24",
33+
"DISPLAY_NUM": "2",
34+
"MAX_SIZE_MB": "250",
35+
"OUTPUT_DIR": "/tmp",
36+
"FFMPEG_PATH": "/usr/local/bin/ffmpeg",
37+
},
38+
wantCfg: &Config{
39+
Port: 12345,
40+
FrameRate: 24,
41+
DisplayNum: 2,
42+
MaxSizeInMB: 250,
43+
OutputDir: "/tmp",
44+
PathToFFmpeg: "/usr/local/bin/ffmpeg",
45+
},
46+
},
47+
{
48+
name: "negative display num",
49+
env: map[string]string{
50+
"DISPLAY_NUM": "-1",
51+
},
52+
wantErr: true,
53+
},
54+
{
55+
name: "frame rate too high",
56+
env: map[string]string{
57+
"FRAME_RATE": "1201",
58+
},
59+
wantErr: true,
60+
},
61+
{
62+
name: "max size too big",
63+
env: map[string]string{
64+
"MAX_SIZE_MB": "10001",
65+
},
66+
wantErr: true,
67+
},
68+
{
69+
name: "missing ffmpeg path (set to empty)",
70+
env: map[string]string{
71+
"FFMPEG_PATH": "",
72+
},
73+
wantErr: true,
74+
},
75+
{
76+
name: "missing output dir (set to empty)",
77+
env: map[string]string{
78+
"OUTPUT_DIR": "",
79+
},
80+
wantErr: true,
81+
},
82+
}
83+
84+
for idx := range testCases {
85+
tc := testCases[idx]
86+
t.Run(tc.name, func(t *testing.T) {
87+
for k, v := range tc.env {
88+
t.Setenv(k, v)
89+
}
90+
91+
cfg, err := Load()
92+
93+
if tc.wantErr {
94+
require.Error(t, err)
95+
} else {
96+
require.NoError(t, err)
97+
require.NotNil(t, cfg)
98+
require.Equal(t, tc.wantCfg, cfg)
99+
}
100+
})
101+
}
102+
}

server/lib/oapi/oapi.go

Lines changed: 28 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/lib/recorder/ffmpeg.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,10 +188,6 @@ func (fr *FFmpegRecorder) IsRecording(ctx context.Context) bool {
188188

189189
// Recording returns the recording file as an io.ReadCloser.
190190
func (fr *FFmpegRecorder) Recording(ctx context.Context) (io.ReadCloser, *RecordingMetadata, error) {
191-
if fr.IsRecording(ctx) {
192-
return nil, nil, fmt.Errorf("recording still in progress, please call stop first")
193-
}
194-
195191
file, err := os.Open(fr.outputPath)
196192
if err != nil {
197193
return nil, nil, fmt.Errorf("failed to open recording file: %w", err)

server/openapi.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ paths:
5050
schema:
5151
type: string
5252
format: binary
53+
"202":
54+
description: Recording is still in progress, please try again later
55+
headers:
56+
Retry-After:
57+
description: Suggested wait time in seconds before retrying
58+
schema:
59+
type: integer
60+
minimum: 1
5361
"400":
5462
$ref: "#/components/responses/BadRequestError"
5563
"404":

0 commit comments

Comments
 (0)