Skip to content

fix(plugins): servo host logging, bounded reply waits, first-frame instrumentation - #668

Merged
streamer45 merged 4 commits into
mainfrom
devin/1785080144-servo-logging-deadlock-gate
Jul 26, 2026
Merged

fix(plugins): servo host logging, bounded reply waits, first-frame instrumentation#668
streamer45 merged 4 commits into
mainfrom
devin/1785080144-servo-logging-deadlock-gate

Conversation

@staging-devin-ai-integration

@staging-devin-ai-integration staging-devin-ai-integration Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Review & Validation

  • just lint-plugins (servo crate: fmt + clippy -D warnings) — passing locally.
  • Servo unit tests (51) and the cross_session_leak integration test (real engine, software rendering) pass locally.
  • Sanity-check the 30s REPLY_TIMEOUT is comfortably above worst-case shared-thread latency under multi-instance load.
  • Run a web-capture session and confirm [<node_id>]-prefixed servo logs now appear in the skit log, including the "First painted frame read" line.

Notes

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


Devin Review

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

…ts, add first-frame instrumentation

Signed-off-by: streamkit-devin <devin@streamkit.dev>
@streamer45 streamer45 self-assigned this Jul 26, 2026
@streamer45
streamer45 self-requested a review July 26, 2026 15:47
@staging-devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Devin Review found 5 potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread plugins/native/servo/src/servo_thread.rs
Comment thread plugins/native/servo/src/servo_thread.rs Outdated
Comment on lines +430 to +443
fn recv_reply_within(
result_rx: &std::sync::mpsc::Receiver<ServoThreadResult>,
timeout: Duration,
) -> Result<ServoThreadResult, String> {
match result_rx.recv_timeout(timeout) {
Ok(result) => Ok(result),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(format!(
"No reply from Servo thread within {timeout:?} — a reply was likely dropped"
)),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
Err("Servo thread result channel closed".to_string())
},
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

📝 Info: Timed-out replies stay queued and shift subsequent replies by one

recv_reply returns an error on timeout but the dropped/late reply is not drained, so a delayed Frame/Status stays in the 2-slot channel. In practice this is harmless because the host treats a tick error as fatal (crates/plugin-native/src/wrapper.rs:2090-2106 sets NodeState::Failed and breaks the tick loop), so no further requests are issued on that receiver. Worth being aware of if tick errors ever become non-fatal: the node would then consume the previous request's reply.

Open in Devin Review (Staging)

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

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged — a timed-out reply stays queued, which is safe today because the host treats a tick error as fatal and stops issuing requests on that receiver. If tick errors ever become non-fatal, the receiver would need draining (or a request/reply correlation id) first.

Comment on lines 322 to +325
let mut instances: HashMap<NodeId, InstanceState> = HashMap::new();
// Fallback for diagnostics when the instance is gone (or was never
// created): the most recently registered instance's logger.
let mut thread_logger: Option<Logger> = None;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

📝 Info: Cloned Logger crossing to the shared thread is safe with current hosts

The Logger moved into ServoWorkItem::Register outlives the node instance (the shared thread keeps it in InstanceState and as thread_logger fallback, never cleared on Unregister). This is safe only because the native host passes a null user_data (crates/plugin-native/src/wrapper.rs:1180) and the callback routes to the process-global tracing dispatcher; if a future host ever passes an instance-scoped user_data, the retained fallback logger becomes a use-after-free. Also note the SDK's clone caveat (sdks/plugin-sdk/native/src/lib.rs:601-615): these clones never receive the log-enabled callback, so plugin_debug! on the shared thread always formats before the host filters — a minor cost on the per-300-frame metrics line only.

Open in Devin Review (Staging)

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

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct — this relies on the native host passing null user_data and routing to the process-global tracing dispatcher. If a host ever passes instance-scoped user_data, retained loggers would need lifecycle management (e.g. dropping them on Unregister). The missing enabled-callback on clones only affects the per-300-frame debug line's formatting cost.

Comment on lines +20 to +24
/// Upper bound on waiting for a reply from the shared Servo thread. The
/// thread answers every request in bounded time (renders take milliseconds;
/// registration defers the page load), so hitting this means a reply was
/// dropped — fail the call instead of parking the tick thread forever.
const REPLY_TIMEOUT: Duration = Duration::from_secs(30);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🔍 30s reply timeout converts shared-thread saturation into a hard node failure

A tick error from request_frame/request_status is fatal on the host side: the source loop emits NodeState::Failed and breaks (crates/plugin-native/src/wrapper.rs:2090-2106). Because all instances share a single renderer thread with an unbounded work queue, sustained multi-instance load that pushes reply latency past REPLY_TIMEOUT would kill capture nodes that previously merely ran slow. The host's own backstop is 5 minutes (crates/plugin-native/src/wrapper.rs:165), so 30s is the binding limit here — this matches the PR's own review checklist item.

Open in Devin Review (Staging)

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

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes — 30s is deliberately generous (renders are milliseconds; the previous behavior was an unbounded hang) but it does turn extreme shared-thread saturation into a node failure. If that ever bites, the constant can be raised toward the host's 5-minute backstop or made config-driven; flagged in the PR checklist for reviewer judgment.

…e first-frame log on page_painted

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 Author

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 plugins/native/servo/src/servo_thread.rs Outdated
Comment on lines +779 to +790
if page_painted(state) && !state.content_frame_logged {
state.content_frame_logged = true;
// A painted-but-blank surface distinguishes an early/empty paint
// signal from a late paint when diagnosing black capture starts.
let blank = frame.iter().all(|&b| b == 0);
plugin_info!(
state.logger,
"[{node_id}] First painted frame read: pre_paint_frames = {}, since_first_render = {:?}, blank_surface = {blank}",
state.pre_paint_frames,
state.first_render_at.map(|at| at.elapsed()).unwrap_or_default()
);
}

@staging-devin-ai-integration staging-devin-ai-integration Bot Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

📝 Info: Blank-surface scan is one-shot per page and bounded by frame size

frame.iter().all(|&b| b == 0) scans the entire RGBA buffer, but it only runs once per page (guarded by content_frame_logged, which is reset on URL change and viewport resize), so the cost is not on the steady-state per-frame path. Note the scan runs unconditionally at the host's info level regardless of whether the log level is enabled, which for very large output sizes is a one-time multi-megabyte read — acceptable, but not free.

Open in Devin Review (Staging)

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

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged — the scan is one-shot per page, so the cost was accepted for diagnostic value. Could be gated on logger.enabled(Info) if it ever matters, but clones currently lack the enabled callback anyway.

Comment on lines +510 to +518
/// Logger for diagnostics about `node_id`: the instance's own logger, or
/// the most recently registered instance's as a fallback.
fn node_logger<'a>(
instances: &'a HashMap<NodeId, InstanceState>,
fallback: Option<&'a Logger>,
node_id: &NodeId,
) -> Option<&'a Logger> {
instances.get(node_id).map(|s| &s.logger).or(fallback)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

📝 Info: Thread-wide fallback logger attributes diagnostics to the last registered node

thread_logger keeps the most recently registered instance's Logger, and node_logger falls back to it when the instance is gone. Since each host Logger carries the registering node's target/context, diagnostics for an unknown or removed node will be emitted under an unrelated node's log target. The [{node_id}] prefix mitigates confusion, but log filtering/routing per node will still attribute these lines to the wrong node.

Open in Devin Review (Staging)

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

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

True — the fallback logger only fires for events with no live instance (panics for unknown/removed nodes, thread shutdown), where per-node attribution is impossible by definition; the [{node_id}] prefix carries the real identity. All normal-path diagnostics use the instance's own logger.

…ions emit transparent frames

Signed-off-by: streamkit-devin <devin@streamkit.dev>
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 Author

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

Open in Devin Review (Staging)
Debug

Playground

"id": "servo",
"name": "Servo Web Renderer",
"version": "0.2.3",
"version": "0.2.4",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

📝 Info: Version bump now consistent across manifest and marketplace

The marketplace entry for the servo plugin is bumped to 0.2.4, matching plugins/native/servo/plugin.yml:7 and plugins/native/servo/Cargo.toml:7, so the three version declarations stay in sync.

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 Author

Tested at 86f01d5 via the web-capture gateway (plugin rebuilt + skit restarted). Devin session: https://staging.itsdev.in/sessions/f29bf33bf0184c92a23f81e695642b4a

Results

  • ✅ Clip/cast end-to-end: real page content from start to end, no black-frame output.
  • ✅ Shared-thread servo diagnostics now reach the skit log with [<node_id>] prefixes: Created Servo WebView (page load deferred), gate-branch lines (Initial page loaded… and the post-paint …load still pending after 2s… on theverge.com), First painted frame read: pre_paint_frames = 0, since_first_render = 3.8ms, blank_surface = false, and Unregistered Servo instance: total_frames = 300, avg_render_us = 918.
  • ✅ Two concurrent captures (streamkit.dev dark / example.com light): no cross-session pixel leakage; each instance logs under its own node-id prefix.
  • ✅ URL tune on a live cast re-fires the first-frame instrumentation with reset counters (pre_paint_frames = 3); output switched pages ~2.4s after the tune.
🟣 streamkit.dev clip (concurrent) ⚪ example.com clip (concurrent)
dark light
URL tune before/after + theverge.com post-paint branch
🔴 Before tune (example.com) 🟢 After tune (streamkit.dev)
before after

theverge.com clip

Notes

  • One of three tune runs showed an unreproduced anomaly: instrumentation fired and the new page loaded per logs, but the cast output kept the old page for the remaining ~18s of the stream. Likely a pre-existing servo repaint race (URL-tune switching isn't changed by this PR), but flagging it.
  • Not exercisable at runtime: the 30s REPLY_TIMEOUT dropped-reply path (unit-tested only), missing-instance error logs, and the load-timeout expiry branch (SSRF guard blocks hangable targets).

@streamer45
streamer45 merged commit e6c99d6 into main Jul 26, 2026
12 checks passed
@streamer45
streamer45 deleted the devin/1785080144-servo-logging-deadlock-gate branch July 26, 2026 16:20
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