Skip to content

feat(examples): add web-capture gateway#631

Open
streamer45 wants to merge 27 commits into
mainfrom
feat/web-capture-gateway
Open

feat(examples): add web-capture gateway#631
streamer45 wants to merge 27 commits into
mainfrom
feat/web-capture-gateway

Conversation

@streamer45

@streamer45 streamer45 commented Jun 27, 2026

Copy link
Copy Markdown
Owner

Summary

  • New examples/web-capture: a thin Go gateway (mirroring examples/speech-gateway) that renders any web page to video via the Servo plugin. clip.* returns a finite MP4 (oneshot); cast.* serves a live WebM stream (dynamic session). The target URL is the verbatim path suffix, with an optional comma-separated key=value options segment (dur=, res=).
  • cast owns pipeline lifetime — the engine doesn't stop a dynamic pipeline when its MSE viewers disconnect, so the gateway does: one shared session per URL+resolution, viewer refcounting, idle + max-lifetime reaping, and graceful-shutdown teardown. The MSE proxy closes the upstream body on client disconnect so a read parked between frames can't pin the viewer count.
  • Public / URL-only, with an SSRF guard (blocks loopback/private/link-local/CGNAT/cloud-metadata). Auth-gated capture is intentionally out of scope — a future self-host feature.
  • Encoders are swappable profiles, software by default (runs with no GPU), hardware opt-in via --clip-encoder/--cast-encoder (Vulkan H.264, NVENC AV1). Capture is 1:1 (render = encode, default 1080p) so text stays crisp.

Update: clean, fast, progressive delivery

Three linked fixes so a capture shows the right page within ~2–4s and streams in realtime:

  • Cross-session pixel leak (privacy) — fixed. Servo's SoftwareRenderingContext shares a surfman surface pool across instances, and spin_event_loop leaves the last-painted instance's context bound, so a freshly-opened clip/cast could read another, unrelated session's pixels. Now handle_render (a) emits a transparent frame until the current page's first paint (notify_new_frame_ready), re-arming the gate on URL change, and (b) make_current()s the instance's own context immediately before read_to_image — guaranteeing the read captures this instance's surface, never a neighbour's.
  • Regression test. A dedicated integration binary drives two concurrent solid-colour data: pages interleaved and asserts neither leaks the other's pixels. It boots the real Servo engine (software/llvmpipe) and _exit(0)s after asserting — Servo has no clean global shutdown, so normal teardown SIGSEGVs in mozjs' C++ destructors.
  • Progressive clip. The clip muxer is fragmented MP4 (mode: stream); the OpenH264 GOP is now ~1s (one IDR per fps frames) so the first playable fragment lands fast instead of after a full default 2s GOP.

Review & Validation

  • Cast lifetime manager (sessions.go): refcount + idle/max-lifetime reaping, and the disconnect path in proxy.go (proxyMSE).
  • SSRF guard (capture.go validateTarget/isBlockedIP) — are the blocked ranges sufficient for a public instance?
  • Pixel-leak fix: the two make_current() calls in handle_render and the first-paint gate — confirm a freshly-opened capture never shows a prior page (sequential + concurrent).
  • Run the regression test: cd plugins/native/servo && CARGO_TARGET_DIR=../../../target/plugins cargo test --release --test cross_session_leak (heavy Servo build; not in CI).
  • Live end-to-end against a backend with the Servo plugin loaded (not in CI, see Notes): warm clip/cast reach clean playable content within ~2–4s (opening frames transparent, then the real page), then stream at ~fps.

Notes

  • Not yet validated against a live Servo backend. Local checks pass (build, go test incl. -race, golangci-lint, reuse lint, the Rust regression test). The full render path needs a backend with plugin::native::servo loaded.
  • The regression test lives in its own integration binary and is excluded from cargo test --workspace (the Servo plugin is a separate crate with a heavy build); run it explicitly as above.
  • Servo's realtime pacing is by design (a 10s clip renders in 10s); this PR fixes progressive delivery, not render speed.
  • The backend token's role must allow creating/destroying sessions and the plugin::native::servo node.
  • Deployment wiring (Caddy routes, systemd unit, Servo prewarm, auto-minted gateway tokens) lives in the separate demo repo and is not part of this PR.
  • clip and the fMP4 cast profiles are H.264/MP4 and play everywhere (incl. Safari/iOS over transport::http::mse); WebM/VP9 cast may not play in Safari.

Link to Devin session: https://staging.itsdev.in/sessions/ff7a2a8a92524906b9877a8c5c6d5009
Requested by: @streamer45


Devin Review

Status Commit
🟢 Reviewed cf95e4a
Open in Devin Review (Staging)

A thin Go gateway, mirroring examples/speech-gateway, that renders any web
page to video via the Servo plugin: clip.* returns a finite MP4 (oneshot),
cast.* serves a live WebM stream (dynamic session). The target URL is the
verbatim path suffix, with an optional comma-separated options segment
(dur=, vp=) and strong 720p30 defaults.

Worth a reviewer's attention:
- cast owns session lifetime (the engine does not auto-stop a pipeline when
  its MSE viewers disconnect): one shared session per URL+viewport, viewer
  refcount, idle + max-lifetime reaper, and graceful-shutdown teardown. The
  MSE proxy closes the upstream body on client disconnect so a read parked
  between frames can't keep the viewer count pinned.
- SSRF guard (public/URL-only): blocks loopback/private/link-local/CGNAT/
  cloud-metadata targets.
- Encoders are swappable profiles, software by default (runs GPU-free),
  hardware opt-in via --clip-encoder/--cast-encoder.
- Prometheus metrics incl. active sessions/viewers and reap counters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: streamer45 <cstcld91@gmail.com>

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 6 potential issues.

Open in Devin Review (Staging)
Debug

Playground

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: SPDX headers on non-source files are covered by REUSE.toml

CONTRIBUTING.md/AGENTS.md require SPDX headers on all new files, but the new examples/web-capture/.gitignore, .golangci.yml, and go.mod have none. I confirmed these are not violations: REUSE.toml aggregates licensing for .gitignore, **/*.yml, and **/go.mod (see REUSE.toml config-files and **/go.mod annotations), and existing example go.mod files follow the same header-less pattern. No bug.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment thread examples/web-capture/cmd/gateway/main.go
Comment thread examples/web-capture/cmd/gateway/main.go Outdated
Comment on lines +176 to +224
func (m *sessionManager) acquire(ctx context.Context, key, yaml string) (*liveSession, error) {
m.mu.Lock()
if s := m.sessions[key]; s != nil {
s.viewers++
s.idleSince = time.Time{}
m.updateGaugesLocked()
m.mu.Unlock()
select {
case <-s.ready:
if s.createErr != nil {
m.release(s)
return nil, s.createErr
}
return s, nil
case <-ctx.Done():
m.release(s)
return nil, ctx.Err()
}
}
if len(m.sessions) >= m.maxSessions {
m.mu.Unlock()
return nil, errOverCapacity
}
s := &liveSession{key: key, viewers: 1, createdAt: m.now(), ready: make(chan struct{})}
m.sessions[key] = s
m.updateGaugesLocked()
m.mu.Unlock()

// Bound the create call independently of the (possibly long-lived) viewer ctx.
cctx, cancel := context.WithTimeout(ctx, m.createTO)
defer cancel()
id, err := m.skit.createSession(cctx, yaml)

m.mu.Lock()
if err != nil {
delete(m.sessions, key)
s.createErr = err
m.updateGaugesLocked()
m.mu.Unlock()
close(s.ready)
return nil, err
}
s.id = id
m.byID[id] = s
m.mu.Unlock()
close(s.ready)
log.Printf("cast: session %s created (key=%q)", id, key)
return s, nil
}

@staging-devin-ai-integration staging-devin-ai-integration Bot Jun 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Session acquire/release accounting verified balanced across creator, waiter, error and reap paths

I traced the viewer refcounting in sessionManager.acquire/release/reap (examples/web-capture/cmd/gateway/sessions.go:151-218). Key points that initially looked risky but are correct: (1) on the error paths inside acquire (existing-session ctx.Done/createErr at 164-171) acquire itself calls release, and serveCast only defers release after checking err == nil (main.go:306-320), so there is no double-release. (2) On creator create-failure (191-197) the session is removed from the map before createErr is set and ready closed, so a later acquire for the same key always creates fresh rather than serving a stale createErr. (3) m.mu guards both acquire and reap, so the idle-clear (idleSince = time.Time{} + viewers++) and the idle-reap check (viewers==0 && !idleSince.IsZero()) cannot interleave to reap a session that just gained a viewer. (4) Reaping a session with active viewers (max_lifetime) drops the gauge correctly because updateGaugesLocked only sums sessions still in the map, and the later release of the orphaned session is a no-op on the gauge. No bug found.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment on lines +235 to +236
case "", "/", "/favicon.ico", "/robots.txt":
gw.handleUsage(w, r)

@staging-devin-ai-integration staging-devin-ai-integration Bot Jun 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Router intercepts /favicon.ico and /robots.txt, blocking those as capture targets

router() (examples/web-capture/cmd/gateway/main.go:235-236) routes "", /, /favicon.ico, and /robots.txt to the usage page based on the cleaned r.URL.Path. A consequence is that a target whose path is exactly favicon.ico or robots.txt cannot be captured, and any cast/clip request to host root clip.streamkit.dev/ shows usage instead of an error. This is intentional convenience behavior and harmless, but worth noting as a minor edge case.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment thread examples/web-capture/cmd/gateway/main.go
Addresses code-review findings on the gateway:

- SSRF guard: also block 0.0.0.0/8, 198.18.0.0/15, and the IPv4 embedded in
  NAT64 (64:ff9b::/96) and 6to4 (2002::/16) IPv6 addresses.
- cast sessions: create on a background context so a creator disconnecting
  mid-create no longer fails the other deduped viewers (and the create error
  can't masquerade as a viewer "client gave up"); cap viewers up front with a
  clean 503 instead of failing downstream at max_clients; drop the reconcile
  step that could abandon a live session on a transient list miss.
- clip: acquireClip now observes client disconnect (no unbounded queueing on a
  full slot channel), and clip length rounds to whole frames instead of
  truncating sub-second durations.
- proxyMSE reuses a single retry timer; collapse the two near-identical HTTP
  clients into one; document the upstream-status guard (it prevents a
  WriteHeader panic). Fix README idle-TTL default (30, not 45).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: streamer45 <cstcld91@gmail.com>

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread examples/web-capture/cmd/gateway/proxy.go
Comment thread examples/web-capture/cmd/gateway/proxy.go
Comment on lines +37 to +93
func (c *skitClient) createSession(ctx context.Context, yaml string) (string, error) {
payload, err := json.Marshal(struct {
YAML string `json:"yaml"`
}{yaml})
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/sessions", bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
c.auth(req)

resp, err := c.client.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", fmt.Errorf("create session: status %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var out struct {
SessionID string `json:"session_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("decode session response: %w", err)
}
if out.SessionID == "" {
return "", errors.New("empty session_id in response")
}
return out.SessionID, nil
}

func (c *skitClient) destroySession(ctx context.Context, id string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL+"/api/v1/sessions/"+url.PathEscape(id), nil)
if err != nil {
return err
}
c.auth(req)
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
// A 404 means the session is already gone — the desired end state.
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound {
return fmt.Errorf("destroy session: status %d", resp.StatusCode)
}
return nil
}

func (c *skitClient) streamURL(id string) string {
return c.baseURL + "/mse/" + url.PathEscape(id) + "/video"
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Backend API contracts (session create/destroy, MSE path, oneshot multipart) match the gateway

Verified the gateway's assumptions against the backend: create body {"yaml":...} matches CreateSessionRequest and the response session_id matches CreateSessionResponse (apps/skit/src/server/sessions.rs:25-37); the MSE URL "/mse/"+id+"/video" built in skitClient.streamURL matches the backend's format!("/mse/{session_id}{path_suffix}") with mse_path: /video (crates/nodes/src/transport/http_mse.rs:136) and route /mse/{*path}; the oneshot call posts a multipart whose first field is config, matching parse_config_field which rejects anything else first (apps/skit/src/server/oneshot.rs:76-79); and client.input.type: none is a valid InputType::None variant (crates/api/src/yaml/mod.rs:181-188). No mismatch found.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Move the clip/cast pipeline skeletons out of Go string constants into
cmd/gateway/pipelines/*.yml.tmpl, embedded via go:embed — they now read as
standalone (commented) YAML that can be diffed and reviewed on their own.
Rendering is unchanged: the same placeholder substitution (URL still
strconv.Quote'd) and per-profile encoder-block injection. The small per-profile
encoder snippets stay in encoders.go since they're tied to the profile metadata.

Also tighten the cast tests to assert on the `frame_count:` param rather than
the bare word, so a comment mentioning it can't trip them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: streamer45 <cstcld91@gmail.com>

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread examples/web-capture/cmd/gateway/sessions.go
Comment thread examples/web-capture/cmd/gateway/proxy.go
Comment on lines +190 to +192
if _, err := io.Copy(dst, src); err != nil && !errors.Is(err, context.Canceled) {
log.Printf("%s copy response error: %v", endpoint, err)
}

@staging-devin-ai-integration staging-devin-ai-integration Bot Jun 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Cast stream copy logs an error on every normal viewer disconnect

When a cast viewer disconnects, the goroutine at examples/web-capture/cmd/gateway/proxy.go:170-173 closes the upstream body, which makes the io.Copy in streamCopy return a read-on-closed-body error. That error is not context.Canceled, so it falls through to log.Printf("%s copy response error: %v", ...) (proxy.go:190-192). This means routine viewer disconnects produce error-level log noise. Not a correctness bug, but may be worth filtering (e.g. ignore net.ErrClosed/closed-body errors) to keep logs clean.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment on lines +106 to +117
switch {
case optionsSegmentRe.MatchString(headDec) && tail == "":
return "", def, errNoTarget // options given but no target followed
case optionsSegmentRe.MatchString(headDec):
opts, err = parseOptions(headDec, def, maxDur)
if err != nil {
return "", def, err
}
target = tail
default:
target = rest
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Option-segment regex can shadow a target whose first path segment contains '='

optionsSegmentRe (examples/web-capture/cmd/gateway/capture.go:60) matches any first segment shaped like key=value. A target URL whose first path segment legitimately contains = and only lowercase letters before it (e.g. a=b.com/page) is parsed as an options segment, then rejected with errUnknownOption instead of being treated as the target. The README documents the assumption that a real host never contains =, so this is intended; the digit-leading case (30s.com/x) is correctly excluded by ^[a-z]+ and is covered by a test. Flagging only because it's a non-obvious parsing edge.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Second review pass on the gateway:

- shutdownAll no longer leaks a session whose creation is in flight at SIGTERM:
  it marks the manager closed and waits each session's ready channel so it has
  an id to DELETE; acquire rejects new requests once closed. Previously an
  id-less in-flight session was skipped and re-registered after the maps were
  cleared, so its backend pipeline was never torn down.
- streamCopy only logs a copy error when the client is still connected — a
  normal cast disconnect (ctx canceled / closed body) is no longer logged as an
  error, which otherwise floods the log on a busy instance.
- copyHeaders strips the full hop-by-hop set (Connection, Upgrade, TE, Trailer,
  Keep-Alive, Proxy-*), not just Content-Length/Transfer-Encoding.
- serveCast renders the cast YAML lazily, so deduped viewers of an already-
  running stream don't do throwaway render work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: streamer45 <cstcld91@gmail.com>
streamer45 and others added 2 commits June 28, 2026 11:52
Leverage two just-merged backend PRs (auth #634, fMP4 MSE #637):

- cast can now serve H.264 in fragmented MP4 via new h264-sw/h264-hw cast
  profiles — http::mse auto-detects fMP4 (#637), so a plain <video> plays the
  live stream in Safari/iOS. The cast muxer is chosen per profile now (WebM for
  VP9/AV1, fMP4 for H.264); VP9/WebM stays the default for crisp screen text on
  Chromium/Firefox.
- Docs: the gateway no longer needs an admin token — the built-in user role now
  allows transport::http::mse (#634), so a user-role token covers the whole
  pipeline. (The demo's token minting is updated separately.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: streamer45 <cstcld91@gmail.com>
Serve both outputs from one host (web.streamkit.dev): the path is
/{clip|cast}/[options/]{url} — output first, then optional config, then the
target URL. detectMode now reads the mode from the path only (the subdomain
branch is dropped), so any host works. Usage page, README, and tests updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: streamer45 <cstcld91@gmail.com>

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread examples/web-capture/cmd/gateway/sessions.go
Comment on lines +305 to +308
key := fmt.Sprintf("%s|%dx%d", u.String(), opts.resW, opts.resH)
s, err := gw.sessions.acquire(r.Context(), key, func() string {
return renderCastPipeline(u.String(), opts.resW, opts.resH, captureFPS, gw.maxViewers, gw.castBitrate, gw.castEnc)
})

@staging-devin-ai-integration staging-devin-ai-integration Bot Jun 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Viewer count and backend max_clients are exactly equal — reconnect churn can briefly 503

The gateway admits up to maxViewers concurrent viewers per session (sessions.go:162) and the cast pipeline is rendered with max_clients = gw.maxViewers (examples/web-capture/cmd/gateway/main.go:307). Each gateway viewer maps to exactly one backend MSE connection. Because the two limits are identical, a viewer that reconnects (common with MSE) before the previous connection's release/body-close has propagated can momentarily push the backend to max_clients, causing the new connection to be rejected. proxyMSE retries for mseReadyTimeout (8s) so it self-heals, but under heavy churn this can surface as transient latency. Not a correctness bug; consider sizing backend max_clients slightly above the gateway's maxViewers.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

@@ -0,0 +1,26 @@
version: "2"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: SPDX/REUSE compliance for headerless config files is satisfied via REUSE.toml

The new .gitignore and .golangci.yml lack inline SPDX headers, which would appear to violate the CONTRIBUTING/AGENTS rule that all new files carry SPDX headers. I confirmed REUSE.toml at the repo root aggregates **/*.yml, .gitignore, and **/.gitignore with the MPL-2.0 license (and this matches the sibling examples/speech-gateway/.golangci.yml, which is also headerless). So these files are compliant and I did not report them as rule violations. go.sum is present on disk (just omitted from the diff), so the Dockerfile go build is not broken.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

@streamer45 streamer45 changed the title feat(examples): add web-capture gateway (clip/cast.streamkit.dev) feat(examples): add web-capture gateway Jun 28, 2026

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 6 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread examples/web-capture/cmd/gateway/capture.go Outdated
Comment on lines +238 to +248
switch {
case s.viewers == 0 && !s.idleSince.IsZero() && now.Sub(s.idleSince) >= m.idleTTL:
s.reapReason = "idle"
case now.Sub(s.createdAt) >= m.maxLifetime:
s.reapReason = "max_lifetime"
default:
continue
}
delete(m.sessions, key)
delete(m.byID, s.id)
toDestroy = append(toDestroy, s)

@staging-devin-ai-integration staging-devin-ai-integration Bot Jun 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Max-lifetime reap of a session with active viewers under-reports active_viewers briefly

When reap tears down a session via the max_lifetime branch (sessions.go:241) while it still has connected viewers, the session is removed from m.sessions and updateGaugesLocked immediately drops webcapture_active_viewers by those viewers, even though their MSE connections keep streaming until skit kills the pipeline and proxyMSE unblocks. The later release calls on the detached session are no-ops for the gauge (session not in the map). This is a transient cosmetic gauge skew, not a correctness/resource bug — flagging only so it isn't mistaken for a leak.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment on lines +217 to +268
func isBlockedIP(ip net.IP) bool {
if ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() {
return true
}
if v4 := ip.To4(); v4 != nil {
return blockedV4(v4)
}
// IPv6 forms that embed an internal IPv4 (NAT64, 6to4) dodge the v4 checks
// above — extract the embedded address and re-check it.
if v4 := embeddedV4(ip); v4 != nil {
return isBlockedIP(v4)
}
return false
}

// blockedV4 covers reserved IPv4 ranges that the net.IP predicates miss.
func blockedV4(v4 net.IP) bool {
switch {
case v4[0] == 0: // 0.0.0.0/8 "this host" — 0.0.0.1 reaches localhost on Linux
return true
case v4[0] == 100 && v4[1]&0xc0 == 64: // 100.64.0.0/10 CGNAT
return true
case v4[0] == 198 && v4[1]&0xfe == 18: // 198.18.0.0/15 benchmarking
return true
default:
return false
}
}

// embeddedV4 returns the IPv4 embedded in a NAT64 (64:ff9b::/96) or 6to4
// (2002::/16) address, or nil for anything else.
func embeddedV4(ip net.IP) net.IP {
v6 := ip.To16()
if v6 == nil || ip.To4() != nil {
return nil
}
nat64 := v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b &&
v6[4] == 0 && v6[5] == 0 && v6[6] == 0 && v6[7] == 0 &&
v6[8] == 0 && v6[9] == 0 && v6[10] == 0 && v6[11] == 0
if nat64 {
return net.IPv4(v6[12], v6[13], v6[14], v6[15])
}
if v6[0] == 0x20 && v6[1] == 0x02 { // 6to4
return net.IPv4(v6[2], v6[3], v6[4], v6[5])
}
return nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: SSRF IP blocklist covers IPv4-mapped/NAT64/6to4 embeddings correctly

I verified the reserved-range arithmetic in blockedV4 and embeddedV4: 100.64.0.0/10 via v4[1]&0xc0==64, 198.18.0.0/15 via v4[1]&0xfe==18, 0.0.0.0/8 via v4[0]==0, plus NAT64 64:ff9b::/96 and 6to4 2002::/16 extraction recursing through isBlockedIP. All match their documented CIDRs and the test vectors in capture_test.go:128-145. The DNS re-resolution in validateTarget is documented as best-effort (rebinding between check and Servo's fetch is an acknowledged limitation). No bug.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment thread apps/skit/src/config.rs
Comment on lines +1649 to +1668
#[test]
fn config_partial_role_override_deep_merges_builtin() {
figment::Jail::expect_with(|jail| {
jail.create_file(
"skit.toml",
r#"[permissions.roles.user]
allowed_nodes = ["video::*", "containers::*", "transport::http::mse", "plugin::*"]
"#,
)?;
let result = load("skit.toml").expect("load with user override");
let user = result.config.permissions.roles.get("user").expect("user role");
// Fields absent from the TOML must survive from the built-in default
// (deep-merge), not reset to the struct's bool default of false.
assert!(user.create_sessions, "create_sessions preserved by deep-merge");
assert!(user.destroy_sessions, "destroy_sessions preserved by deep-merge");
// The overridden array adds the MSE transport node.
assert!(user.is_node_allowed("transport::http::mse"));
Ok(())
});
}

@staging-devin-ai-integration staging-devin-ai-integration Bot Jun 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Deep-merge config test relies on figment dict-level merge of map values

The new test config_partial_role_override_deep_merges_builtin asserts that overriding only allowed_nodes for the built-in user role preserves create_sessions/destroy_sessions from defaults. This holds because load merges Serialized::defaults(Config::default()) then the TOML (config.rs:1187-1200), and figment merges nested dicts (including HashMap<String,Permissions> values) at the Value level before struct extraction, so absent fields keep the default's true rather than serde's bool default of false (defaults defined at permissions.rs:137-138). The test reflects real existing behavior, not a regression.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment on lines +122 to +181
func (gw *gateway) proxyMSE(w http.ResponseWriter, r *http.Request, s *liveSession) {
ctx := r.Context()
streamURL := gw.skit.streamURL(s.id)
deadline := time.Now().Add(gw.mseReadyTimeout)
retry := time.NewTicker(200 * time.Millisecond)
defer retry.Stop()

var resp *http.Response
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)
if err != nil {
gw.failUpstream(w, "cast", err)
return
}
gw.authReq(req)
r2, err := gw.streamClient.Do(req)
if err == nil && r2.StatusCode == http.StatusOK {
resp = r2
break
}
if r2 != nil {
_, _ = io.Copy(io.Discard, io.LimitReader(r2.Body, 2048))
_ = r2.Body.Close()
}
if ctx.Err() != nil {
return
}
if time.Now().After(deadline) {
if err != nil {
gw.failUpstream(w, "cast", err)
} else {
gw.failUpstream(w, "cast", fmt.Errorf("mse stream not ready (status %d)", r2.StatusCode))
}
return
}
select {
case <-ctx.Done():
return
case <-retry.C:
}
}
defer func() { _ = resp.Body.Close() }()

// Close the upstream body the instant the viewer's connection ends, so
// streamCopy's read unblocks immediately. Without this, a read parked
// between frames keeps the session's viewer count > 0 and dodges idle
// reaping until skit happens to write again. ctx is also canceled when the
// handler returns, so this goroutine never outlives the request.
go func() {
<-ctx.Done()
_ = resp.Body.Close()
}()

copyHeaders(w.Header(), resp.Header)
if w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", "video/webm")
}
w.WriteHeader(http.StatusOK)
streamCopy(ctx, w, resp.Body, "cast")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: upstream_duration metric is never observed for the cast endpoint

webcapture_upstream_duration_seconds is declared with an endpoint label (metrics.go:72-76) and observed in proxyOneshot for clip (proxy.go:93), but proxyMSE never records it for cast. This is harmless (the histogram simply has no cast series) and arguably intentional given the retry-until-ready loop has no single 'time to headers' value, but reviewers expecting per-endpoint upstream latency should know cast is absent.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment thread examples/web-capture/cmd/gateway/capture.go Outdated
@codecov

codecov Bot commented Jun 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.33333% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 85.24%. Comparing base (5faed28) to head (cf95e4a).

Files with missing lines Patch % Lines
apps/skit/src/config.rs 93.33% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #631      +/-   ##
==========================================
+ Coverage   85.22%   85.24%   +0.01%     
==========================================
  Files         249      249              
  Lines       75412    75427      +15     
  Branches     2437     2437              
==========================================
+ Hits        64272    64298      +26     
+ Misses      11134    11123      -11     
  Partials        6        6              
Flag Coverage Δ
backend 85.30% <93.33%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
core 85.79% <ø> (ø)
engine 83.76% <ø> (ø)
api 91.14% <ø> (ø)
nodes 84.88% <ø> (+0.04%) ⬆️
server 85.20% <93.33%> (+<0.01%) ⬆️
plugin-native 84.79% <ø> (ø)
plugin-wasm 95.41% <ø> (ø)
ui-services 86.27% <ø> (ø)
ui-components 70.18% <ø> (ø)
Files with missing lines Coverage Δ
apps/skit/src/config.rs 97.53% <93.33%> (-0.09%) ⬇️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…t fragment fast

Servo's SoftwareRenderingContext shares a surfman surface pool across
instances, and spin_event_loop leaves the last-painted instance's
context bound — so a freshly-opened clip/cast could read another,
unrelated session's pixels (a privacy leak on the public demo).

- Gate surface reads on first paint: until notify_new_frame_ready has
  fired for the current page, emit a transparent frame instead of
  reading a possibly-stale surface; re-arm the gate on URL change.
- Re-bind the instance's rendering context immediately before
  read_to_image (after spin_event_loop) so the read always captures
  this instance's surface, never a concurrent node's.
- Add an isolated integration test that drives two concurrent solid-
  colour data: pages and asserts neither leaks the other's pixels.
- web-capture: tune the OpenH264 GOP to ~1s (one IDR per fps frames)
  so the fragmented-MP4 clip flushes its first playable fragment fast.

Signed-off-by: streamkit-devin <devin@streamkit.dev>

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread plugins/native/servo/src/servo_thread.rs
Comment on lines +130 to +163
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)
if err != nil {
gw.failUpstream(w, "cast", err)
return
}
gw.authReq(req)
r2, err := gw.streamClient.Do(req)
if err == nil && r2.StatusCode == http.StatusOK {
resp = r2
break
}
if r2 != nil {
_, _ = io.Copy(io.Discard, io.LimitReader(r2.Body, 2048))
_ = r2.Body.Close()
}
if ctx.Err() != nil {
return
}
if time.Now().After(deadline) {
if err != nil {
gw.failUpstream(w, "cast", err)
} else {
gw.failUpstream(w, "cast", fmt.Errorf("mse stream not ready (status %d)", r2.StatusCode))
}
return
}
select {
case <-ctx.Done():
return
case <-retry.C:
}
}
defer func() { _ = resp.Body.Close() }()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: proxyMSE retry loop avoids nil deref and leaks responses correctly

In proxyMSE (examples/web-capture/cmd/gateway/proxy.go:130-162), r2 is only dereferenced for r2.StatusCode in the deadline branch when err == nil (guaranteeing r2 != nil), and non-200 responses are drained+closed each iteration before retrying. The success response gets a defer Close plus a goroutine that closes it on ctx.Done(); double-Close on an http.Response.Body is safe. The ticker is stopped via defer. No connection/response leak across the retry window.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

@staging-devin-ai-integration

Copy link
Copy Markdown
Contributor

E2E test results — clean / fast / realtime

Ran the plugin regression test plus a live stack (skit + the fixed Servo .so → web-capture gateway on 127.0.0.1:8080), and analyzed every decoded frame through a pixel-fraction oracle. Summary: the three core properties (isolation, fast-first-frame, realtime) all check out; two checks were blocked by a local software-GL limitation (not a code defect) and are covered by the deterministic regression test + the GPU CI runner.

Isolation — no cross-session leak (the headline fix)
  • A — plugin regression cross_session_leak: two concurrent instances (red/blue data: pages) interleaved → red frames 0 blue px, blue frames 0 red px. PASS (exit 0).
  • B1 — sequential clips via gateway: clip(red) then clip(blue) (and reverse), all 90 frames each: opening frames transparent (not the prior page), then the solid color.
    • blue-after-red: MAX red% = 0.00
    • red-after-blue: MAX blue% = 0.00

isolation

Fast-first-frame + progressive delivery — PASS

clip(dur=6s, example.com): TTFB ≈ 0.99s vs ~6.0s total (first fragment ~16% in, not moov-at-end). MP4 boxes ftyp moov moof mdat moof mdat… (fragmented, ~1 fragment/s from the ~1s GOP). Opening 10 frames transparent (0% red, 0% blue), final frame = real page.

fastframe

Realtime — PASS

ffprobe on the clip: nb_read_frames=180 duration=5.998s30.0 fps, matching Servo's by-design 1:1 pacing.

Blocked / not freshly run (env, not code)
  • Concurrent casts (B2) + recorded in-browser playback (E): software-GL (llvmpipe/Xvfb) leaks GLX pixmaps; after ~4–8 instances the X server fails X_CreatePixmap and the backend dies. Environmental, not the PR — isolation is still proven by the concurrent regression test + sequential gateway clips, and Skit / Test (GPU) is green.
  • Realtime cast over curl (D): cast is an MSE stream (transport::http::mse), not a plain chunked body, so curl gets 0 bytes; realtime proven via the clip path instead.
  • A2 (before-fix leak): not freshly reproduced this session (slow incremental Servo rebuild); not claimed as run.
Latent finding — resize re-arm gate (Comment #35)

The first-paint gate re-arms on URL change but not on a viewport resize via UpdateConfig (servo_thread.rs:644-659). The gateway never issues UpdateConfig (fresh node per URL+resolution), so this path isn't exercised here. Real but out-of-path — a 1-line re-arm (painted = false in the resize branch) or a tracking issue would close it.

CI: 29 checks green (lint-skit, cargo test, GPU, Playwright).

Devin session

A resize via UpdateConfig reallocates the surfman surface, which is
pooled across instances and may hold another instance's pixels until
the resized page repaints. Re-arm the painted gate (as on URL change)
so handle_render emits transparent frames until the post-resize paint,
closing the same cross-session leak on the resize path.

Signed-off-by: streamkit-devin <devin@streamkit.dev>

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 6 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread examples/web-capture/cmd/gateway/proxy.go
Comment thread plugins/native/servo/src/servo_thread.rs
Comment on lines +124 to +133
switch k {
case "dur":
d, err := parseDuration(v)
if err != nil {
return def, err
}
if d > maxDur {
d = maxDur // clamp rather than reject — friendlier for a demo
}
opts.dur = d

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: dur option silently accepted on cast despite being clip-only

parseOptions (examples/web-capture/cmd/gateway/capture.go:124-133) parses dur regardless of mode, and serveCast ignores opts.dur. The README states "dur is clip-only". So /cast/dur=30s/example.com is accepted and silently ignored rather than rejected. The detectMode test deliberately includes /cast/dur=5s/..., indicating this is intentional permissiveness, not a defect — noting only the doc/behavior mismatch.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment thread examples/web-capture/cmd/gateway/main.go
Comment on lines +155 to +213
func (m *sessionManager) acquire(ctx context.Context, key string, renderYAML func() string) (*liveSession, error) {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return nil, errShuttingDown
}
if s := m.sessions[key]; s != nil {
if s.viewers >= m.maxViewers {
m.mu.Unlock()
return nil, errOverCapacity // reject now rather than fail downstream at max_clients
}
s.viewers++
s.idleSince = time.Time{}
m.updateGaugesLocked()
m.mu.Unlock()
select {
case <-s.ready:
if s.createErr != nil {
m.release(s)
return nil, s.createErr
}
return s, nil
case <-ctx.Done():
m.release(s)
return nil, ctx.Err()
}
}
if len(m.sessions) >= m.maxSessions {
m.mu.Unlock()
return nil, errOverCapacity
}
s := &liveSession{key: key, viewers: 1, createdAt: m.now(), ready: make(chan struct{})}
m.sessions[key] = s
m.updateGaugesLocked()
m.mu.Unlock()

// Create on a background context, NOT the first viewer's: if that viewer
// disconnects mid-create, the pipeline must still come up for the other
// deduped waiters (and createErr must not masquerade as context.Canceled).
cctx, cancel := context.WithTimeout(context.Background(), m.createTO)
defer cancel()
id, err := m.skit.createSession(cctx, renderYAML())

m.mu.Lock()
if err != nil {
delete(m.sessions, key)
s.createErr = err
m.updateGaugesLocked()
m.mu.Unlock()
close(s.ready)
return nil, err
}
s.id = id
m.byID[id] = s
m.mu.Unlock()
close(s.ready)
log.Printf("cast: session %s created (key=%q)", id, key)
return s, nil
}

@staging-devin-ai-integration staging-devin-ai-integration Bot Jun 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Session created/destroyed lifecycle and reaper interactions reviewed

Reviewed sessionManager concurrency carefully: dedupe path increments viewers under lock then waits on ready outside the lock with proper release on ctx-cancel/createErr; creator uses a background context so a disconnecting first viewer doesn't abort creation for deduped waiters; reap deletes during range (safe in Go) and tears down max-lifetime sessions even with active viewers; shutdownAll snapshots then waits on in-flight creates (covered by TestSessionManagerShutdownDuringCreate). The double resp.Body.Close() in proxyMSE (defer + ctx-watcher goroutine) is safe for net/http bodies. No viewer-count underflow (guarded by s.viewers > 0). No bug found.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment on lines +217 to +268
func isBlockedIP(ip net.IP) bool {
if ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() {
return true
}
if v4 := ip.To4(); v4 != nil {
return blockedV4(v4)
}
// IPv6 forms that embed an internal IPv4 (NAT64, 6to4) dodge the v4 checks
// above — extract the embedded address and re-check it.
if v4 := embeddedV4(ip); v4 != nil {
return isBlockedIP(v4)
}
return false
}

// blockedV4 covers reserved IPv4 ranges that the net.IP predicates miss.
func blockedV4(v4 net.IP) bool {
switch {
case v4[0] == 0: // 0.0.0.0/8 "this host" — 0.0.0.1 reaches localhost on Linux
return true
case v4[0] == 100 && v4[1]&0xc0 == 64: // 100.64.0.0/10 CGNAT
return true
case v4[0] == 198 && v4[1]&0xfe == 18: // 198.18.0.0/15 benchmarking
return true
default:
return false
}
}

// embeddedV4 returns the IPv4 embedded in a NAT64 (64:ff9b::/96) or 6to4
// (2002::/16) address, or nil for anything else.
func embeddedV4(ip net.IP) net.IP {
v6 := ip.To16()
if v6 == nil || ip.To4() != nil {
return nil
}
nat64 := v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b &&
v6[4] == 0 && v6[5] == 0 && v6[6] == 0 && v6[7] == 0 &&
v6[8] == 0 && v6[9] == 0 && v6[10] == 0 && v6[11] == 0
if nat64 {
return net.IPv4(v6[12], v6[13], v6[14], v6[15])
}
if v6[0] == 0x20 && v6[1] == 0x02 { // 6to4
return net.IPv4(v6[2], v6[3], v6[4], v6[5])
}
return nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: SSRF IP-range checks verified against tests

isBlockedIP/blockedV4/embeddedV4 were checked bit-by-bit: CGNAT 100.64.0.0/10 (v4[1]&0xc0==64), benchmarking 198.18.0.0/15 (v4[1]&0xfe==18), 0.0.0.0/8, plus NAT64 (64:ff9b::/96) and 6to4 (2002::/16) embedded-v4 extraction and recursion all match the table in capture_test.go (including allowing NAT64-of-8.8.8.8). The DNS re-resolution guard is advisory since Servo re-resolves independently (documented). Logic is correct.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

…er parity

Browser visits to /clip/* (Accept: text/html) now get the same autoplay
HTML player page already served for /cast/*, instead of the browser's bare
native media viewer. The page's <video> re-requests the same URL with
Accept: */* and receives the raw progressive MP4, so CLI clients
(curl/ffmpeg) are unaffected. The HTML branch runs before clip-slot acquire,
so a browser visit does not consume render concurrency.

Generalizes castPageTemplate/writeCastPage into playerPageTemplate/
writePlayerPage(kind) shared by both handlers.

Signed-off-by: streamkit-devin <devin@streamkit.dev>
The URL-change branch of handle_update_config re-armed the first-paint
gate but left state.last_good_frame holding the previous URL's frame. If
read_to_image returned None on a tick after the new page painted,
handle_render fell back to that stale frame. Null it on URL change,
mirroring the viewport-resize branch.

Signed-off-by: streamkit-devin <devin@streamkit.dev>
…xample

--max-concurrency 0 sized clipSem as an unbuffered channel, deadlocking
every clip render; clamp to a floor of 1 via clampConcurrency. Also fix
the optionsSegmentRe comment to use the real 'res=' key instead of the
non-existent 'vp='.

Signed-off-by: streamkit-devin <devin@streamkit.dev>

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread examples/web-capture/cmd/gateway/proxy.go
Comment on lines +507 to +521
// Until Servo has painted the current page at least once, the surfman
// surface backing this rendering context may still contain a previous,
// unrelated capture's pixels (surfman reuses surfaces across contexts).
// Emit a fully transparent frame instead of reading stale content, so a
// freshly-opened clip/cast never leaks another session's page.
if !state.delegate.painted.get() {
let len = (state.config.width as usize) * (state.config.height as usize) * 4;
let rgba_data = vec![0u8; len];
state.render_count += 1;
state.render_duration_sum += render_start.elapsed();
if state.result_tx.send(ServoThreadResult::Frame { rgba_data }).is_err() {
instances.remove(node_id);
}
return;
}

@staging-devin-ai-integration staging-devin-ai-integration Bot Jun 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Cross-session pixel-leak fix relies on surfman bind-before-read semantics

The fix in handle_render adds state.rendering_context.make_current() both before spin_event_loop (plugins/native/servo/src/servo_thread.rs:485) and immediately before read_to_image (:544), plus a first-paint gate (:512-521) emitting transparent frames until painted flips, and re-arms painted/clears last_good_frame on URL change (:617-620) and on viewport resize (:664). Note handle_resize (output-only dimension change, :674-696) intentionally does NOT reset painted, which is consistent with the stated rationale that only UpdateConfig reallocates the surfman surface. The correctness of the whole fix hinges on the assumption that read_to_image reads whichever surfman context is currently bound process-wide and that make_current deterministically rebinds this instance's surface — this is exercised by the new tests/cross_session_leak.rs integration test but is not otherwise verifiable from the diff. Worth confirming the assumption holds across surfman versions.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — this is an accurate description of the fix, not a defect. The surfman bind-before-read assumption is exactly what tests/cross_session_leak.rs exercises (two concurrent solid-color data: pages, interleaved ticks, asserting no cross-bleed), and it's pinned to the surfman version vendored via the Servo dep, so it won't silently drift across versions without that test catching it. Leaving the code as-is.

The node only detected WebM Cluster IDs to delimit the init segment and
GOPs, so an H.264 cast stream (fragmented MP4: ftyp+moov init, repeating
moof+mdat fragments) never flipped init_complete and zero bytes reached
clients. Browsers showed nothing for the h264-sw cast encoder the demo
pins.

Detect the container framing from the first packet (ftyp/styp box at
offset 4 => fMP4, else WebM) and drive a parallel fMP4 state machine that
mirrors the WebM init+GOP replay: buffer ftyp+moov as the init segment,
split off the first moof-led fragment, and replay init + latest fragment
to late-joiners. The WebM path is unchanged.

Adds unit tests for framing detection, MP4 box scanning (incl. 64-bit
largesize), init/fragment splitting, cross-packet init buffering, and
GOP buffer capping.

Signed-off-by: streamkit-devin <devin@streamkit.dev>
main landed #637 (fMP4 support in transport::http::mse) independently; adopt
its more robust implementation (box-walking that ignores moof bytes inside
mdat, content_type-authoritative format detection, bounded-memory freeze
guard) and drop this branch's duplicate fMP4 work in http_mse.rs.

Signed-off-by: streamkit-devin <devin@streamkit.dev>
If http.NewRequestWithContext fails in proxyOneshot, the multipart writer
goroutine parks forever on the io.Pipe whose reader is never consumed. Close
the reader on the error path so the worker unwinds. Addresses Devin Review.

Signed-off-by: streamkit-devin <devin@streamkit.dev>
Release-ready bump covering the cross-session pixel-leak privacy fix and the
first-paint transparent-frame gate. Regenerates marketplace/official-plugins.json
via scripts/marketplace/generate_official_plugins.py.

Signed-off-by: streamkit-devin <devin@streamkit.dev>
staging-devin-ai-integration[bot]

This comment was marked as resolved.

A post-teardown 404 from the MSE upstream is indistinguishable by status
from a pre-ready 404, so the readiness loop spun the full mseReadyTimeout
and returned a misleading 502 when a session was reaped (idle/max-lifetime)
just as a deduped late viewer connected. Check session liveness in the loop
and return 503 immediately when the session is gone.

Signed-off-by: streamkit-devin <devin@streamkit.dev>
…SRF guard

::a.b.c.d (IPv4-compatible) bypassed blockedV4 because To4() returns nil,
and the RFC 8215 local-use NAT64 prefix (64:ff9b:1::/48) was not matched by
embeddedV4, so ::169.254.169.254 and 64:ff9b:1::a9fe:a9fe reached the
metadata IP. Extend embeddedV4 to extract the embedded v4 from both forms
and re-screen it; add regression cases for the two bypasses.

Signed-off-by: streamkit-devin <devin@streamkit.dev>
handleCapture ran the full validateTarget (up to a 5s DNS lookup) before the
acceptsHTML short-circuit, so a browser address-bar visit paid the lookup just
to render the static autoplay page. Split out parseTargetURL (scheme +
literal-IP screen, no DNS) for the cheap fail-fast path and defer the hostname
resolution into serveClip/serveCast after the player page is served. Also drop
the dead dur<=0 re-defaulting in serveClip (opts.dur is always >0).

Signed-off-by: streamkit-devin <devin@streamkit.dev>
…ent errors

Extract a transparent_frame(w,h) helper and route the unpainted, panic-fallback,
and read_to_image-miss paths through it, collapsing three copies of the buffer
build. Move the painted-surface read into read_painted_frame and stop discarding
the make_current() Result before read_to_image — a bind failure means the frame
may not be this instance's surface, so log it instead of silently risking the
cross-session leak the bind is meant to prevent.

Signed-off-by: streamkit-devin <devin@streamkit.dev>

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 6 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment on lines +307 to +319
for _, s := range all {
// Wait for an in-flight creation to resolve so we have an id to DELETE;
// tearing down only id-bearing sessions would leak the rest's pipelines.
select {
case <-s.ready:
case <-ctx.Done():
return
}
if s.id != "" && s.createErr == nil {
s.reapReason = "shutdown"
m.destroy(ctx, s)
}
}

@staging-devin-ai-integration staging-devin-ai-integration Bot Jun 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: shutdownAll abandons remaining sessions on context expiry

shutdownAll (examples/web-capture/cmd/gateway/sessions.go:307-319) iterates all and, while waiting for each in-flight create to resolve, returns on ctx.Done(). This abandons any subsequent sessions in all (potentially already-ready) without DELETE, contradicting the documented invariant that shutdown 'never leaks Servo pipelines'. In practice this is benign: m.destroy derives its timeout from the same ctx, so once that budget is exhausted no destroy could succeed anyway. Worth noting only as a best-effort limitation under a single slow create blocking the shutdown budget.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment thread examples/web-capture/cmd/gateway/sessions.go
Comment on lines +627 to 631
// page paints can't fall back to the previous URL's last frame.
state.last_good_frame = None;
// Reset the one-shot post-load gate so the new page gets
// its custom-CSS injection (if any) once it finishes
// loading. Render ticks will run `handle_render`'s

@staging-devin-ai-integration staging-devin-ai-integration Bot Jun 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: painted gate intentionally not reset on compositor output Resize

handle_resize (plugins/native/servo/src/servo_thread.rs:627-649) clears last_good_frame but does NOT reset delegate.painted, unlike the URL-change and viewport-resize paths in handle_update_config which both re-arm it. This is correct: Resize only changes the output (frame) dimensions and leaves the SoftwareRenderingContext/surfman surface and rc_width/rc_height untouched, so the already-painted surface is still this instance's own content and is simply re-scaled to the new output size. No stale/cross-instance pixels can surface, so no re-arm is needed.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment on lines +511 to +540
let rgba_data = if state.delegate.painted.get() {
read_painted_frame(state, node_id)
} else {
// Until Servo has painted the current page at least once, the surfman
// surface backing this rendering context may still contain a previous,
// unrelated capture's pixels (surfman reuses surfaces across contexts).
// Emit a fully transparent frame instead of reading stale content, so a
// freshly-opened clip/cast never leaks another session's page.
transparent_frame(state.config.width, state.config.height)
};

let render_duration = render_start.elapsed();
state.render_count += 1;
state.render_duration_sum += render_duration;

// Log render time periodically (every 300 frames ~ 10s at 30fps).
if state.render_count % 300 == 0 {
let avg_us = state.render_duration_sum.as_micros() / u128::from(state.render_count);
tracing::debug!(
node_id = %node_id,
frame = state.render_count,
render_us = render_duration.as_micros(),
avg_render_us = avg_us,
"Servo render metrics",
);
}

if state.result_tx.send(ServoThreadResult::Frame { rgba_data }).is_err() {
instances.remove(node_id);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: render metrics now include pre-first-paint transparent frames

In the refactored handle_render (plugins/native/servo/src/servo_thread.rs:511-540), render_count / render_duration_sum and the periodic timing log now increment for every tick, including the transparent frames emitted before the first paint. Previously the read path was always exercised. This slightly skews the average-render-time metric (pre-paint ticks are near-instant), but does not affect correctness of the emitted frames.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment thread plugins/native/servo/Cargo.toml Outdated
Comment on lines +247 to +309
func isBlockedIP(ip net.IP) bool {
if ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() {
return true
}
if v4 := ip.To4(); v4 != nil {
return blockedV4(v4)
}
// IPv6 forms that embed an internal IPv4 (NAT64, 6to4) dodge the v4 checks
// above — extract the embedded address and re-check it.
if v4 := embeddedV4(ip); v4 != nil {
return isBlockedIP(v4)
}
return false
}

// blockedV4 covers reserved IPv4 ranges that the net.IP predicates miss.
func blockedV4(v4 net.IP) bool {
switch {
case v4[0] == 0: // 0.0.0.0/8 "this host" — 0.0.0.1 reaches localhost on Linux
return true
case v4[0] == 100 && v4[1]&0xc0 == 64: // 100.64.0.0/10 CGNAT
return true
case v4[0] == 198 && v4[1]&0xfe == 18: // 198.18.0.0/15 benchmarking
return true
default:
return false
}
}

// embeddedV4 returns the IPv4 address an IPv6 form can reach an IPv4 host
// through — so the caller can re-screen it against the internal-range block
// list. Covers NAT64 (well-known 64:ff9b::/96 and RFC 8215 local-use
// 64:ff9b:1::/48), 6to4 (2002::/16), and the deprecated IPv4-compatible
// ::a.b.c.d (::/96); returns nil for anything else.
func embeddedV4(ip net.IP) net.IP {
v6 := ip.To16()
if v6 == nil || ip.To4() != nil {
return nil
}
suffixV4 := net.IPv4(v6[12], v6[13], v6[14], v6[15])
switch {
case bytes.HasPrefix(v6, []byte{0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0}): // NAT64 64:ff9b::/96
return suffixV4
case bytes.HasPrefix(v6, []byte{0x00, 0x64, 0xff, 0x9b, 0x00, 0x01}): // NAT64 64:ff9b:1::/48 (RFC 8215)
return suffixV4
case v6[0] == 0x20 && v6[1] == 0x02: // 6to4 2002::/16
return net.IPv4(v6[2], v6[3], v6[4], v6[5])
case bytes.Equal(v6[:12], ipv4CompatPrefix): // ::a.b.c.d (loopback/unspecified already screened)
return suffixV4
default:
return nil
}
}

// ipv4CompatPrefix is the 12 zero bytes that lead an IPv4-compatible IPv6
// address (::a.b.c.d).
var ipv4CompatPrefix = make([]byte, 12)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: SSRF IPv6-embedded-IPv4 screening verified against test matrix

The isBlockedIP/embeddedV4/blockedV4 logic (examples/web-capture/cmd/gateway/capture.go:247-309) was cross-checked against the test cases. Notably ::1 (loopback) is caught by IsLoopback() before embeddedV4 would (mis)classify it via the all-zero ipv4CompatPrefix, and ::ffff:a.b.c.d is handled by Go's To4() returning non-nil so the standard predicates apply. CGNAT (100.64.0.0/10 via &0xc0==64) and benchmarking (198.18.0.0/15 via &0xfe==18) masks are correct. No gaps found.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

… Connection-listed headers

- Drop gateway.authToken/authReq; route auth through the single skitClient.auth.
- Drop gateway.maxViewers; read from the session manager so the two can't drift.
- copyHeaders now also strips headers named in the Connection header (RFC 7230 §6.1).

Signed-off-by: streamkit-devin <devin@streamkit.dev>
…tained advisory

Resolves the cargo-deny advisory failures from newly published RUSTSEC notices:
- anyhow 1.0.102 -> 1.0.103 (RUSTSEC-2026-0190, downcast_mut unsoundness)
- wasmtime-wasi 46.0.0 -> 46.0.1 (RUSTSEC-2026-0188, WASI FilePerms bypass)
- ignore RUSTSEC-2026-0192 (ttf-parser unmaintained via fontdue; no safe
  upgrade, tracked in #642), matching the existing unmaintained-advisory ignores.

Signed-off-by: streamkit-devin <devin@streamkit.dev>

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: First-paint gate is not re-armed on output-only Resize, by design

handle_update_config re-arms delegate.painted = false on URL change and on viewport (rendering-context) resize because the surfman surface is reallocated (plugins/native/servo/src/servo_thread.rs:625,669-672). handle_resize (output-only hint) deliberately does NOT reset painted — it only changes config.width/height used for output scaling and clears last_good_frame, while the underlying rendering surface still holds this instance's own pixels. This asymmetry is correct (no cross-session leak risk from an output-dimension change) but is subtle; a future change that makes handle_resize touch the rendering context would need to re-arm the gate too.

(Refers to lines 700-704)

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment thread examples/web-capture/cmd/gateway/main.go
Comment thread examples/web-capture/cmd/gateway/sessions.go
Comment on lines +155 to +170
defer func() {
p := recover()
code := rec.code
switch {
case p != nil:
code = http.StatusInternalServerError
case code == 0:
code = codeClientClosed
}
requestDuration.WithLabelValues(endpoint).Observe(time.Since(start).Seconds())
requestsTotal.WithLabelValues(endpoint, methodLabel(r.Method), codeLabel(code)).Inc()
inflightRequests.WithLabelValues(endpoint).Dec()
if p != nil {
panic(p)
}
}()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Panicking handler is counted as 500 even if a 200 header was already sent

In withMetrics, when the wrapped handler panics after it has already written a status (e.g. proxyMSE wrote 200 then panicked mid-stream), the deferred recorder unconditionally overrides code to 500 (examples/web-capture/cmd/gateway/metrics.go:158-163) regardless of rec.code. The client may have received a 200 plus partial body, but webcapture_requests_total records code=500. This is a minor metrics-accuracy nuance, not a functional bug, and the 500 classification is arguably the more useful signal for a panic.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

A create in flight when shutdownAll ran would re-register its id in the
fresh byID map and hand the caller a session that shutdownAll was about
to destroy. Now acquire re-checks closed after the create resolves and
returns errShuttingDown; shutdownAll still tears the pipeline down.

Signed-off-by: streamkit-devin <devin@streamkit.dev>
…der profile

Store encoder content types as plain MIME strings (YAML-quoted at
template render time via strconv.Quote) so proxyMSE can reuse the
active cast profile's type instead of a hardcoded video/webm that would
mislabel H.264/fMP4 streams if the upstream header were ever missing.

Also bound the stream client's wait for upstream response headers so a
hung backend can't pin a viewer request indefinitely.

Signed-off-by: streamkit-devin <devin@streamkit.dev>
…Token field

A max-sessions or max-viewers value < 1 would silently reject every
cast; floor them at 1 with a log line, like max-concurrency.

Signed-off-by: streamkit-devin <devin@streamkit.dev>
Integration tests already see the identical uuid entry from
[dependencies].

Signed-off-by: streamkit-devin <devin@streamkit.dev>
…pendal

No upgrade path exists: opendal 0.57 (latest) pins quick-xml 0.39, while the
fixes land in quick-xml >= 0.41. Exposure is limited to XML responses from
the operator-configured S3 endpoint. Removal tracked in #647.

Signed-off-by: streamkit-devin <devin@streamkit.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants