From 1852ef9fc6abb9c4e85edcf40c26d11f0f9cbb13 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Fri, 11 Jul 2025 14:57:50 -0700 Subject: [PATCH 01/23] add start and stop time headers --- server/openapi.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/server/openapi.yaml b/server/openapi.yaml index fc1ebebb..8ee2b3eb 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -45,6 +45,17 @@ paths: responses: "200": description: Recording file + headers: + X-Recording-Start: + description: Timestamp of when the recording started + schema: + type: string + format: date-time + X-Recording-End: + description: Timestamp of when the recording ended + schema: + type: string + format: date-time content: video/mp4: schema: From 877ad457c7dda889273c4d70b00bb758ab2a9d35 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Fri, 11 Jul 2025 14:57:57 -0700 Subject: [PATCH 02/23] make oapi-generate --- server/lib/oapi/oapi.go | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 8bce6db3..430b342c 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -15,6 +15,7 @@ import ( "net/url" "path" "strings" + "time" "github.com/getkin/kin-openapi/openapi3" "github.com/go-chi/chi/v5" @@ -816,8 +817,14 @@ type DownloadRecordingResponseObject interface { VisitDownloadRecordingResponse(w http.ResponseWriter) error } +type DownloadRecording200ResponseHeaders struct { + XRecordingEnd time.Time + XRecordingStart time.Time +} + type DownloadRecording200Videomp4Response struct { Body io.Reader + Headers DownloadRecording200ResponseHeaders ContentLength int64 } @@ -826,6 +833,8 @@ func (response DownloadRecording200Videomp4Response) VisitDownloadRecordingRespo if response.ContentLength != 0 { w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) } + w.Header().Set("X-Recording-End", fmt.Sprint(response.Headers.XRecordingEnd)) + w.Header().Set("X-Recording-Start", fmt.Sprint(response.Headers.XRecordingStart)) w.WriteHeader(200) if closer, ok := response.Body.(io.ReadCloser); ok { @@ -1075,20 +1084,21 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file From b1a41af50db00f3f9bbe6f9fc90ac694a77e5631 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Fri, 11 Jul 2025 14:58:05 -0700 Subject: [PATCH 03/23] scaletozero --- server/lib/scaletozero/scaletozero.go | 86 +++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 server/lib/scaletozero/scaletozero.go diff --git a/server/lib/scaletozero/scaletozero.go b/server/lib/scaletozero/scaletozero.go new file mode 100644 index 00000000..0ec86990 --- /dev/null +++ b/server/lib/scaletozero/scaletozero.go @@ -0,0 +1,86 @@ +package scaletozero + +import ( + "context" + "os" + "sync" + + "github.com/onkernel/kernel-images/server/lib/logger" +) + +// Unikraft scale-to-zero control file +// https://unikraft.cloud/docs/api/v1/instances/#scaletozero_app +const unikraftScaleToZeroFile = "/uk/libukp/scale_to_zero_disable" + +type Controller interface { + // Disable turns scale-to-zero off. + Disable(ctx context.Context) error + // Enable re-enables scale-to-zero after it has previously been disabled. + Enable(ctx context.Context) error +} + +type unikraftCloudController struct { + path string +} + +func NewUnikraftCloudController() Controller { + return &unikraftCloudController{path: unikraftScaleToZeroFile} +} + +func (c *unikraftCloudController) Disable(ctx context.Context) error { + return c.write(ctx, "+") +} + +func (c *unikraftCloudController) Enable(ctx context.Context) error { + return c.write(ctx, "-") +} + +func (c *unikraftCloudController) write(ctx context.Context, char string) error { + if _, err := os.Stat(c.path); err != nil { + if os.IsNotExist(err) { + return nil + } + logger.FromContext(ctx).Error("failed to stat scale-to-zero control file", "path", c.path, "err", err) + return err + } + + f, err := os.OpenFile(c.path, os.O_WRONLY|os.O_TRUNC, 0) + if err != nil { + logger.FromContext(ctx).Error("failed to open scale-to-zero control file", "path", c.path, "err", err) + return err + } + defer f.Close() + if _, err := f.Write([]byte(char)); err != nil { + logger.FromContext(ctx).Error("failed to write scale-to-zero control file", "path", c.path, "err", err) + return err + } + return nil +} + +type NoopController struct{} + +func NewNoopController() *NoopController { return &NoopController{} } + +func (NoopController) Disable(context.Context) error { return nil } +func (NoopController) Enable(context.Context) error { return nil } + +// Oncer wraps a Controller and ensures that Disable and Enable are called at most once. +type Oncer struct { + ctrl Controller + disableOnce sync.Once + enableOnce sync.Once + disableErr error + enableErr error +} + +func NewOncer(c Controller) *Oncer { return &Oncer{ctrl: c} } + +func (o *Oncer) Disable(ctx context.Context) error { + o.disableOnce.Do(func() { o.disableErr = o.ctrl.Disable(ctx) }) + return o.disableErr +} + +func (o *Oncer) Enable(ctx context.Context) error { + o.enableOnce.Do(func() { o.enableErr = o.ctrl.Enable(ctx) }) + return o.enableErr +} From 0908bb94649d14559b47575c827f3730d053b52a Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Fri, 11 Jul 2025 14:58:18 -0700 Subject: [PATCH 04/23] integrate scaletozero --- server/cmd/api/api/api.go | 20 ++++++++++++++++---- server/cmd/api/api/api_test.go | 29 +++++++++++++++++++---------- server/cmd/api/main.go | 12 +++++++++++- server/lib/recorder/ffmeg_test.go | 3 +++ server/lib/recorder/ffmpeg.go | 23 ++++++++++++++++++++--- 5 files changed, 69 insertions(+), 18 deletions(-) diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index a8140992..622e161f 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -2,6 +2,7 @@ package api import ( "context" + "fmt" "github.com/onkernel/kernel-images/server/lib/logger" oapi "github.com/onkernel/kernel-images/server/lib/oapi" @@ -16,12 +17,19 @@ type ApiService struct { factory recorder.FFmpegRecorderFactory } -func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFactory) *ApiService { +func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFactory) (*ApiService, error) { + switch { + case recordManager == nil: + return nil, fmt.Errorf("recordManager cannot be nil") + case factory == nil: + return nil, fmt.Errorf("factory cannot be nil") + } + return &ApiService{ recordManager: recordManager, factory: factory, mainRecorderID: "main", // use a single recorder for now - } + }, nil } func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecordingRequestObject) (oapi.StartRecordingResponseObject, error) { @@ -50,7 +58,7 @@ func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecording if err := rec.Start(ctx); err != nil { log.Error("failed to start recording", "err", err) - // ensure the recorder is deregistered if we fail to start + // ensure the recorder is deregistered defer s.recordManager.DeregisterRecorder(ctx, rec) return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to start recording"}}, nil } @@ -120,7 +128,11 @@ func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRec log.Info("serving recording file for download", "size", meta.Size) return oapi.DownloadRecording200Videomp4Response{ - Body: out, + Body: out, + Headers: oapi.DownloadRecording200ResponseHeaders{ + XRecordingStart: meta.StartTime, + XRecordingEnd: meta.EndTime, + }, ContentLength: meta.Size, }, nil } diff --git a/server/cmd/api/api/api_test.go b/server/cmd/api/api/api_test.go index 7366256d..559288ea 100644 --- a/server/cmd/api/api/api_test.go +++ b/server/cmd/api/api/api_test.go @@ -17,7 +17,8 @@ func TestApiService_StartRecording(t *testing.T) { t.Run("success", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) resp, err := svc.StartRecording(ctx, oapi.StartRecordingRequestObject{}) require.NoError(t, err) @@ -30,10 +31,11 @@ func TestApiService_StartRecording(t *testing.T) { t.Run("already recording", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) // First start should succeed - _, err := svc.StartRecording(ctx, oapi.StartRecordingRequestObject{}) + _, err = svc.StartRecording(ctx, oapi.StartRecordingRequestObject{}) require.NoError(t, err) // Second start should return conflict @@ -48,7 +50,8 @@ func TestApiService_StopRecording(t *testing.T) { t.Run("no active recording", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) resp, err := svc.StopRecording(ctx, oapi.StopRecordingRequestObject{}) require.NoError(t, err) @@ -60,7 +63,8 @@ func TestApiService_StopRecording(t *testing.T) { rec := &mockRecorder{id: "main", isRecordingFlag: true} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) resp, err := svc.StopRecording(ctx, oapi.StopRecordingRequestObject{}) require.NoError(t, err) require.IsType(t, oapi.StopRecording200Response{}, resp) @@ -74,7 +78,8 @@ func TestApiService_StopRecording(t *testing.T) { force := true req := oapi.StopRecordingRequestObject{Body: &oapi.StopRecordingJSONRequestBody{ForceStop: &force}} - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) resp, err := svc.StopRecording(ctx, req) require.NoError(t, err) require.IsType(t, oapi.StopRecording200Response{}, resp) @@ -87,7 +92,8 @@ func TestApiService_DownloadRecording(t *testing.T) { t.Run("not found", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) require.NoError(t, err) require.IsType(t, oapi.DownloadRecording404JSONResponse{}, resp) @@ -106,7 +112,8 @@ func TestApiService_DownloadRecording(t *testing.T) { rec := &mockRecorder{id: "main", isRecordingFlag: true, recordingData: randomBytes(minRecordingSizeInBytes - 1)} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) // will return a 202 when the recording is too small resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) require.NoError(t, err) @@ -135,7 +142,8 @@ func TestApiService_DownloadRecording(t *testing.T) { rec := &mockRecorder{id: "main", recordingData: data} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) require.NoError(t, err) r, ok := resp.(oapi.DownloadRecording200Videomp4Response) @@ -154,7 +162,8 @@ func TestApiService_Shutdown(t *testing.T) { rec := &mockRecorder{id: "main", isRecordingFlag: true} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc := New(mgr, newMockFactory()) + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) require.NoError(t, svc.Shutdown(ctx)) require.True(t, rec.stopCalled, "Shutdown should have stopped active recorder") diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 536e3b20..50a56a14 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -22,6 +22,7 @@ import ( "github.com/onkernel/kernel-images/server/lib/logger" oapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/onkernel/kernel-images/server/lib/recorder" + "github.com/onkernel/kernel-images/server/lib/scaletozero" ) func main() { @@ -64,7 +65,16 @@ func main() { slogger.Error("invalid default recording parameters", "err", err) os.Exit(1) } - apiService := api.New(recorder.NewFFmpegManager(), recorder.NewFFmpegRecorderFactory(config.PathToFFmpeg, defaultParams)) + stz := scaletozero.NewUnikraftCloudController() + + apiService, err := api.New( + recorder.NewFFmpegManager(), + recorder.NewFFmpegRecorderFactory(config.PathToFFmpeg, defaultParams, stz), + ) + if err != nil { + slogger.Error("failed to create api service", "err", err) + os.Exit(1) + } strictHandler := oapi.NewStrictHandler(apiService, nil) oapi.HandlerFromMux(strictHandler, r) diff --git a/server/lib/recorder/ffmeg_test.go b/server/lib/recorder/ffmeg_test.go index 47e3a03e..9ef38125 100644 --- a/server/lib/recorder/ffmeg_test.go +++ b/server/lib/recorder/ffmeg_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/onkernel/kernel-images/server/lib/scaletozero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -30,6 +31,7 @@ func TestFFmpegRecorder_StartAndStop(t *testing.T) { id: "startstop", binaryPath: mockBin, params: defaultParams(t.TempDir()), + stz: scaletozero.NewOncer(scaletozero.NewNoopController()), } require.NoError(t, rec.Start(t.Context())) require.True(t, rec.IsRecording(t.Context())) @@ -46,6 +48,7 @@ func TestFFmpegRecorder_ForceStop(t *testing.T) { id: "startstop", binaryPath: mockBin, params: defaultParams(t.TempDir()), + stz: scaletozero.NewOncer(scaletozero.NewNoopController()), } require.NoError(t, rec.Start(t.Context())) require.True(t, rec.IsRecording(t.Context())) diff --git a/server/lib/recorder/ffmpeg.go b/server/lib/recorder/ffmpeg.go index b3dbbe3a..131a1b5f 100644 --- a/server/lib/recorder/ffmpeg.go +++ b/server/lib/recorder/ffmpeg.go @@ -17,6 +17,7 @@ import ( "time" "github.com/onkernel/kernel-images/server/lib/logger" + "github.com/onkernel/kernel-images/server/lib/scaletozero" ) const ( @@ -45,6 +46,7 @@ type FFmpegRecorder struct { ffmpegErr error exitCode int exited chan struct{} + stz *scaletozero.Oncer } type FFmpegRecordingParams struct { @@ -76,7 +78,7 @@ type FFmpegRecorderFactory func(id string, overrides FFmpegRecordingParams) (Rec // NewFFmpegRecorderFactory returns a factory that creates new recorders. The provided // pathToFFmpeg is used as the binary to execute; if empty it defaults to "ffmpeg" which // is expected to be discoverable on the host's PATH. -func NewFFmpegRecorderFactory(pathToFFmpeg string, config FFmpegRecordingParams) FFmpegRecorderFactory { +func NewFFmpegRecorderFactory(pathToFFmpeg string, config FFmpegRecordingParams, ctrl scaletozero.Controller) FFmpegRecorderFactory { return func(id string, overrides FFmpegRecordingParams) (Recorder, error) { mergedParams := mergeFFmpegRecordingParams(config, overrides) return &FFmpegRecorder{ @@ -84,6 +86,7 @@ func NewFFmpegRecorderFactory(pathToFFmpeg string, config FFmpegRecordingParams) binaryPath: pathToFFmpeg, outputPath: filepath.Join(*mergedParams.OutputDir, fmt.Sprintf("%s.mp4", id)), params: mergedParams, + stz: scaletozero.NewOncer(ctrl), }, nil } } @@ -125,6 +128,11 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { return fmt.Errorf("recording already in progress") } + if err := fr.stz.Disable(ctx); err != nil { + fr.mu.Unlock() + return fmt.Errorf("failed to disable scale-to-zero: %w", err) + } + // ensure internal state fr.ffmpegErr = nil fr.exitCode = exitCodeInitValue @@ -146,6 +154,7 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { fr.mu.Unlock() if err := cmd.Start(); err != nil { + _ = fr.stz.Enable(ctx) return fmt.Errorf("failed to start ffmpeg process: %w", err) } @@ -164,19 +173,25 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { // Stop gracefully stops the recording using a multi-phase shutdown process. func (fr *FFmpegRecorder) Stop(ctx context.Context) error { - return fr.shutdownInPhases(ctx, []shutdownPhase{ + defer fr.stz.Enable(ctx) + err := fr.shutdownInPhases(ctx, []shutdownPhase{ {"wake_and_interrupt", []syscall.Signal{syscall.SIGCONT, syscall.SIGINT}, 5 * time.Second, "graceful stop"}, {"retry_interrupt", []syscall.Signal{syscall.SIGINT}, 3 * time.Second, "retry graceful stop"}, {"terminate", []syscall.Signal{syscall.SIGTERM}, 250 * time.Millisecond, "forceful termination"}, {"kill", []syscall.Signal{syscall.SIGKILL}, 100 * time.Millisecond, "immediate kill"}, }) + + return err } // ForceStop immediately terminates the recording process. func (fr *FFmpegRecorder) ForceStop(ctx context.Context) error { - return fr.shutdownInPhases(ctx, []shutdownPhase{ + defer fr.stz.Enable(ctx) + err := fr.shutdownInPhases(ctx, []shutdownPhase{ {"kill", []syscall.Signal{syscall.SIGKILL}, 100 * time.Millisecond, "immediate kill"}, }) + + return err } // IsRecording returns true if a recording is currently in progress. @@ -271,6 +286,8 @@ func ffmpegArgs(params FFmpegRecordingParams, outputPath string) ([]string, erro // waitForCommand should be run in the background to wait for the ffmpeg process to complete and // update the internal state accordingly. func (fr *FFmpegRecorder) waitForCommand(ctx context.Context) { + defer fr.stz.Enable(ctx) + log := logger.FromContext(ctx) // wait for the process to complete and extract the exit code From 848e71337cd2f320d22903a8305c619a0de1a7a6 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Fri, 11 Jul 2025 15:27:56 -0700 Subject: [PATCH 05/23] add timeout param --- server/openapi.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/openapi.yaml b/server/openapi.yaml index 8ee2b3eb..423f9d1a 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -90,6 +90,10 @@ components: description: Recording framerate in fps (overrides server default) minimum: 1 maximum: 60 + maxDurationInSeconds: + type: integer + description: Maximum recording duration in seconds (overrides server default) + minimum: 1 additionalProperties: false StopRecordingRequest: type: object From 7b3e945b5e402f864769579d6ef9e3bef5b1de06 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Fri, 11 Jul 2025 15:28:06 -0700 Subject: [PATCH 06/23] make oapi-generate --- server/lib/oapi/oapi.go | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 430b342c..2e283dc9 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -32,6 +32,9 @@ type StartRecordingRequest struct { // Framerate Recording framerate in fps (overrides server default) Framerate *int `json:"framerate,omitempty"` + // MaxDurationInSeconds Maximum recording duration in seconds (overrides server default) + MaxDurationInSeconds *int `json:"maxDurationInSeconds,omitempty"` + // MaxFileSizeInMB Maximum file size in MB (overrides server default) MaxFileSizeInMB *int `json:"maxFileSizeInMB,omitempty"` } @@ -1084,21 +1087,22 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "d35kQf8GAAD//76bNWkwDAAA", } // GetSwagger returns the content of the embedded swagger specification file From 23717c8a05e7d65434468e9167eb36cb836337ff Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Fri, 11 Jul 2025 15:28:12 -0700 Subject: [PATCH 07/23] thread timeout --- server/cmd/api/api/api.go | 6 ++- server/lib/recorder/ffmpeg.go | 89 +++++++++++++++++------------------ 2 files changed, 49 insertions(+), 46 deletions(-) diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 622e161f..cf2dec79 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -39,6 +39,7 @@ func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecording if req.Body != nil { params.FrameRate = req.Body.Framerate params.MaxSizeInMB = req.Body.MaxFileSizeInMB + params.MaxDurationInSeconds = req.Body.MaxDurationInSeconds } // Create, register, and start a new recorder @@ -70,9 +71,12 @@ func (s *ApiService) StopRecording(ctx context.Context, req oapi.StopRecordingRe log := logger.FromContext(ctx) rec, exists := s.recordManager.GetRecorder(s.mainRecorderID) - if !exists || !rec.IsRecording(ctx) { + if !exists { log.Warn("attempted to stop recording when none is active") return oapi.StopRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "no active recording to stop"}}, nil + } else if !rec.IsRecording(ctx) { + log.Warn("recording already stopped") + return oapi.StopRecording200Response{}, nil } // Check if force stop is requested diff --git a/server/lib/recorder/ffmpeg.go b/server/lib/recorder/ffmpeg.go index 131a1b5f..174b77c6 100644 --- a/server/lib/recorder/ffmpeg.go +++ b/server/lib/recorder/ffmpeg.go @@ -53,7 +53,9 @@ type FFmpegRecordingParams struct { FrameRate *int DisplayNum *int MaxSizeInMB *int - OutputDir *string + // MaxDurationInSeconds optionally limits the total recording time. If nil there is no duration limit. + MaxDurationInSeconds *int + OutputDir *string } func (p FFmpegRecordingParams) Validate() error { @@ -69,6 +71,9 @@ func (p FFmpegRecordingParams) Validate() error { if p.MaxSizeInMB == nil { return fmt.Errorf("max size in MB is required") } + if p.MaxDurationInSeconds != nil && *p.MaxDurationInSeconds <= 0 { + return fmt.Errorf("max duration must be greater than 0 seconds") + } return nil } @@ -93,10 +98,11 @@ func NewFFmpegRecorderFactory(pathToFFmpeg string, config FFmpegRecordingParams, func mergeFFmpegRecordingParams(config FFmpegRecordingParams, overrides FFmpegRecordingParams) FFmpegRecordingParams { merged := FFmpegRecordingParams{ - FrameRate: config.FrameRate, - DisplayNum: config.DisplayNum, - MaxSizeInMB: config.MaxSizeInMB, - OutputDir: config.OutputDir, + FrameRate: config.FrameRate, + DisplayNum: config.DisplayNum, + MaxSizeInMB: config.MaxSizeInMB, + MaxDurationInSeconds: config.MaxDurationInSeconds, + OutputDir: config.OutputDir, } if overrides.FrameRate != nil { merged.FrameRate = overrides.FrameRate @@ -107,6 +113,9 @@ func mergeFFmpegRecordingParams(config FFmpegRecordingParams, overrides FFmpegRe if overrides.MaxSizeInMB != nil { merged.MaxSizeInMB = overrides.MaxSizeInMB } + if overrides.MaxDurationInSeconds != nil { + merged.MaxDurationInSeconds = overrides.MaxDurationInSeconds + } if overrides.OutputDir != nil { merged.OutputDir = overrides.OutputDir } @@ -226,61 +235,51 @@ func (fr *FFmpegRecorder) Recording(ctx context.Context) (io.ReadCloser, *Record // ffmpegArgs generates platform-specific ffmpeg command line arguments. func ffmpegArgs(params FFmpegRecordingParams, outputPath string) ([]string, error) { + args := []string{ + // Video encoding + "-c:v", "libx264", + + // Timestamp handling for reliable playback + "-use_wallclock_as_timestamps", "1", // Use system time instead of input stream time + "-reset_timestamps", "1", // Reset timestamps to start from zero + "-avoid_negative_ts", "make_zero", // Convert negative timestamps to zero + + // Error handling + "-xerror", // Exit on any error + + // Output configuration for data safety + "-movflags", "+frag_keyframe+empty_moov", // Enable fragmented MP4 for data safety + "-frag_duration", "2000000", // 2-second fragments (in microseconds) + "-fs", fmt.Sprintf("%dM", *params.MaxSizeInMB), // File size limit + "-y", // Overwrite output file if it exists + outputPath, + } + + if params.MaxDurationInSeconds != nil { + args = append(args, "-t", strconv.Itoa(*params.MaxDurationInSeconds)) + } + switch runtime.GOOS { case "darwin": - return []string{ + args = append(args, []string{ // Input configuration - Use AVFoundation for macOS screen capture "-f", "avfoundation", "-framerate", strconv.Itoa(*params.FrameRate), "-pixel_format", "nv12", "-i", fmt.Sprintf("%d:none", *params.DisplayNum), // Screen capture, no audio - - // Video encoding - "-c:v", "libx264", - - // Timestamp handling for reliable playback - "-use_wallclock_as_timestamps", "1", // Use system time instead of input stream time - "-reset_timestamps", "1", // Reset timestamps to start from zero - "-avoid_negative_ts", "make_zero", // Convert negative timestamps to zero - - // Error handling - "-xerror", // Exit on any error - - // Output configuration for data safety - "-movflags", "+frag_keyframe+empty_moov", // Enable fragmented MP4 for data safety - "-frag_duration", "2000000", // 2-second fragments (in microseconds) - "-fs", fmt.Sprintf("%dM", *params.MaxSizeInMB), // File size limit - "-y", // Overwrite output file if it exists - outputPath, - }, nil + }...) case "linux": - return []string{ + args = append(args, []string{ // Input configuration - Use X11 screen capture for Linux "-f", "x11grab", "-framerate", strconv.Itoa(*params.FrameRate), "-i", fmt.Sprintf(":%d", *params.DisplayNum), // X11 display - - // Video encoding - "-c:v", "libx264", - - // Timestamp handling for reliable playback - "-use_wallclock_as_timestamps", "1", // Use system time instead of input stream time - "-reset_timestamps", "1", // Reset timestamps to start from zero - "-avoid_negative_ts", "make_zero", // Convert negative timestamps to zero - - // Error handling - "-xerror", // Exit on any error - - // Output configuration for data safety - "-movflags", "+frag_keyframe+empty_moov", // Enable fragmented MP4 for data safety - "-frag_duration", "2000000", // 2-second fragments (in microseconds) - "-fs", fmt.Sprintf("%dM", *params.MaxSizeInMB), // File size limit - "-y", // Overwrite output file if it exists - outputPath, - }, nil + }...) default: return nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS) } + + return args, nil } // waitForCommand should be run in the background to wait for the ffmpeg process to complete and From 07fe11c3369b26d3f658dd0ede41c0f0f583b259 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Fri, 11 Jul 2025 15:34:12 -0700 Subject: [PATCH 08/23] relax errors --- server/lib/recorder/ffmpeg.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/server/lib/recorder/ffmpeg.go b/server/lib/recorder/ffmpeg.go index 174b77c6..ed7a80c2 100644 --- a/server/lib/recorder/ffmpeg.go +++ b/server/lib/recorder/ffmpeg.go @@ -244,9 +244,6 @@ func ffmpegArgs(params FFmpegRecordingParams, outputPath string) ([]string, erro "-reset_timestamps", "1", // Reset timestamps to start from zero "-avoid_negative_ts", "make_zero", // Convert negative timestamps to zero - // Error handling - "-xerror", // Exit on any error - // Output configuration for data safety "-movflags", "+frag_keyframe+empty_moov", // Enable fragmented MP4 for data safety "-frag_duration", "2000000", // 2-second fragments (in microseconds) From 4d6837e27bf792646f3cfe8bb3f388f7f4ec53e7 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Fri, 11 Jul 2025 16:18:17 -0700 Subject: [PATCH 09/23] args + bugs --- server/lib/recorder/ffmpeg.go | 67 ++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/server/lib/recorder/ffmpeg.go b/server/lib/recorder/ffmpeg.go index ed7a80c2..a583a3f1 100644 --- a/server/lib/recorder/ffmpeg.go +++ b/server/lib/recorder/ffmpeg.go @@ -134,6 +134,7 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { fr.mu.Lock() if fr.cmd != nil { + fr.mu.Unlock() return fmt.Errorf("recording already in progress") } @@ -150,6 +151,11 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { args, err := ffmpegArgs(fr.params, fr.outputPath) if err != nil { + _ = fr.stz.Enable(ctx) + fr.cmd = nil + close(fr.exited) + fr.mu.Unlock() + return err } log.Info(fmt.Sprintf("%s %s", fr.binaryPath, strings.Join(args, " "))) @@ -164,6 +170,10 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { if err := cmd.Start(); err != nil { _ = fr.stz.Enable(ctx) + fr.mu.Lock() + fr.ffmpegErr = err + close(fr.exited) + fr.mu.Unlock() return fmt.Errorf("failed to start ffmpeg process: %w", err) } @@ -233,9 +243,35 @@ func (fr *FFmpegRecorder) Recording(ctx context.Context) (io.ReadCloser, *Record }, nil } -// ffmpegArgs generates platform-specific ffmpeg command line arguments. +// ffmpegArgs generates platform-specific ffmpeg command line arguments. Allegedly order matters. func ffmpegArgs(params FFmpegRecordingParams, outputPath string) ([]string, error) { - args := []string{ + var args []string + + // Input options first + switch runtime.GOOS { + case "darwin": + args = []string{ + // Input options for AVFoundation + "-f", "avfoundation", + "-framerate", strconv.Itoa(*params.FrameRate), + "-pixel_format", "nv12", + // Input file + "-i", fmt.Sprintf("%d:none", *params.DisplayNum), // Screen capture, no audio + } + case "linux": + args = []string{ + // Input options for X11 + "-f", "x11grab", + "-framerate", strconv.Itoa(*params.FrameRate), + // Input file + "-i", fmt.Sprintf(":%d", *params.DisplayNum), // X11 display + } + default: + return nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + + // Output options next + args = append(args, []string{ // Video encoding "-c:v", "libx264", @@ -244,37 +280,20 @@ func ffmpegArgs(params FFmpegRecordingParams, outputPath string) ([]string, erro "-reset_timestamps", "1", // Reset timestamps to start from zero "-avoid_negative_ts", "make_zero", // Convert negative timestamps to zero - // Output configuration for data safety + // Data safety "-movflags", "+frag_keyframe+empty_moov", // Enable fragmented MP4 for data safety "-frag_duration", "2000000", // 2-second fragments (in microseconds) "-fs", fmt.Sprintf("%dM", *params.MaxSizeInMB), // File size limit "-y", // Overwrite output file if it exists - outputPath, - } + }...) + // Duration limit if params.MaxDurationInSeconds != nil { args = append(args, "-t", strconv.Itoa(*params.MaxDurationInSeconds)) } - switch runtime.GOOS { - case "darwin": - args = append(args, []string{ - // Input configuration - Use AVFoundation for macOS screen capture - "-f", "avfoundation", - "-framerate", strconv.Itoa(*params.FrameRate), - "-pixel_format", "nv12", - "-i", fmt.Sprintf("%d:none", *params.DisplayNum), // Screen capture, no audio - }...) - case "linux": - args = append(args, []string{ - // Input configuration - Use X11 screen capture for Linux - "-f", "x11grab", - "-framerate", strconv.Itoa(*params.FrameRate), - "-i", fmt.Sprintf(":%d", *params.DisplayNum), // X11 display - }...) - default: - return nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS) - } + // Output file + args = append(args, outputPath) return args, nil } From db0634dfd90d73010f810525031f6655718a439d Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Mon, 14 Jul 2025 11:13:00 -0700 Subject: [PATCH 10/23] allow setting recorder id for mulitple at once --- server/openapi.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/server/openapi.yaml b/server/openapi.yaml index 423f9d1a..b9dc5b45 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -41,6 +41,13 @@ paths: /recording/download: get: summary: Download the most recently recorded video file + parameters: + - name: id + in: query + description: Optional recorder identifier. When omitted, the server uses the default recorder. + schema: + type: string + pattern: "^[a-zA-Z0-9-]+$" operationId: downloadRecording responses: "200": @@ -94,6 +101,10 @@ components: type: integer description: Maximum recording duration in seconds (overrides server default) minimum: 1 + id: + type: string + description: Optional identifier for the recording session. Alphanumeric or hyphen. + pattern: "^[a-zA-Z0-9-]+$" additionalProperties: false StopRecordingRequest: type: object @@ -102,6 +113,10 @@ components: type: boolean description: Immediately stop without graceful shutdown. This may result in a corrupted video file. default: false + id: + type: string + description: Identifier of the recorder to stop. Alphanumeric or hyphen. + pattern: "^[a-zA-Z0-9-]+$" additionalProperties: false Error: type: object From ba35b228b9630af6b859bd48c26da046bc566671 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Mon, 14 Jul 2025 11:13:06 -0700 Subject: [PATCH 11/23] make oapi-generate --- server/lib/oapi/oapi.go | 107 ++++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 27 deletions(-) diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 2e283dc9..5d8b229e 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -19,6 +19,7 @@ import ( "github.com/getkin/kin-openapi/openapi3" "github.com/go-chi/chi/v5" + "github.com/oapi-codegen/runtime" strictnethttp "github.com/oapi-codegen/runtime/strictmiddleware/nethttp" ) @@ -32,6 +33,9 @@ type StartRecordingRequest struct { // Framerate Recording framerate in fps (overrides server default) Framerate *int `json:"framerate,omitempty"` + // Id Optional identifier for the recording session. Alphanumeric or hyphen. + Id *string `json:"id,omitempty"` + // MaxDurationInSeconds Maximum recording duration in seconds (overrides server default) MaxDurationInSeconds *int `json:"maxDurationInSeconds,omitempty"` @@ -43,6 +47,9 @@ type StartRecordingRequest struct { type StopRecordingRequest struct { // ForceStop Immediately stop without graceful shutdown. This may result in a corrupted video file. ForceStop *bool `json:"forceStop,omitempty"` + + // Id Identifier of the recorder to stop. Alphanumeric or hyphen. + Id *string `json:"id,omitempty"` } // BadRequestError defines model for BadRequestError. @@ -57,6 +64,12 @@ type InternalError = Error // NotFoundError defines model for NotFoundError. type NotFoundError = Error +// DownloadRecordingParams defines parameters for DownloadRecording. +type DownloadRecordingParams struct { + // Id Optional recorder identifier. When omitted, the server uses the default recorder. + Id *string `form:"id,omitempty" json:"id,omitempty"` +} + // StartRecordingJSONRequestBody defines body for StartRecording for application/json ContentType. type StartRecordingJSONRequestBody = StartRecordingRequest @@ -137,7 +150,7 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption { // The interface specification for the client above. type ClientInterface interface { // DownloadRecording request - DownloadRecording(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + DownloadRecording(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*http.Response, error) // StartRecordingWithBody request with any body StartRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -150,8 +163,8 @@ type ClientInterface interface { StopRecording(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) } -func (c *Client) DownloadRecording(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewDownloadRecordingRequest(c.Server) +func (c *Client) DownloadRecording(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDownloadRecordingRequest(c.Server, params) if err != nil { return nil, err } @@ -211,7 +224,7 @@ func (c *Client) StopRecording(ctx context.Context, body StopRecordingJSONReques } // NewDownloadRecordingRequest generates requests for DownloadRecording -func NewDownloadRecordingRequest(server string) (*http.Request, error) { +func NewDownloadRecordingRequest(server string, params *DownloadRecordingParams) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -229,6 +242,28 @@ func NewDownloadRecordingRequest(server string) (*http.Request, error) { return nil, err } + if params != nil { + queryValues := queryURL.Query() + + if params.Id != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "id", runtime.ParamLocationQuery, *params.Id); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + req, err := http.NewRequest("GET", queryURL.String(), nil) if err != nil { return nil, err @@ -361,7 +396,7 @@ func WithBaseURL(baseURL string) ClientOption { // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { // DownloadRecordingWithResponse request - DownloadRecordingWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) + DownloadRecordingWithResponse(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) // StartRecordingWithBodyWithResponse request with any body StartRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) @@ -445,8 +480,8 @@ func (r StopRecordingResponse) StatusCode() int { } // DownloadRecordingWithResponse request returning *DownloadRecordingResponse -func (c *ClientWithResponses) DownloadRecordingWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) { - rsp, err := c.DownloadRecording(ctx, reqEditors...) +func (c *ClientWithResponses) DownloadRecordingWithResponse(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) { + rsp, err := c.DownloadRecording(ctx, params, reqEditors...) if err != nil { return nil, err } @@ -597,7 +632,7 @@ func ParseStopRecordingResponse(rsp *http.Response) (*StopRecordingResponse, err type ServerInterface interface { // Download the most recently recorded video file // (GET /recording/download) - DownloadRecording(w http.ResponseWriter, r *http.Request) + DownloadRecording(w http.ResponseWriter, r *http.Request, params DownloadRecordingParams) // Start a screen recording. Only one recording can be active at a time. // (POST /recording/start) StartRecording(w http.ResponseWriter, r *http.Request) @@ -612,7 +647,7 @@ type Unimplemented struct{} // Download the most recently recorded video file // (GET /recording/download) -func (_ Unimplemented) DownloadRecording(w http.ResponseWriter, r *http.Request) { +func (_ Unimplemented) DownloadRecording(w http.ResponseWriter, r *http.Request, params DownloadRecordingParams) { w.WriteHeader(http.StatusNotImplemented) } @@ -640,8 +675,21 @@ type MiddlewareFunc func(http.Handler) http.Handler // DownloadRecording operation middleware func (siw *ServerInterfaceWrapper) DownloadRecording(w http.ResponseWriter, r *http.Request) { + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params DownloadRecordingParams + + // ------------- Optional query parameter "id" ------------- + + err = runtime.BindQueryParameter("form", true, false, "id", r.URL.Query(), ¶ms.Id) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DownloadRecording(w, r) + siw.Handler.DownloadRecording(w, r, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -814,6 +862,7 @@ type InternalErrorJSONResponse Error type NotFoundErrorJSONResponse Error type DownloadRecordingRequestObject struct { + Params DownloadRecordingParams } type DownloadRecordingResponseObject interface { @@ -999,9 +1048,11 @@ type strictHandler struct { } // DownloadRecording operation middleware -func (sh *strictHandler) DownloadRecording(w http.ResponseWriter, r *http.Request) { +func (sh *strictHandler) DownloadRecording(w http.ResponseWriter, r *http.Request, params DownloadRecordingParams) { var request DownloadRecordingRequestObject + request.Params = params + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { return sh.ssi.DownloadRecording(ctx, request.(DownloadRecordingRequestObject)) } @@ -1087,22 +1138,24 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/7xWXW/bNhT9KwS3hw1QbCXNBsxvzdoCxpCuiPswYNgDI17JLEhe9vLKqVr4vw+kbNmy", - "3aTL0rzJ/Drn3HM//EVW6AJ68Bzl7IskiAF9hPzjSukb+NhC5NdESGmpQs/gOX2qEKypFBv00w8RfVqL", - "1RKcSl8/EtRyJn+Y7t6f9rtx2r+2Xq8LqSFWZEJ6RM4SoNggynUhf0dfW1M9F/oWLkHPPQN5ZZ8Jegsn", - "FkArILE5WMi3yG+w9fqZeLxFFhlPpr3N8fTagB8IAxCbPkMcxKgaSJ/cBZAzGZmMb/J1go+tIdBy9vdw", - "8J9iexBvP0Af6wUr4huokLTxzdb/JFBrk4gp+24PtVY2QnFApCblgBRnKmNNw8tiOCSMF3WI4idcAZHR", - "EEXsA6+hVq3ln2UhnfpkXOvk7NeykM74/sf5IMB4hgayS059etVSdmLuF1Ch1/GYyHX/oKCBkN5cSnxi", - "f+0BTg/ReGMsLMxnmPvrq68zqI0FEc3nHIjrq2+Mw3lZlqNQlMck1if9xfB/7UWqIL3Ta8rchqMHpeQc", - "aKMYbCciYxB3hpfYsmhIVVC3VsRlyxrv/ES8X5oonOoEQWwtp2goUSFRGxi0WBkNmIM1kYOuW0QLyp+S", - "mpaMrzGXg2Gb9v4A8mDF3KkGonj5bi4LuQKKPdlycj4pU4wwgFfByJl8MSknL2Qhg+Jl1j4d8mWaWFtU", - "Oi03kIOYotQnnpYz+WpzYAi3LMYt/aIsD7pIFjl14XLcPmokpzjpNV5Rt9M/FPhR89grNGNBFnIJSgNl", - "3L/Oht2z114fp+Z74yCyckFgLe6W4AUvYa9UwGvQsjjFUCuGMzYOTpAsRsi50/x37JiuPQY94V+UF/f1", - "JBNFZGNtyr1A2BDEWIhgQUUQTJ1QjTJeWMVA45DeAFN39rJOG0cAi7ZpIKYkvlOGReK332RuoUZKEpm6", - "Pkt2yu7rMVnRZZ9DpwbMkGvTw/8O+d7lw/fGA29dyF++BW08rvPsap1LeburiWyqw8jJWfBsu43Fo0rP", - "l/dKLm5TJmA8UXDj2SX7oQeRr1B3TzauTw/I3oyD6j6/L9e2eZy9+O3hqI7/fz2FF1mJUCJWBOB3JTYR", - "f3rbCfT7ZVcpL25BqIrNCoRK91IeT44t6ufC1xzamz7fzaATE+6kP+X9/mAIW38eV2NP4BCGXClVSwSe", - "d35kQf8GAAD//76bNWkwDAAA", + "H4sIAAAAAAAC/7xW3W4bNxN9lQG/XHxFV9LacQtEd3aTAELhJLADtKjhAvRyVmLAPw9n7ciG3r0gV1pp", + "JdlOUtd3+0NyzpxzZjj3ovI2eIeOoxjfC8IYvIuYX06kOsPrBiO/I/KUPlXeMTpOjzIEoyvJ2rvRl+hd", + "+harGVqZnl4R1mIs/jdanz9q/8ZRe9pisSiEwliRDukQMU4BYRlRLArxm3e10dVLRV+FS6EnjpGcNC8U", + "ehUOzpFukGC5sBAfPL/3jVMvhOODZ8jxRPq3XJ5O6+IH8gGJdesQizHKKaZHngcUYxGZtJvm7YTXjSZU", + "YnzRLbwsVgv91RdsuT5nSXyGlSel3XSlf0pQKZ2ASfNpI2otTcRiC0hN0iJJzlD6OXUnQ7cItIM6RPi/", + "v0EirTBCbIlXWMvG8E+iEFZ+1baxYvxrWQirXfty0CWgHeMUs0pa7Yb9GFrooBU61rVGgtoT8AyBOkgR", + "Y9TeDeHYhJl0jUXSFXiC2TzM0A1FIYLkZA4xFn9fyMHd8eCvcvBmcPnzK1Fsk55Bv20oe2LizrHyTsVd", + "bKdtahs41HJTYia2255g51FCrPz6Xhs813c4cacnDyOotUGI+i5LcnryjYoclGXZE6XcBbHY6zQf/q3R", + "PFWYzmlzyti6pVtFbS0qLRnNHCL7ALeaZ75hmJKssG4MxFnDyt+6IXye6QhWzoEwNoYTGxIqT9QERgU3", + "WqHPZA3Xql95b1C6hww4WfvO1xu2QwL2GdAzuW6X6fRJu9rnvqDZpH+/Izk0MLFyihGOP01EIW6QYgu2", + "HB4My5SJD+hk0GIsXg/L4esWySxTP+rsOkqkGS9z1lPMGiaRWt8rMRZvlws6tfNBqf4ZKYrxxYPl2pG0", + "rtsh/DFDB95qZlRF5nLpziZizO9LK3TbE4c6HXzdIM1FIZy02Z9KFBtd+ntoviz61/NhWW7dCNkmIxuO", + "+ldB7clKTo7RTmYw20fvXAQbTVMbFIWYoVSZuXvx56D7O3jn9jjvs7YYWdqQjHebqOs3PXQK+zR0CJVk", + "HLC2uNdmm5HzrfH9sWPa9iPRU/zD8vCx+0VHiKyNSdUbyE8JYywgGJQRgWkOciq1AyMZqU/pGTLNB8d1", + "+rET4LyZTjGmNnArNUPCt9mmr7D2lFJkmrdGX2f2WJfOGR21Hto3LHReG23PgXnf0dP7+sPLohC/fEu0", + "/uiV55DG2uTbdVlnUa2Pud7QsZmvCm+zV+bNG10jriwTfNzTM/pziGgHGIx84tX82Uav/cNOK8ZWdR88", + "5rWVj7MWb55mtT9LP4cWOROQECtCdOsSG8JHZ+bg3WbZVdLBFYKsWN8gyLQv+Xi4K1F7sz6k0Mb9/Z8J", + "tGdG2KtP+bg+PoSVPj9WY8+gkA+5UqqGCB2v9cgJ/RMAAP//hfJgGPwNAAA=", } // GetSwagger returns the content of the embedded swagger specification file From f9f55581dde82237b1010d266d6fa8cf3d06ac0d Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Mon, 14 Jul 2025 11:13:13 -0700 Subject: [PATCH 12/23] api changes --- server/cmd/api/api/api.go | 42 ++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index cf2dec79..d52c4923 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -9,12 +9,12 @@ import ( "github.com/onkernel/kernel-images/server/lib/recorder" ) -// ApiService implements the API endpoints -// It manages a single recording session and provides endpoints for starting, stopping, and downloading it type ApiService struct { - mainRecorderID string // ID used for the primary recording session - recordManager recorder.RecordManager - factory recorder.FFmpegRecorderFactory + // defaultRecorderID is used whenever the caller doesn't specify an explicit ID. + defaultRecorderID string + + recordManager recorder.RecordManager + factory recorder.FFmpegRecorderFactory } func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFactory) (*ApiService, error) { @@ -26,9 +26,9 @@ func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFa } return &ApiService{ - recordManager: recordManager, - factory: factory, - mainRecorderID: "main", // use a single recorder for now + recordManager: recordManager, + factory: factory, + defaultRecorderID: "main", }, nil } @@ -42,14 +42,20 @@ func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecording params.MaxDurationInSeconds = req.Body.MaxDurationInSeconds } + // Determine recorder ID (use default if none provided) + recorderID := s.defaultRecorderID + if req.Body != nil && req.Body.Id != nil && *req.Body.Id != "" { + recorderID = *req.Body.Id + } + // Create, register, and start a new recorder - rec, err := s.factory(s.mainRecorderID, params) + rec, err := s.factory(recorderID, params) if err != nil { log.Error("failed to create recorder", "err", err) return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to create recording"}}, nil } if err := s.recordManager.RegisterRecorder(ctx, rec); err != nil { - if rec, exists := s.recordManager.GetRecorder(s.mainRecorderID); exists && rec.IsRecording(ctx) { + if rec, exists := s.recordManager.GetRecorder(recorderID); exists && rec.IsRecording(ctx) { log.Error("attempted to start recording while one is already active") return oapi.StartRecording409JSONResponse{ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{Message: "recording already in progress"}}, nil } @@ -70,7 +76,13 @@ func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecording func (s *ApiService) StopRecording(ctx context.Context, req oapi.StopRecordingRequestObject) (oapi.StopRecordingResponseObject, error) { log := logger.FromContext(ctx) - rec, exists := s.recordManager.GetRecorder(s.mainRecorderID) + // Determine recorder ID + recorderID := s.defaultRecorderID + if req.Body != nil && req.Body.Id != nil && *req.Body.Id != "" { + recorderID = *req.Body.Id + } + + rec, exists := s.recordManager.GetRecorder(recorderID) if !exists { log.Warn("attempted to stop recording when none is active") return oapi.StopRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "no active recording to stop"}}, nil @@ -108,8 +120,14 @@ const ( func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRecordingRequestObject) (oapi.DownloadRecordingResponseObject, error) { log := logger.FromContext(ctx) + // Determine recorder ID + recorderID := s.defaultRecorderID + if req.Params.Id != nil && *req.Params.Id != "" { + recorderID = *req.Params.Id + } + // Get the recorder to access its output path - rec, exists := s.recordManager.GetRecorder(s.mainRecorderID) + rec, exists := s.recordManager.GetRecorder(recorderID) if !exists { log.Error("attempted to download non-existent recording") return oapi.DownloadRecording404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "no recording found"}}, nil From 39f67b627fe9e9801f87d8fc27bb8fec336b8526 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Mon, 14 Jul 2025 11:13:20 -0700 Subject: [PATCH 13/23] go mod --- server/go.mod | 2 ++ server/go.sum | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/server/go.mod b/server/go.mod index 38925a73..3ed05aeb 100644 --- a/server/go.mod +++ b/server/go.mod @@ -13,9 +13,11 @@ require ( ) require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/uuid v1.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect diff --git a/server/go.sum b/server/go.sum index d120fb9c..2adcffee 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,3 +1,8 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk= @@ -12,8 +17,11 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -36,6 +44,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= From 311a70913562caa756c58ded2cb6511579cf337e Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Mon, 14 Jul 2025 11:55:08 -0700 Subject: [PATCH 14/23] logging, 400 --- server/cmd/api/api/api.go | 35 ++++++++++++---------- server/cmd/api/api/api_test.go | 43 +++++++++++++++++++++++---- server/lib/oapi/oapi.go | 53 ++++++++++++++++++++++------------ server/openapi.yaml | 6 ++-- 4 files changed, 96 insertions(+), 41 deletions(-) diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index d52c4923..24624af7 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -28,7 +28,7 @@ func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFa return &ApiService{ recordManager: recordManager, factory: factory, - defaultRecorderID: "main", + defaultRecorderID: "default", }, nil } @@ -51,20 +51,25 @@ func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecording // Create, register, and start a new recorder rec, err := s.factory(recorderID, params) if err != nil { - log.Error("failed to create recorder", "err", err) + log.Error("failed to create recorder", "err", err, "recorder_id", recorderID) return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to create recording"}}, nil } if err := s.recordManager.RegisterRecorder(ctx, rec); err != nil { - if rec, exists := s.recordManager.GetRecorder(recorderID); exists && rec.IsRecording(ctx) { - log.Error("attempted to start recording while one is already active") - return oapi.StartRecording409JSONResponse{ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{Message: "recording already in progress"}}, nil + if rec, exists := s.recordManager.GetRecorder(recorderID); exists { + if rec.IsRecording(ctx) { + log.Error("attempted to start recording while one is already active", "recorder_id", recorderID) + return oapi.StartRecording409JSONResponse{ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{Message: "recording already in progress"}}, nil + } else { + log.Error("attempted to restart recording", "recorder_id", recorderID) + return oapi.StartRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "recording already completed"}}, nil + } } - log.Error("failed to register recorder", "err", err) + log.Error("failed to register recorder", "err", err, "recorder_id", recorderID) return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to register recording"}}, nil } if err := rec.Start(ctx); err != nil { - log.Error("failed to start recording", "err", err) + log.Error("failed to start recording", "err", err, "recorder_id", recorderID) // ensure the recorder is deregistered defer s.recordManager.DeregisterRecorder(ctx, rec) return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to start recording"}}, nil @@ -84,10 +89,10 @@ func (s *ApiService) StopRecording(ctx context.Context, req oapi.StopRecordingRe rec, exists := s.recordManager.GetRecorder(recorderID) if !exists { - log.Warn("attempted to stop recording when none is active") + log.Warn("attempted to stop recording when none is active", "recorder_id", recorderID) return oapi.StopRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "no active recording to stop"}}, nil } else if !rec.IsRecording(ctx) { - log.Warn("recording already stopped") + log.Warn("recording already stopped", "recorder_id", recorderID) return oapi.StopRecording200Response{}, nil } @@ -99,15 +104,15 @@ func (s *ApiService) StopRecording(ctx context.Context, req oapi.StopRecordingRe var err error if forceStop { - log.Info("force stopping recording") + log.Info("force stopping recording", "recorder_id", recorderID) err = rec.ForceStop(ctx) } else { - log.Info("gracefully stopping recording") + log.Info("gracefully stopping recording", "recorder_id", recorderID) err = rec.Stop(ctx) } if err != nil { - log.Error("error occurred while stopping recording", "err", err, "force", forceStop) + log.Error("error occurred while stopping recording", "err", err, "force", forceStop, "recorder_id", recorderID) } return oapi.StopRecording200Response{}, nil @@ -129,13 +134,13 @@ func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRec // Get the recorder to access its output path rec, exists := s.recordManager.GetRecorder(recorderID) if !exists { - log.Error("attempted to download non-existent recording") + log.Error("attempted to download non-existent recording", "recorder_id", recorderID) return oapi.DownloadRecording404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "no recording found"}}, nil } out, meta, err := rec.Recording(ctx) if err != nil { - log.Error("failed to get recording", "err", err) + log.Error("failed to get recording", "err", err, "recorder_id", recorderID) return oapi.DownloadRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to get recording"}}, nil } @@ -148,7 +153,7 @@ func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRec }, nil } - log.Info("serving recording file for download", "size", meta.Size) + log.Info("serving recording file for download", "size", meta.Size, "recorder_id", recorderID) return oapi.DownloadRecording200Videomp4Response{ Body: out, Headers: oapi.DownloadRecording200ResponseHeaders{ diff --git a/server/cmd/api/api/api_test.go b/server/cmd/api/api/api_test.go index 559288ea..eb6c8b6f 100644 --- a/server/cmd/api/api/api_test.go +++ b/server/cmd/api/api/api_test.go @@ -3,12 +3,14 @@ package api import ( "bytes" "context" + "fmt" "io" "math/rand" "testing" oapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/onkernel/kernel-images/server/lib/recorder" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,7 +26,7 @@ func TestApiService_StartRecording(t *testing.T) { require.NoError(t, err) require.IsType(t, oapi.StartRecording201Response{}, resp) - rec, exists := mgr.GetRecorder("main") + rec, exists := mgr.GetRecorder("default") require.True(t, exists, "recorder was not registered") require.True(t, rec.IsRecording(ctx), "recorder should be recording after Start") }) @@ -43,6 +45,35 @@ func TestApiService_StartRecording(t *testing.T) { require.NoError(t, err) require.IsType(t, oapi.StartRecording409JSONResponse{}, resp) }) + + t.Run("custom ids don't collide", func(t *testing.T) { + mgr := recorder.NewFFmpegManager() + svc, err := New(mgr, newMockFactory()) + require.NoError(t, err) + + for i := 0; i < 5; i++ { + customID := fmt.Sprintf("rec-%d", i) + resp, err := svc.StartRecording(ctx, oapi.StartRecordingRequestObject{Body: &oapi.StartRecordingJSONRequestBody{Id: &customID}}) + require.NoError(t, err) + require.IsType(t, oapi.StartRecording201Response{}, resp) + + rec, exists := mgr.GetRecorder(customID) + assert.True(t, exists) + assert.True(t, rec.IsRecording(ctx)) + } + + out := mgr.ListActiveRecorders(ctx) + require.NoError(t, err) + assert.Equal(t, 5, len(out)) + assert.NotContains(t, out, "default") + + err = mgr.StopAll(ctx) + require.NoError(t, err) + + out = mgr.ListActiveRecorders(ctx) + require.NoError(t, err) + assert.Equal(t, 0, len(out)) + }) } func TestApiService_StopRecording(t *testing.T) { @@ -60,7 +91,7 @@ func TestApiService_StopRecording(t *testing.T) { t.Run("graceful stop", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - rec := &mockRecorder{id: "main", isRecordingFlag: true} + rec := &mockRecorder{id: "default", isRecordingFlag: true} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") svc, err := New(mgr, newMockFactory()) @@ -73,7 +104,7 @@ func TestApiService_StopRecording(t *testing.T) { t.Run("force stop", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - rec := &mockRecorder{id: "main", isRecordingFlag: true} + rec := &mockRecorder{id: "default", isRecordingFlag: true} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") force := true @@ -109,7 +140,7 @@ func TestApiService_DownloadRecording(t *testing.T) { t.Run("still recording", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - rec := &mockRecorder{id: "main", isRecordingFlag: true, recordingData: randomBytes(minRecordingSizeInBytes - 1)} + rec := &mockRecorder{id: "default", isRecordingFlag: true, recordingData: randomBytes(minRecordingSizeInBytes - 1)} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") svc, err := New(mgr, newMockFactory()) @@ -139,7 +170,7 @@ func TestApiService_DownloadRecording(t *testing.T) { t.Run("success", func(t *testing.T) { mgr := recorder.NewFFmpegManager() data := []byte("dummy video data") - rec := &mockRecorder{id: "main", recordingData: data} + rec := &mockRecorder{id: "default", recordingData: data} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") svc, err := New(mgr, newMockFactory()) @@ -159,7 +190,7 @@ func TestApiService_DownloadRecording(t *testing.T) { func TestApiService_Shutdown(t *testing.T) { ctx := context.Background() mgr := recorder.NewFFmpegManager() - rec := &mockRecorder{id: "main", isRecordingFlag: true} + rec := &mockRecorder{id: "default", isRecordingFlag: true} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") svc, err := New(mgr, newMockFactory()) diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 5d8b229e..1c0cf691 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -436,6 +436,7 @@ func (r DownloadRecordingResponse) StatusCode() int { type StartRecordingResponse struct { Body []byte HTTPResponse *http.Response + JSON400 *BadRequestError JSON409 *ConflictError JSON500 *InternalError } @@ -576,6 +577,13 @@ func ParseStartRecordingResponse(rsp *http.Response) (*StartRecordingResponse, e } switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: var dest ConflictError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -953,6 +961,15 @@ func (response StartRecording201Response) VisitStartRecordingResponse(w http.Res return nil } +type StartRecording400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response StartRecording400JSONResponse) VisitStartRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + type StartRecording409JSONResponse struct{ ConflictErrorJSONResponse } func (response StartRecording409JSONResponse) VisitStartRecordingResponse(w http.ResponseWriter) error { @@ -1138,24 +1155,24 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index b9dc5b45..9390e522 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -5,7 +5,7 @@ info: paths: /recording/start: post: - summary: Start a screen recording. Only one recording can be active at a time. + summary: Start a screen recording. Only one recording per ID can be registered at a time. operationId: startRecording requestBody: required: false @@ -16,6 +16,8 @@ paths: responses: "201": description: Recording started + "400": + $ref: "#/components/responses/BadRequestError" "409": description: A recording is already in progress $ref: "#/components/responses/ConflictError" @@ -23,7 +25,7 @@ paths: $ref: "#/components/responses/InternalError" /recording/stop: post: - summary: Stop the current recording + summary: Stop the recording operationId: stopRecording requestBody: required: false From f94d27c792b7591df25142f1ae8afb0a69da7e12 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Mon, 14 Jul 2025 12:02:26 -0700 Subject: [PATCH 15/23] bug bot :bless: --- server/lib/recorder/ffmpeg.go | 1 + 1 file changed, 1 insertion(+) diff --git a/server/lib/recorder/ffmpeg.go b/server/lib/recorder/ffmpeg.go index a583a3f1..ebaa8080 100644 --- a/server/lib/recorder/ffmpeg.go +++ b/server/lib/recorder/ffmpeg.go @@ -172,6 +172,7 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { _ = fr.stz.Enable(ctx) fr.mu.Lock() fr.ffmpegErr = err + fr.cmd = nil // reset cmd on failure to start so IsRecording() remains correct close(fr.exited) fr.mu.Unlock() return fmt.Errorf("failed to start ffmpeg process: %w", err) From df2525b0120bad1d671906173d8a94060de810a4 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Mon, 14 Jul 2025 12:17:42 -0700 Subject: [PATCH 16/23] bug bot :bless: --- server/cmd/api/api/api_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/cmd/api/api/api_test.go b/server/cmd/api/api/api_test.go index eb6c8b6f..1636e129 100644 --- a/server/cmd/api/api/api_test.go +++ b/server/cmd/api/api/api_test.go @@ -63,7 +63,6 @@ func TestApiService_StartRecording(t *testing.T) { } out := mgr.ListActiveRecorders(ctx) - require.NoError(t, err) assert.Equal(t, 5, len(out)) assert.NotContains(t, out, "default") @@ -71,7 +70,6 @@ func TestApiService_StartRecording(t *testing.T) { require.NoError(t, err) out = mgr.ListActiveRecorders(ctx) - require.NoError(t, err) assert.Equal(t, 0, len(out)) }) } From 346376e34ba5ecc7dd028432d2150021e8ef8041 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Mon, 14 Jul 2025 12:35:19 -0700 Subject: [PATCH 17/23] list all recorders --- server/cmd/api/api/api.go | 14 ++ server/cmd/api/api/api_test.go | 2 +- server/lib/oapi/oapi.go | 244 ++++++++++++++++++++++++++++---- server/lib/recorder/ffmpeg.go | 13 +- server/lib/recorder/recorder.go | 4 +- server/openapi.yaml | 23 +++ 6 files changed, 266 insertions(+), 34 deletions(-) diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 24624af7..68193d16 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -164,6 +164,20 @@ func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRec }, nil } +// ListRecorders returns a list of all registered recorders and whether each one is currently recording. +func (s *ApiService) ListRecorders(ctx context.Context, _ oapi.ListRecordersRequestObject) (oapi.ListRecordersResponseObject, error) { + infos := []oapi.RecorderInfo{} + + recs := s.recordManager.ListActiveRecorders(ctx) + for _, r := range recs { + infos = append(infos, oapi.RecorderInfo{ + Id: r.ID(), + IsRecording: r.IsRecording(ctx), + }) + } + return oapi.ListRecorders200JSONResponse(infos), nil +} + func (s *ApiService) Shutdown(ctx context.Context) error { return s.recordManager.StopAll(ctx) } diff --git a/server/cmd/api/api/api_test.go b/server/cmd/api/api/api_test.go index 1636e129..e47b11d9 100644 --- a/server/cmd/api/api/api_test.go +++ b/server/cmd/api/api/api_test.go @@ -70,7 +70,7 @@ func TestApiService_StartRecording(t *testing.T) { require.NoError(t, err) out = mgr.ListActiveRecorders(ctx) - assert.Equal(t, 0, len(out)) + assert.Equal(t, 5, len(out)) }) } diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 1c0cf691..cc3cc8cc 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -28,6 +28,12 @@ type Error struct { Message string `json:"message"` } +// RecorderInfo defines model for RecorderInfo. +type RecorderInfo struct { + Id string `json:"id"` + IsRecording bool `json:"isRecording"` +} + // StartRecordingRequest defines model for StartRecordingRequest. type StartRecordingRequest struct { // Framerate Recording framerate in fps (overrides server default) @@ -152,6 +158,9 @@ type ClientInterface interface { // DownloadRecording request DownloadRecording(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListRecorders request + ListRecorders(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // StartRecordingWithBody request with any body StartRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -175,6 +184,18 @@ func (c *Client) DownloadRecording(ctx context.Context, params *DownloadRecordin return c.Client.Do(req) } +func (c *Client) ListRecorders(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListRecordersRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) StartRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewStartRecordingRequestWithBody(c.Server, contentType, body) if err != nil { @@ -272,6 +293,33 @@ func NewDownloadRecordingRequest(server string, params *DownloadRecordingParams) return req, nil } +// NewListRecordersRequest generates requests for ListRecorders +func NewListRecordersRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/recording/list") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewStartRecordingRequest calls the generic StartRecording builder with application/json body func NewStartRecordingRequest(server string, body StartRecordingJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -398,6 +446,9 @@ type ClientWithResponsesInterface interface { // DownloadRecordingWithResponse request DownloadRecordingWithResponse(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) + // ListRecordersWithResponse request + ListRecordersWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListRecordersResponse, error) + // StartRecordingWithBodyWithResponse request with any body StartRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) @@ -433,6 +484,29 @@ func (r DownloadRecordingResponse) StatusCode() int { return 0 } +type ListRecordersResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *[]RecorderInfo + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r ListRecordersResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListRecordersResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type StartRecordingResponse struct { Body []byte HTTPResponse *http.Response @@ -489,6 +563,15 @@ func (c *ClientWithResponses) DownloadRecordingWithResponse(ctx context.Context, return ParseDownloadRecordingResponse(rsp) } +// ListRecordersWithResponse request returning *ListRecordersResponse +func (c *ClientWithResponses) ListRecordersWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListRecordersResponse, error) { + rsp, err := c.ListRecorders(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseListRecordersResponse(rsp) +} + // StartRecordingWithBodyWithResponse request with arbitrary body returning *StartRecordingResponse func (c *ClientWithResponses) StartRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) { rsp, err := c.StartRecordingWithBody(ctx, contentType, body, reqEditors...) @@ -563,6 +646,39 @@ func ParseDownloadRecordingResponse(rsp *http.Response) (*DownloadRecordingRespo return response, nil } +// ParseListRecordersResponse parses an HTTP response from a ListRecordersWithResponse call +func ParseListRecordersResponse(rsp *http.Response) (*ListRecordersResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListRecordersResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest []RecorderInfo + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseStartRecordingResponse parses an HTTP response from a StartRecordingWithResponse call func ParseStartRecordingResponse(rsp *http.Response) (*StartRecordingResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -641,10 +757,13 @@ type ServerInterface interface { // Download the most recently recorded video file // (GET /recording/download) DownloadRecording(w http.ResponseWriter, r *http.Request, params DownloadRecordingParams) - // Start a screen recording. Only one recording can be active at a time. + // List all recorders + // (GET /recording/list) + ListRecorders(w http.ResponseWriter, r *http.Request) + // Start a screen recording. Only one recording per ID can be registered at a time. // (POST /recording/start) StartRecording(w http.ResponseWriter, r *http.Request) - // Stop the current recording + // Stop the recording // (POST /recording/stop) StopRecording(w http.ResponseWriter, r *http.Request) } @@ -659,13 +778,19 @@ func (_ Unimplemented) DownloadRecording(w http.ResponseWriter, r *http.Request, w.WriteHeader(http.StatusNotImplemented) } -// Start a screen recording. Only one recording can be active at a time. +// List all recorders +// (GET /recording/list) +func (_ Unimplemented) ListRecorders(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Start a screen recording. Only one recording per ID can be registered at a time. // (POST /recording/start) func (_ Unimplemented) StartRecording(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } -// Stop the current recording +// Stop the recording // (POST /recording/stop) func (_ Unimplemented) StopRecording(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) @@ -707,6 +832,20 @@ func (siw *ServerInterfaceWrapper) DownloadRecording(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } +// ListRecorders operation middleware +func (siw *ServerInterfaceWrapper) ListRecorders(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListRecorders(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // StartRecording operation middleware func (siw *ServerInterfaceWrapper) StartRecording(w http.ResponseWriter, r *http.Request) { @@ -851,6 +990,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/recording/download", wrapper.DownloadRecording) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/recording/list", wrapper.ListRecorders) + }) r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/recording/start", wrapper.StartRecording) }) @@ -945,6 +1087,31 @@ func (response DownloadRecording500JSONResponse) VisitDownloadRecordingResponse( return json.NewEncoder(w).Encode(response) } +type ListRecordersRequestObject struct { +} + +type ListRecordersResponseObject interface { + VisitListRecordersResponse(w http.ResponseWriter) error +} + +type ListRecorders200JSONResponse []RecorderInfo + +func (response ListRecorders200JSONResponse) VisitListRecordersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ListRecorders500JSONResponse struct{ InternalErrorJSONResponse } + +func (response ListRecorders500JSONResponse) VisitListRecordersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type StartRecordingRequestObject struct { Body *StartRecordingJSONRequestBody } @@ -1027,10 +1194,13 @@ type StrictServerInterface interface { // Download the most recently recorded video file // (GET /recording/download) DownloadRecording(ctx context.Context, request DownloadRecordingRequestObject) (DownloadRecordingResponseObject, error) - // Start a screen recording. Only one recording can be active at a time. + // List all recorders + // (GET /recording/list) + ListRecorders(ctx context.Context, request ListRecordersRequestObject) (ListRecordersResponseObject, error) + // Start a screen recording. Only one recording per ID can be registered at a time. // (POST /recording/start) StartRecording(ctx context.Context, request StartRecordingRequestObject) (StartRecordingResponseObject, error) - // Stop the current recording + // Stop the recording // (POST /recording/stop) StopRecording(ctx context.Context, request StopRecordingRequestObject) (StopRecordingResponseObject, error) } @@ -1090,6 +1260,30 @@ func (sh *strictHandler) DownloadRecording(w http.ResponseWriter, r *http.Reques } } +// ListRecorders operation middleware +func (sh *strictHandler) ListRecorders(w http.ResponseWriter, r *http.Request) { + var request ListRecordersRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListRecorders(ctx, request.(ListRecordersRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListRecorders") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListRecordersResponseObject); ok { + if err := validResponse.VisitListRecordersResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // StartRecording operation middleware func (sh *strictHandler) StartRecording(w http.ResponseWriter, r *http.Request) { var request StartRecordingRequestObject @@ -1155,24 +1349,26 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "9g8AAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/lib/recorder/ffmpeg.go b/server/lib/recorder/ffmpeg.go index ebaa8080..5c81fbf8 100644 --- a/server/lib/recorder/ffmpeg.go +++ b/server/lib/recorder/ffmpeg.go @@ -411,17 +411,16 @@ func (fm *FFmpegManager) GetRecorder(id string) (Recorder, bool) { return recorder, exists } -func (fm *FFmpegManager) ListActiveRecorders(ctx context.Context) []string { +func (fm *FFmpegManager) ListActiveRecorders(ctx context.Context) []Recorder { fm.mu.Lock() defer fm.mu.Unlock() - var active []string - for id, recorder := range fm.recorders { - if recorder.IsRecording(ctx) { - active = append(active, id) - } + recorders := make([]Recorder, 0, len(fm.recorders)) + for _, recorder := range fm.recorders { + recorders = append(recorders, recorder) } - return active + + return recorders } func (fm *FFmpegManager) DeregisterRecorder(ctx context.Context, recorder Recorder) error { diff --git a/server/lib/recorder/recorder.go b/server/lib/recorder/recorder.go index cbcc9140..e8a971e3 100644 --- a/server/lib/recorder/recorder.go +++ b/server/lib/recorder/recorder.go @@ -29,8 +29,8 @@ type RecordManager interface { // Returns the recorder and true if found, nil and false otherwise. GetRecorder(id string) (Recorder, bool) - // ListActiveRecorders returns a list of IDs for all currently recording recorders. - ListActiveRecorders(ctx context.Context) []string + // ListActiveRecorders returns a list of IDs for all registered recorders + ListActiveRecorders(ctx context.Context) []Recorder // DeregisterRecorder removes a recorder from the manager. DeregisterRecorder(ctx context.Context, recorder Recorder) error diff --git a/server/openapi.yaml b/server/openapi.yaml index 9390e522..97e6f6ac 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -84,6 +84,21 @@ paths: $ref: "#/components/responses/NotFoundError" "500": $ref: "#/components/responses/InternalError" + /recording/list: + get: + summary: List all recorders + operationId: listRecorders + responses: + "200": + description: List of recorders + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/RecorderInfo" + "500": + $ref: "#/components/responses/InternalError" components: schemas: StartRecordingRequest: @@ -126,6 +141,14 @@ components: properties: message: type: string + RecorderInfo: + type: object + required: [id, isRecording] + properties: + id: + type: string + isRecording: + type: boolean responses: BadRequestError: description: Bad Request From bdd0e5baef780b581e770411e60919b914997b6a Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Mon, 14 Jul 2025 14:25:22 -0700 Subject: [PATCH 18/23] bug bot :bless: --- server/cmd/api/api/api_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/cmd/api/api/api_test.go b/server/cmd/api/api/api_test.go index e47b11d9..f8b7512c 100644 --- a/server/cmd/api/api/api_test.go +++ b/server/cmd/api/api/api_test.go @@ -64,7 +64,9 @@ func TestApiService_StartRecording(t *testing.T) { out := mgr.ListActiveRecorders(ctx) assert.Equal(t, 5, len(out)) - assert.NotContains(t, out, "default") + for _, rec := range out { + assert.NotEqual(t, "default", rec.ID()) + } err = mgr.StopAll(ctx) require.NoError(t, err) From c33d3ee184232b4eef6d1753253653a6448a4321 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Tue, 15 Jul 2025 09:02:22 -0700 Subject: [PATCH 19/23] consistently pass start/finish times --- server/cmd/api/api/api.go | 11 ++++++++ server/cmd/api/api/api_test.go | 4 +++ server/lib/oapi/oapi.go | 49 ++++++++++++++++++--------------- server/lib/recorder/ffmpeg.go | 11 ++++++++ server/lib/recorder/recorder.go | 1 + server/openapi.yaml | 10 +++++++ 6 files changed, 64 insertions(+), 22 deletions(-) diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 68193d16..7b9d8501 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -3,6 +3,7 @@ package api import ( "context" "fmt" + "time" "github.com/onkernel/kernel-images/server/lib/logger" oapi "github.com/onkernel/kernel-images/server/lib/oapi" @@ -168,11 +169,21 @@ func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRec func (s *ApiService) ListRecorders(ctx context.Context, _ oapi.ListRecordersRequestObject) (oapi.ListRecordersResponseObject, error) { infos := []oapi.RecorderInfo{} + timeOrNil := func(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + return &t + } + recs := s.recordManager.ListActiveRecorders(ctx) for _, r := range recs { + m := r.Metadata() infos = append(infos, oapi.RecorderInfo{ Id: r.ID(), IsRecording: r.IsRecording(ctx), + StartedAt: timeOrNil(m.StartTime), + FinishedAt: timeOrNil(m.EndTime), }) } return oapi.ListRecorders200JSONResponse(infos), nil diff --git a/server/cmd/api/api/api_test.go b/server/cmd/api/api/api_test.go index f8b7512c..76beb446 100644 --- a/server/cmd/api/api/api_test.go +++ b/server/cmd/api/api/api_test.go @@ -259,6 +259,10 @@ func (m *mockRecorder) Recording(ctx context.Context) (io.ReadCloser, *recorder. return reader, meta, nil } +func (m *mockRecorder) Metadata() *recorder.RecordingMetadata { + return &recorder.RecordingMetadata{} +} + func newMockFactory() recorder.FFmpegRecorderFactory { return func(id string, _ recorder.FFmpegRecordingParams) (recorder.Recorder, error) { rec := &mockRecorder{id: id} diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index cc3cc8cc..ff18da20 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -30,8 +30,13 @@ type Error struct { // RecorderInfo defines model for RecorderInfo. type RecorderInfo struct { - Id string `json:"id"` - IsRecording bool `json:"isRecording"` + // FinishedAt Timestamp when recording finished + FinishedAt *time.Time `json:"finished_at"` + Id string `json:"id"` + IsRecording bool `json:"isRecording"` + + // StartedAt Timestamp when recording started + StartedAt *time.Time `json:"started_at"` } // StartRecordingRequest defines model for StartRecordingRequest. @@ -1349,26 +1354,26 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/7xXbY8TNxD+K5bLh1bdJHtAK5FvdwWkqOVFHFKrIir51rOJkd+Ymb1jQfnvlb3JJpss", - "OShXvmVtz8zjZ54ZTz7JKrgYPHgmOf8kESgGT5A/LpR+Be8bIH6CGDAtVcEzeE4/VYzWVIpN8LN3FHxa", - "o2oFTqVf9xBqOZc/zHb+Z90uzTpv6/W6kBqoQhOTEzlPAcUmolwX8rfga2uq7xV9Gy6FXngG9Mp+p9Db", - "cOIS8BpQbA4W8nngp6Hx+jvheB5Y5Hgy7W2OJ299/IghArLpFOKASC0h/eQ2gpxLYjR+mc0R3jcGQcv5", - "m/7g22J7MFy9g47rV1AF1IALX4fjEEaPeC+koc4sfe72r0KwoPxReKPl0GQMxyUr5P7IVoeJaK1NIkjZ", - "l3vQamUJigO0NSoHqDhTMuS29yz6Q8J4UUcSP4ZrQDQaSFAnAA21aiz/JAvp1AfjGifnv5aFdMZ3H2f9", - "BYxnWEJWS8fVMOyL2EEXRoNnUxtAUQcUvAKBPSQCIhP8VJzbuFK+cYCmEgHFqo0r8FNZyKg4iVTO5T9v", - "1OTj+eTvcvJo8vbne7I4To9THx43mLW58JdQBa/pGNuz7mp7OPTGKDFDndkt7JwkxKkPT42FS/MRFv7Z", - "xecR1MaCIPMxp+TZxRdm5Kwsy0FSymMQ61GlhfitQgtYQfLT3Slj648eNBfnQBvFYFtBHKK4MbwKDYsl", - "qgrqxgpaNazDjZ+K1ytDwqlWIFBjObGhRBUQm8igxbXREDJZ013W+6IbF+Bip7tQ78kOUHDIgO5IdcdM", - "pyWz6Sps2Ka93wE9WLFwagkkzl8uZCGvAakDW07PpmW6SYjgVTRyLh9My+mDDskqUz/r5TpLpNmg8q2X", - "kHOYktTpXsu5fLw5sOs8yVGqfwYkOX/z2XLtSdrV7VT8uQIvgjPMoIvM5UadDQHl740UevPEoUmO3zeA", - "rSykVy7rM3XE3WvxNTS/LYZjwv2yPHiZskxmLj4cPkl1QKc4KcZ4lcEcuj56kPaaprEgC7kCpTNzn+Rf", - "k3538sSPKO+1cUCsXEzCu0nUDZseeA1DGnqEWjFM2DgYldl+5PxqfH1sSmb/JXqKf7+8f+p9MSSIjbWp", - "eiOGJQJRIaIFRSAYW6GWynhhFQMOKX0FjO3kvE4bRwEum+USKLWBG2VYJHz7bfoK6oDpioxtJ/TdzU51", - "6Xyjh52GxoaWXmuzw3k02z283W44RK0L+cuXRBuOgHkeapxLut2VdU6qC5TrDTzbdlt4+70yG+91DWu6", - "dj/aMf4wxNuJiOStpXZ6CDQMjm6bBgfz166JKkTVjtVkQphUjT3Ku6A0e1XW7rsd0kbbSouBRogbjm+y", - "GwCB+CLo9s4m5/EZsdPwQabOTpXotvy/SfqPbrcb/oW6izxlBoQSVCGA33W0qXjhbSuC3+9yEVAsHotK", - "eXGV1peGGBC0UMlF6iDT4yx3M83nkrw3Of1vOR6ZzkZTXJ5OcYjx21J8B8kKcfjw5Iv8GwAA//9yqpZD", - "9g8AAA==", + "H4sIAAAAAAAC/7xXW2/bRhP9K4v98vAVpSQ6SQtEb3aTAEKbC+wALRqkxZo7lCbYW2aWdpTA/73YpSSS", + "EiPHjZs3kdydy5kzZ0afZeVt8A5cZDn/LAk4eMeQH86UPocPDXB8RuQpvaq8i+Bi+qlCMFipiN7N3rN3", + "6R1XK7Aq/XpAUMu5/N+ssz9rv/KstXZzc1NIDVwRhmREzpNDsfEobwr5i3e1wep7ed+6S64XLgI5Zb6T", + "6607cQF0BSQ2Bwv50sfnvnH6O8Xx0keR/cn0bXM8Wdv5D+QDUMSWIRaY1RLSz7gOIOeSI6Fb5usEHxok", + "0HL+dnfwXbE96C/fQ4v1OVSeNNDC1f7QRY0OeQX6b5WTHsb7Bi1wVDaI6xU4QdkSuqXY3pKFrD3ZdFdq", + "FWES0YIspGuMUZcG5DxSA8V+9IVEPZJUIZHPtz563y+9N6BcOsBRUbxrtJtL/zLYPagx2enHOYb5RfK4", + "O7LtuUQqrTGFq8zrXhlqZRiK/cqQskAqwmGm510htocEOlEHFv/3V0CEGlhwS3YNtWpM/EEW0qqPaBsr", + "5z+XhbTo2oeTXQLoIiyBugIN3b4KbegCNbiINQKJ2pOIK+ijDczo3VScmrBSrrFAWAlPYrUOK3BTWcig", + "YmpIOZd/vVWTT6eTP8vJk8m7Hx/IEapY9fFpQ7kPF+4CKu80H8b2ok2tF4feXErIcHvtFnSOAmLVx+do", + "4AI/wcK9OPtyBDUaEIyfcklenH1lRU7KshwUpTwM4maUaT58K9E8VZDstDnl2HZH94TUWtCoIpi14OiD", + "uMa48k0US1IV1I0RvGqi9tduKt6skIVVa0HAjYkJDSUqT9SECFpcoQafwZp2Ve91+hgBFx3vfN2jHZCI", + "Pgd0T6w7RDq9wo2CRoxJLeSvQA6MWFi1BBanrxeykFdA3AZbTk+mZcrEB3AqoJzLR9Ny+qiNZJWhn+3o", + "OkugGa9y1kvINUxFanmv5Vw+3RzolCcZSv0fgVjO336xXXcgdX07Fb8njfQWYwRdZCw37GwYOD9vqLC7", + "njDEZPhDA7ROwqls5mdSxG4y3gXmd8VwJXpYlntTONNkZsPj4fjdCfklOpWD2Td9MHx7ookm6f4KlM7I", + "fZZ/THZfJ8+cPjZbfN2Ol6HogdMwhGF01BzSrO85T427++7G2x29J/8Py4fH5guy4IjGpO4N5JcEzIUI", + "BhSDiLQWaqnQCaMi0BDSc4i0npzW6cOBg4tmuQROMnCtMIoUX1+mL6H2lFKMtG6J3mV2TKVzRo9bDo0t", + "aDuuzfZ373zv8e33hgvjTSF/+hpvw3U3736NtYm3XVvnolrPud/ARbPeNl5fK/PlnmoYbOV+VDF+Q47b", + "7Y/lra12fOHFCJZv23wHu2YnoopIrcd6MkWYWE27KO8D0mxVGdM3O4SNt50WPI8AN1zfZLsAAsczr9f3", + "9i9hfEdsObxXqZNjLbpt/2+i/pPb7w3/Lt5HnTICQgmuCPoL+1S8cmYtvOurXAASi6eiUk5cpvdL5AgE", + "WqhkIinI9LDK7U7zpSL3Nqf/rMYj29loicvjJfYhfFuJ76FYPgwHT07knwAAAP//+kBG3eIQAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/lib/recorder/ffmpeg.go b/server/lib/recorder/ffmpeg.go index 5c81fbf8..80e5dd93 100644 --- a/server/lib/recorder/ffmpeg.go +++ b/server/lib/recorder/ffmpeg.go @@ -221,6 +221,17 @@ func (fr *FFmpegRecorder) IsRecording(ctx context.Context) bool { return fr.cmd != nil && fr.exitCode < exitCodeProcessDoneMinValue } +// Metadata is an incomplete snapshot of the recording metadata. +func (fr *FFmpegRecorder) Metadata() *RecordingMetadata { + fr.mu.Lock() + defer fr.mu.Unlock() + + return &RecordingMetadata{ + StartTime: fr.startTime, + EndTime: fr.endTime, + } +} + // Recording returns the recording file as an io.ReadCloser. func (fr *FFmpegRecorder) Recording(ctx context.Context) (io.ReadCloser, *RecordingMetadata, error) { file, err := os.Open(fr.outputPath) diff --git a/server/lib/recorder/recorder.go b/server/lib/recorder/recorder.go index e8a971e3..f888b2d5 100644 --- a/server/lib/recorder/recorder.go +++ b/server/lib/recorder/recorder.go @@ -13,6 +13,7 @@ type Recorder interface { Stop(ctx context.Context) error ForceStop(ctx context.Context) error IsRecording(ctx context.Context) bool + Metadata() *RecordingMetadata Recording(ctx context.Context) (io.ReadCloser, *RecordingMetadata, error) // Returns the recording file as a ReadCloser } diff --git a/server/openapi.yaml b/server/openapi.yaml index 97e6f6ac..7a873a00 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -149,6 +149,16 @@ components: type: string isRecording: type: boolean + started_at: + type: string + format: date-time + nullable: true + description: Timestamp when recording started + finished_at: + type: string + format: date-time + nullable: true + description: Timestamp when recording finished responses: BadRequestError: description: Bad Request From 5a4d367817fc37969c0c4d871d3ce672a05525bf Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Tue, 15 Jul 2025 09:54:31 -0700 Subject: [PATCH 20/23] consistency --- server/lib/oapi/oapi.go | 49 +++++++++++++++++++++-------------------- server/openapi.yaml | 6 ++--- 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index ff18da20..9223d520 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -1025,8 +1025,8 @@ type DownloadRecordingResponseObject interface { } type DownloadRecording200ResponseHeaders struct { - XRecordingEnd time.Time - XRecordingStart time.Time + XRecordingFinishedAt time.Time + XRecordingStartedAt time.Time } type DownloadRecording200Videomp4Response struct { @@ -1040,8 +1040,8 @@ func (response DownloadRecording200Videomp4Response) VisitDownloadRecordingRespo if response.ContentLength != 0 { w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) } - w.Header().Set("X-Recording-End", fmt.Sprint(response.Headers.XRecordingEnd)) - w.Header().Set("X-Recording-Start", fmt.Sprint(response.Headers.XRecordingStart)) + w.Header().Set("X-Recording-Finished-At", fmt.Sprint(response.Headers.XRecordingFinishedAt)) + w.Header().Set("X-Recording-Started-At", fmt.Sprint(response.Headers.XRecordingStartedAt)) w.WriteHeader(200) if closer, ok := response.Body.(io.ReadCloser); ok { @@ -1354,26 +1354,27 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "AA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index 7a873a00..32275753 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -55,13 +55,13 @@ paths: "200": description: Recording file headers: - X-Recording-Start: + X-Recording-Started-At: description: Timestamp of when the recording started schema: type: string format: date-time - X-Recording-End: - description: Timestamp of when the recording ended + X-Recording-Finished-At: + description: Timestamp of when the recording finished schema: type: string format: date-time From e86cfc5ee403e282ec279ca4dd93b109f2d14366 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Tue, 15 Jul 2025 09:59:32 -0700 Subject: [PATCH 21/23] whups --- server/cmd/api/api/api.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 7b9d8501..8c222567 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -158,8 +158,8 @@ func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRec return oapi.DownloadRecording200Videomp4Response{ Body: out, Headers: oapi.DownloadRecording200ResponseHeaders{ - XRecordingStart: meta.StartTime, - XRecordingEnd: meta.EndTime, + XRecordingStartedAt: meta.StartTime, + XRecordingFinishedAt: meta.EndTime, }, ContentLength: meta.Size, }, nil From 11fc5b2e9bba95beea6dd2a97bd25852f7f974a7 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Tue, 15 Jul 2025 10:17:23 -0700 Subject: [PATCH 22/23] welp --- server/cmd/api/api/api.go | 4 ++-- server/lib/oapi/oapi.go | 46 +++++++++++++++++++-------------------- server/openapi.yaml | 10 +++++---- 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 8c222567..a4c83bcd 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -158,8 +158,8 @@ func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRec return oapi.DownloadRecording200Videomp4Response{ Body: out, Headers: oapi.DownloadRecording200ResponseHeaders{ - XRecordingStartedAt: meta.StartTime, - XRecordingFinishedAt: meta.EndTime, + XRecordingStartedAt: meta.StartTime.Format(time.RFC3339), + XRecordingFinishedAt: meta.EndTime.Format(time.RFC3339), }, ContentLength: meta.Size, }, nil diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 9223d520..4958b5aa 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -1025,8 +1025,8 @@ type DownloadRecordingResponseObject interface { } type DownloadRecording200ResponseHeaders struct { - XRecordingFinishedAt time.Time - XRecordingStartedAt time.Time + XRecordingFinishedAt string + XRecordingStartedAt string } type DownloadRecording200Videomp4Response struct { @@ -1354,27 +1354,27 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/7xXW2/bRhP9K4v98vAVpSQ6SQtEb3bTAEKbC+wALRqkxZo7JCfYW2aXdpjA/73YpcSL", - "xMi5uH4Tyd2ZMzNnzow+8cJqZw2Y4Pn6EyfwzhoP6eFMyHN434APvxJZiq8KawKYEH8K5xQWIqA1q3fe", - "mvjOFzVoEX89ICj5mv9vNdhfdV/9qrN2c3OTcQm+IHTRCF9Hh2zrkd9k/BdrSoXFfXnfuYuuNyYAGaHu", - "yfXOHbsAugJi24MZf2HDM9sYeU84XtjAkj8ev22PR2u9f0fWAQXsGKLBe1FB/BlaB3zNfSA0VbpO8L5B", - "AsnXb/qDb7PdQXv5Drpcn0NhSQJtTGkPXZRo0Ncg/xEp6Cne16jBB6Edu67BMEqW0FRsd4tnvLSk410u", - "RYBFQA0846ZRSlwq4OtADWT76DOOciaojKM/3/kYfb+0VoEw8YAPgsLXot1e+kawe6nGaGeMcy7nF9Fj", - "f2TXc5FUUmKEK9SrURlKoTxk+5UhoYFEgMNIz4dC7A4xNKx0nv3fXgERSvDMd2SXUIpGhR94xrX4gLrR", - "fP1znnGNpns46QNAE6ACGgo0dfvSddAZSjABSwRipSUWahhnG7xHa5bsVLlamEYDYcEssbp1NZglz7gT", - "ITYkX/O/34jFx9PFX/niyeLtjw/4DFW0+PC0odSHG3MBhTXSH2J73oU2wiG3l2JmfHftluwcTYgWH56h", - "ggv8CBvz/OzzCEpUwDx+TCV5fvaFFTnJ83xSlPwQxM0s06z7XqJZKiDa6WJK2Pqje0KqNUgUAVTLfLCO", - "XWOobRNYRaKAslHM102Q9tos2esaPdOiZQS+USFmQ7DCEjUugGRXKMGmZC2Hqo86fY6Am4F3thzRDogF", - "mwDdEesOMx1f4VZBA4aoFvw3IAOKbbSowLPTVxue8Ssg34HNlyfLPEZiHRjhkK/5o2W+fNQhqVPqVz1d", - "VzFpyooUdQWphrFIHe8lX/On2wOD8kRDsf8DkOfrN59t1z5JQ98u2R9RI63GEEBmKZdbdjYefHreUqG/", - "HnOI0fD7BqiNwil04mdUxGEyfk2a32bTlehhnu9N4USTlXaPp+O3F/JLNCKB2Td9MHxHookq6n4NQqbM", - "feJ/Lvqvi2fb2bY4PTpjbNmNman4jQbjDNjx1Dlk3BjERTeyvgnDMO6+EkIE8TB/eGzeoGc+oFKxmx3Z", - "isD7jDkFwgML1DJRCTRMiQA0TfE5BGoXp2X8cODgoqkq8FEWrgUGFvGNZfsSSksxxEBtR/whsmOqnSJ6", - "3HFqbmHrubfa38XTvce335sukDcZ/+lLvE3X37QLNlpHHg9tnoqqrU/9ByaodteIY+1Ml0cqorCT/1kF", - "+R192G2Dnt/aescXYAyg/W2b8GT3HERVEIl2rkcjwshq6lHeRUqTVaHU2Ow0ballknhZP5O46TrHu4UQ", - "fDizsr2zfw3zO2PH4b1KnRxr0V37fxf1n9x+b/r38S7qlDLABPMFwXiBX7KXRrXMmrHKOSC2ecoKYdhl", - "fF+hD0AgmYgmooIsD6vc7TifK/Jok/rPajyzrc2WOD9eYuvc95X4Dopl3XTwpED+DQAA//8pK9xt8hAA", - "AA==", + "H4sIAAAAAAAC/7xXW2/bRhP9K4v98vAVpSg6TgtEb3ZSF0KbC+wALRqkxZo7lCbYW2aHdpRA/73YpS6k", + "pMi5uHkTyd2ZM2fOXPRR1t4G78BxlJOPkiAG7yLkh3OlL+FdC5F/IfKUXtXeMThOP1UIBmvF6N34bfQu", + "vYv1HKxKvx4QNHIi/zfe2h93X+O4s7ZcLgupIdaEIRmRk+RQrDzKZSGfeNcYrL+X97W75HrqGMgp851c", + "r92JK6AbILE6WMjnni986/R3wvHcs8j+ZPq2Op6sbfwH8gGIsVOIhRjVDNJPXgSQExmZ0M3ydYJ3LRJo", + "OXm9OfimWB/012+h4/oSak8aaOoav++iQYdxDvoflYMe4n2FFiIrG8TtHJygbAndTKxvyUI2nmy6K7Vi", + "GDFakIV0rTHq2oCcMLVQ7KIvJOoDQRUS4+XaR+/7tfcGlEsHIiviL0W7uvSVYHeoxmSnj/MQ51fJ4+bI", + "uuaSqLTGBFeZl700NMpEKHYzQ8oCKYb9SC+3iVgfEuhEE6L4v78BItQQRezErqFRreEfZCGteo+2tXLy", + "c1VIi657ONkEgI5hBrRN0NDti9BBF6jBMTYIJBpPgufQZxtiRO9KcWbCXLnWAmEtPIn5IszBlbKQQXEq", + "SDmRf79Wow9no7+q0ePRmx8fyANSser905ZyHU7dFdTe6biP7VkXWg+HXl1KzMTu2h3sHCXEqvcXaOAK", + "P8DUPTv/NIIGDYiIH3JKnp1/ZkZOqqoaJKXaB7E8qDQfvlVonmpIdrqYMrbN0Z1Gai1oVAxmISL7IG6R", + "575lMSNVQ9MaEecta3/rSvFqjlFYtRAEsTWc2FCi9kRtYNDiBjX4TFa5zXqv0g8JcLrVnW96sgMS7DOg", + "e1LdPtPpFa46KCOnbiF/A3JgxNSqGURx9nIqC3kDFDuwVXlSVikSH8CpgHIiT8uqPO2QzDP1441cx4k0", + "41WOegY5hylJne61nMinqwPbzpMMpfpnoCgnrz9ZrhuStnVbij9Sj/QWmUEXmcuVOtsIMT+vpLC5njjE", + "ZPhdC7RIjVPZrM/UEbeT8UtoflMMV6KHVbUzhbNMxjY8Go7fTSO/RqcymF3Te8O31zTRpL4/B6Uzcx/l", + "n6PN19HFaraNzo7OGN90Y2bY/NaDsRS/toqUYwCdtHkN4vLiyenp6eNyQNa+7vpQrrrB9VVIVkPva4Ek", + "KA+rh8dmD0YRGY1JlR3IzwhiLEQwoCIIpoVQM4VOGMVAQ7ovgWkxOmvShz0HV+1sBjG1iFuFLNKQ7rfw", + "a2g8pUCZFl0RbIM41sFzRI86fR1a3jY6HO/u5fneo7vvDZfJZSF/+hxvw1U474WttUnT25LPqbU+5loE", + "x2axLsp+H82Xex3FYDcKDnaT3zHyejOM8s4yPL4MI4ONd23Fgz1022AVkVocqteEMGmbNijvg9JsVRnT", + "NzukLRdObmQ+HiBuuNrJbjmEyOdeL+7tH8Th/bHT8E6mTo6V6Hrz/SbpP7773vCv5H3kKTMglIg1QX+Z", + "L8ULZxbCu36vC0Bi+lTUyqX+RjDDyECghUomUgcp97Pc7TufSnJvq/rPcnxgczuY4up4in0I35bie0iW", + "D8PxkwP5NwAA///q759s/hAAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index 32275753..1304fb65 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -55,16 +55,18 @@ paths: "200": description: Recording file headers: + # Note: using a `format: date-time` here doesn't work as inteded. The generated code + # does a `fmt.Sprint` on the value when setting the header. Since time.String is a + # non-standard format it makes everyone's life harder so we're skipping the `format` + # directive X-Recording-Started-At: - description: Timestamp of when the recording started + description: Timestamp of when the recording started. Guaranteed to be RFC3339. schema: type: string - format: date-time X-Recording-Finished-At: - description: Timestamp of when the recording finished + description: Timestamp of when the recording finished. Guaranteed to be RFC3339. schema: type: string - format: date-time content: video/mp4: schema: From 69f5ebc8fd6670ccbee746ab754f66ef573ea79d Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Tue, 15 Jul 2025 10:55:54 -0700 Subject: [PATCH 23/23] wording --- server/openapi.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/openapi.yaml b/server/openapi.yaml index 1304fb65..8d284c61 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -55,10 +55,10 @@ paths: "200": description: Recording file headers: - # Note: using a `format: date-time` here doesn't work as inteded. The generated code - # does a `fmt.Sprint` on the value when setting the header. Since time.String is a - # non-standard format it makes everyone's life harder so we're skipping the `format` - # directive + # Note: using a `format: date-time` here doesn't work as intended as the generated code + # calls a `fmt.Sprint` on the value when setting the header. time.String is a + # non-standard format that most parses will barf on, making everyone's life harder, so + # we're skipping the `format` in favor of an explicit description. X-Recording-Started-At: description: Timestamp of when the recording started. Guaranteed to be RFC3339. schema: