-
Notifications
You must be signed in to change notification settings - Fork 43
feat: gracefully stop and restart recordings during display resize #158
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1a8e693
3d62c2a
0820c4a
c4fd4c7
d2fc5a4
f4d3313
3e47691
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,14 +4,18 @@ import ( | |
| "context" | ||
| "encoding/base64" | ||
| "fmt" | ||
| "log/slog" | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "strconv" | ||
| "strings" | ||
| "time" | ||
|
|
||
| nekooapi "github.com/m1k1o/neko/server/lib/oapi" | ||
| "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" | ||
| ) | ||
|
|
||
| // PatchDisplay updates the display configuration. When require_idle | ||
|
|
@@ -79,6 +83,26 @@ func (s *ApiService) PatchDisplay(ctx context.Context, req oapi.PatchDisplayRequ | |
| } | ||
| } | ||
|
|
||
| // Gracefully stop active recordings so the resize can proceed. | ||
| // Recordings are always restarted (via defer) regardless of whether the | ||
| // resize succeeds — losing recording data is worse than a brief gap. If | ||
| // the resize fails the display is still at the old resolution, so | ||
| // restarting at the "old" resolution is correct. | ||
| stopped, stopErr := s.stopActiveRecordings(ctx) | ||
| if stopErr != nil { | ||
| log.Error("failed to stop recordings for resize", "error", stopErr) | ||
| return oapi.PatchDisplay500JSONResponse{ | ||
| InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{ | ||
| Message: fmt.Sprintf("failed to stop recordings for resize: %s", stopErr), | ||
| }, | ||
| }, nil | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if len(stopped) > 0 { | ||
| defer func() { | ||
| go s.startNewRecordingSegments(context.WithoutCancel(ctx), stopped) | ||
| }() | ||
| } | ||
|
|
||
| // Detect display mode (xorg or xvfb) | ||
| displayMode := s.detectDisplayMode(ctx) | ||
|
|
||
|
|
@@ -361,6 +385,129 @@ func (s *ApiService) getCurrentResolution(ctx context.Context) (int, int, int, e | |
| return width, height, refreshRate, nil | ||
| } | ||
|
|
||
| // stoppedRecordingInfo holds state captured from a recording that was stopped | ||
| // so it can be restarted after a display resize. | ||
| type stoppedRecordingInfo struct { | ||
| id string | ||
| params recorder.FFmpegRecordingParams | ||
| metadata *recorder.RecordingMetadata | ||
| } | ||
|
|
||
| // stopActiveRecordings gracefully stops every recording that is currently in | ||
| // progress. The old recorders remain registered in the manager so their | ||
| // finalized files stay discoverable and downloadable. It returns info needed | ||
| // to start a new recording segment for each stopped recorder. | ||
| func (s *ApiService) stopActiveRecordings(ctx context.Context) ([]stoppedRecordingInfo, error) { | ||
| log := logger.FromContext(ctx) | ||
| var stopped []stoppedRecordingInfo | ||
|
Comment on lines
+396
to
+402
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is another place where we're breaking the record manager interface. I wonder if it's simpler in thinking of these operations as also |
||
|
|
||
| for _, rec := range s.recordManager.ListActiveRecorders(ctx) { | ||
| if !rec.IsRecording(ctx) { | ||
| continue | ||
| } | ||
|
|
||
| id := rec.ID() | ||
|
|
||
| ffmpegRec, ok := rec.(*recorder.FFmpegRecorder) | ||
| if !ok { | ||
| log.Warn("cannot capture params from non-FFmpeg recorder, skipping", "id", id) | ||
| continue | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| params := ffmpegRec.Params() | ||
|
|
||
| log.Info("stopping recording for resize", "id", id) | ||
| if err := rec.Stop(ctx); err != nil { | ||
| // Stop() returns finalization errors even when the process was | ||
| // successfully terminated. Only treat it as a hard failure if | ||
| // the process is still running. | ||
| if rec.IsRecording(ctx) { | ||
| log.Error("failed to stop recording for resize", "id", id, "error", err) | ||
| return stopped, fmt.Errorf("failed to stop recording %s: %w", id, err) | ||
| } | ||
| log.Warn("recording stopped with finalization warning", "id", id, "error", err) | ||
| } | ||
|
|
||
| stopped = append(stopped, stoppedRecordingInfo{ | ||
| id: id, | ||
| params: params, | ||
| metadata: rec.Metadata(), | ||
| }) | ||
| log.Info("recording stopped for resize, old segment preserved", "id", id) | ||
| } | ||
|
|
||
| return stopped, nil | ||
| } | ||
|
|
||
| // adjustParamsForRemainingBudget reduces MaxDurationInSeconds and MaxSizeInMB | ||
| // in the cloned params to reflect what the previous segment already consumed. | ||
| // This keeps cumulative duration and disk usage within the originally requested limits. | ||
| func adjustParamsForRemainingBudget(log *slog.Logger, info stoppedRecordingInfo) recorder.FFmpegRecordingParams { | ||
| params := info.params | ||
|
|
||
| if params.MaxDurationInSeconds != nil && info.metadata != nil && !info.metadata.EndTime.IsZero() { | ||
| elapsed := int(info.metadata.EndTime.Sub(info.metadata.StartTime).Seconds()) | ||
| remaining := *params.MaxDurationInSeconds - elapsed | ||
| if remaining < 1 { | ||
| remaining = 1 | ||
| } | ||
| params.MaxDurationInSeconds = &remaining | ||
| log.Info("adjusted max duration for new segment", "id", info.id, "elapsed_s", elapsed, "remaining_s", remaining) | ||
| } | ||
|
|
||
| if params.MaxSizeInMB != nil && params.OutputDir != nil { | ||
| segmentPath := filepath.Join(*params.OutputDir, info.id+".mp4") | ||
| if fi, err := os.Stat(segmentPath); err == nil { | ||
| consumedMB := int((fi.Size() + 1024*1024 - 1) / (1024 * 1024)) | ||
| remaining := *params.MaxSizeInMB - consumedMB | ||
| if remaining < 1 { | ||
| remaining = 1 | ||
| } | ||
| params.MaxSizeInMB = &remaining | ||
| log.Info("adjusted max size for new segment", "id", info.id, "consumed_mb", consumedMB, "remaining_mb", remaining) | ||
| } | ||
| } | ||
|
|
||
| return params | ||
| } | ||
|
|
||
| // startNewRecordingSegments creates and starts a new recording segment for | ||
| // each previously-stopped recorder. Each new segment gets a unique suffixed | ||
| // ID so the old (stopped) recorder and its finalized file remain accessible | ||
| // in the manager. | ||
| // | ||
| // Duration and size limits are adjusted to account for what the previous | ||
| // segment already consumed, so the cumulative totals stay within the | ||
| // originally requested bounds. | ||
| func (s *ApiService) startNewRecordingSegments(ctx context.Context, stopped []stoppedRecordingInfo) { | ||
| log := logger.FromContext(ctx) | ||
|
|
||
| for _, info := range stopped { | ||
| newID := fmt.Sprintf("%s-%d", info.id, time.Now().UnixMilli()) | ||
|
|
||
| params := adjustParamsForRemainingBudget(log, info) | ||
|
|
||
| rec, err := s.factory(newID, params) | ||
| if err != nil { | ||
| log.Error("failed to create recorder for new segment", "old_id", info.id, "new_id", newID, "error", err) | ||
| continue | ||
| } | ||
|
|
||
| if err := s.recordManager.RegisterRecorder(ctx, rec); err != nil { | ||
| log.Error("failed to register new segment recorder", "old_id", info.id, "new_id", newID, "error", err) | ||
| continue | ||
| } | ||
|
|
||
| if err := rec.Start(ctx); err != nil { | ||
| log.Error("failed to start new segment recording", "old_id", info.id, "new_id", newID, "error", err) | ||
| _ = s.recordManager.DeregisterRecorder(ctx, rec) | ||
| continue | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| log.Info("new recording segment started after resize", "old_id", info.id, "new_id", newID) | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| // isNekoEnabled checks if Neko service is enabled | ||
| func (s *ApiService) isNekoEnabled() bool { | ||
| return os.Getenv("ENABLE_WEBRTC") == "true" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Recording check blocks graceful stop/restart under defaults
High Severity
The
requireIdleblock at line 68–82 still includesisRecordingin theresizableNowcondition ((live == 0) && !isRecording), so any active recording causes a 409 return before the newstopActiveRecordingscode at line 86 is ever reached. SincerequireIdledefaults totrue, the graceful stop-and-restart feature introduced by this PR is effectively unreachable under normal usage. Per the PR description, the live-view check and recording check were supposed to be separated, with only live sessions triggering a 409.Additional Locations (1)
server/cmd/api/api/display.go#L83-L97There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is intentional. The two modes are by design:
require_idle=true(default): strict mode — returns 409 if anything is active (live views or recordings). This preserves the original safety behavior.require_idle=false: graceful mode — stops active recordings, resizes, then starts new recording segments with suffixed IDs.The graceful stop/restart path is reachable when the caller explicitly opts in via
require_idle=false. A follow-up change in the kernel API will thread a user-facing flag down to this parameter.