Skip to content

Commit 0a7fc31

Browse files
NagyViktclaude
andcommitted
v1.27: eager screenshot pickup + lazy-compress + stale-clipboard overrides + payload telemetry
Five distinct user-reported failure modes from a single afternoon's debugging session, each fixed once the live-state probes made the root cause visible. * Eager fresh-screenshot pickup on every paste (ipc.rs). GNOME's screenshot tool atomically renames its tempfile into place but KEEPS THE FD OPEN for 3–5 s while rendering its in-shell "saved" toast, so inotify's CLOSE_WRITE doesn't fire until that fd closes. A paste right after PrtScr saw the old staged_text and dispatched the URL the user had on the clipboard before. Defence in depth: every paste stats the screenshots dir for the newest PNG/JPG; if its mtime beats staged_image.captured_at, read it inline (~5–20 ms for a 500 KB PNG) and stage before dispatch. Symptom journal entry: "staged screenshot from inotify" landing 4 s AFTER the file's mtime, with stale-text PASTED lines in between. Helpers: `newest_image_in`, `mime_for_path`, `now_unix_secs`. * Lazy compression on the inotify path (inotify_watch.rs). The synchronous `compress_for_attach_env` call re-encodes 4K PNGs to WebP and was blocking the stage for 500–2000 ms on big screens. Fix: read raw bytes and stage them right away (one fs::read, ~5–20 ms), then spawn a std::thread to compress in the background; re-stage only if the compressed result actually wins on bytes. The brief window where X11 serves the raw PNG is fine — selection requests read from our in-memory buffer regardless of size. * 3 s freshness gate on the external-text override (ipc.rs). v1.26 added "if X11 advertises text-only, override staged image with the live text" — correct in principle but fired on EVERY paste right after a screenshot, because the daemon's X11 re-claim is asynchronous (wakes x11.rs via stage_notifier_tx and re-issues SetSelectionOwner ~100–300 ms later) and the probe saw the OLD text targets in the meantime. Gate the probe behind `age > Duration::from_secs(3)` on staged_image; any deliberate text-copy after a screenshot takes at least ~1 s of human action, so 3 s is comfortably above the X11 re-claim race window without being above any real workflow. * Stale-text override probe in the Text branch (ipc.rs). Symmetric to v1.26's image-side override. When kitty's copy_and_clear_or_interrupt fires (Ctrl+C with a live selection), kitty writes the bytes straight to the X11 CLIPBOARD without going through `flashpaste-trigger --stage-text`. The daemon's in-memory staged_text stayed at whatever clipboard-set.sh last set it to, so every paste delivered the old text. Probe live X11; if it differs from the staged bytes, re-stage with the live text. Companion: `~/.config/kitty/kitty.conf` got `map ctrl+c copy_and_clear_or_interrupt` so the kitty side actually copies on Ctrl+C with a selection. * "What are we pasting?" payload telemetry (paste.rs + flashpaste-shoot/src/main.rs). The previous `dispatched image paste pane=%X` told you nothing about WHICH image. v1.27 emits `PASTED image pane=%X kind=image payload_bytes=N payload_mime=image/png payload_name=<basename> payload_path=… ms_*` — every dispatch now self-describes. Same shape on the shoot path: `ms_portal`, `ms_write`, `ms_annotate`, `ms_stage` so the screenshot pipeline's latency is broken down per phase. Companion shell-side improvements (already in main): - `flashpaste-logs` got `[shot]`, `[clip]` (xclip-only by default to avoid the dock flash on Mutter), `[kitty]`, `[claude]` streams and `--all` shortcut. - `bin/clipboard-set.sh` mirrors text to xclip when daemon accepts a stage so the bash fallback dispatcher's `wl-paste --list-types` probe sees text (was seeing image/png from a stale prior screenshot). No behaviour change for the existing fast paths. Daemon dispatch latency stays at single-digit ms; the new pickup probe adds ~1 ms when the screenshots dir is unchanged and ~5–20 ms when there's a fresh file to read. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b70ac5c commit 0a7fc31

4 files changed

Lines changed: 372 additions & 616 deletions

File tree

rs/flashpaste-shoot/src/main.rs

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -554,14 +554,28 @@ async fn main() -> Result<()> {
554554
return run_ocr_only_path().await;
555555
}
556556

557+
// Per-phase timing. Emitted as a single SHOT structured line at the
558+
// end so the user can see exactly where the screenshot-to-ready time
559+
// is going. User asked (2026-05-19): "can we measure the speed with
560+
// a cli logs?" — so we always emit the summary, not gated on -v.
561+
let t_start = std::time::Instant::now();
562+
let mut t_phase = t_start;
563+
let mut take_phase = || -> u64 {
564+
let now = std::time::Instant::now();
565+
let ms = now.duration_since(t_phase).as_millis() as u64;
566+
t_phase = now;
567+
ms
568+
};
569+
557570
// 1. Portal capture (with timeout).
558571
let portal_path = timeout(
559572
Duration::from_millis(cli.timeout_ms),
560573
take_portal_screenshot(cli.interactive),
561574
)
562575
.await
563576
.map_err(|_| anyhow!("portal screenshot timed out after {} ms", cli.timeout_ms))??;
564-
info!(src = %portal_path.display(), "portal screenshot captured");
577+
let ms_portal = take_phase();
578+
info!(src = %portal_path.display(), ms_portal, "portal screenshot captured");
565579

566580
// 2. Determine output path.
567581
let output_path: PathBuf = match cli.output {
@@ -573,33 +587,55 @@ async fn main() -> Result<()> {
573587
dir.join(format!("flashpaste-shoot-{}.png", unix_secs()))
574588
}
575589
};
590+
let ms_outpath = take_phase();
576591

577592
// 3. Stream bytes from portal temp file to output path. Sniff mime
578593
// while we're at it.
579594
let mime = copy_with_sniff(&portal_path, &output_path)
580595
.context("copy portal output to destination")?;
581-
info!(dst = %output_path.display(), mime = mime, "wrote screenshot");
596+
let ms_write = take_phase();
597+
info!(dst = %output_path.display(), mime = mime, ms_write, "wrote screenshot");
582598

583599
// 4. Optional annotation pass. Runs BEFORE daemon stage so the
584600
// daemon sees the annotated bytes, not the raw capture. Blocking
585601
// by design — the user is in the editor and we want the final
586602
// file before we tell anyone about it.
587-
if cli.annotate {
603+
let ms_annotate = if cli.annotate {
588604
if let Err(e) = run_annotate(&output_path) {
589605
warn!("annotate failed ({e:#}); proceeding with raw capture");
590606
}
591-
}
607+
take_phase()
608+
} else {
609+
take_phase()
610+
};
592611

593612
// 5. Best-effort daemon stage. The file is already on disk so even if
594613
// this fails, auto-pickup in tmux-paste-dispatch.sh will find it.
595-
if !cli.no_daemon {
614+
let ms_daemon = if !cli.no_daemon {
596615
match try_stage_to_daemon(&output_path, mime).await {
597616
Ok(()) => info!("daemon stage ok"),
598617
Err(e) => warn!("daemon stage failed ({e:#}); file on disk is the fallback"),
599618
}
619+
take_phase()
600620
} else {
601621
debug!("--no-daemon set; skipping daemon stage");
602-
}
622+
take_phase()
623+
};
624+
625+
let ms_total = t_start.elapsed().as_millis() as u64;
626+
// Single SHOT summary line. Goes to stderr (info!) so stdout stays
627+
// clean for --print-path. Read with `journalctl --user --since="1
628+
// minute ago" | grep SHOT` if running via systemd-launched keybind.
629+
info!(
630+
path = %output_path.display(),
631+
ms_portal,
632+
ms_outpath,
633+
ms_write,
634+
ms_annotate,
635+
ms_daemon,
636+
ms_total,
637+
"SHOT"
638+
);
603639

604640
// 6. Optional OCR. Done AFTER daemon stage so we don't delay the
605641
// clipboard becoming usable — OCR can take a few hundred ms even

rs/flashpasted/src/inotify_watch.rs

Lines changed: 113 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -137,91 +137,128 @@ fn run_watcher(
137137
continue;
138138
}
139139

140-
// Auto-compress before staging. The common case (small PNG
141-
// from PrtScr) is a pure pass-through and is no more expensive
142-
// than the previous `fs::read` — `compress_for_attach` stats
143-
// first and only reads if it returns early. The big-screen
144-
// case (4K multimon → 12 MB PNG) gets re-encoded to ~1 MB
145-
// WebP. Env-vars `FLASHPASTE_MAX_BYTES` and `FLASHPASTE_MAX_DIM`
146-
// tune the thresholds.
140+
// ── Fast path: stage RAW bytes immediately, compress later.
147141
//
148-
// If compression itself errors out we fall back to the raw
149-
// file — staging *something* is better than staging nothing,
150-
// and the user can still pick the file up via auto-pickup.
151-
let (bytes, mime, staged_path) = match compress::compress_for_attach_env(&path) {
152-
Ok((b, m)) if m == "image/png" || m == "image/jpeg" => {
153-
let mime = mime_for_string(&m);
154-
(b, mime, path.clone())
155-
}
156-
Ok((b, m)) => {
157-
// The compressor returned re-encoded bytes (likely
158-
// WebP). Write them to a sibling tmpfile so X11
159-
// selection requests (which serve from-disk in some
160-
// paths) and downstream tools that want a file path
161-
// both see the smaller artifact. The original PNG
162-
// is left in place — never destroy user data.
163-
let tmp = make_compressed_tmp_path(&path, &m);
164-
match std::fs::write(&tmp, &b) {
165-
Ok(()) => {
166-
info!(
167-
src = %path.display(),
168-
dst = %tmp.display(),
169-
bytes = b.len(),
170-
mime = %m,
171-
"wrote compressed sibling for staging"
172-
);
173-
let mime = mime_for_string(&m);
174-
(b, mime, tmp)
175-
}
176-
Err(e) => {
177-
warn!(
178-
path = %tmp.display(),
179-
error = %e,
180-
"failed to write compressed sibling — staging raw bytes from original path"
181-
);
182-
let mime = mime_for_string(&m);
183-
(b, mime, path.clone())
184-
}
185-
}
186-
}
142+
// User feedback (2026-05-19): "screenshot has big delay, can
143+
// it be lightspeed fast?" — the synchronous compression call
144+
// below (`compress_for_attach_env`) re-encodes 4K multimon
145+
// PNGs (12 MB+) to WebP, which can take 500-2000ms on this
146+
// box. During that window the daemon's staged_image slot
147+
// still holds whatever was there before (often stale text),
148+
// so a paste right after PrtScr delivers the OLD content.
149+
//
150+
// Fix: read raw bytes from disk, stage them right now (single
151+
// file read, ~5-20ms), then kick off compression in a
152+
// background spawn_blocking thread that re-stages with the
153+
// smaller artifact once it's ready. The brief window where
154+
// we serve raw bytes is fine — X11 SelectionRequest serves
155+
// from our in-memory buffer regardless of size.
156+
let raw_bytes = match std::fs::read(&path) {
157+
Ok(b) => b,
187158
Err(e) => {
188-
warn!(
189-
path = %path.display(),
190-
error = ?e,
191-
"compress_for_attach failed — falling back to raw read"
192-
);
193-
match std::fs::read(&path) {
194-
Ok(b) => (b, mime_for(&path), path.clone()),
195-
Err(e2) => {
196-
warn!(
197-
path = %path.display(),
198-
error = %e2,
199-
"failed to read new screenshot"
200-
);
201-
continue;
202-
}
203-
}
159+
warn!(path = %path.display(), error = %e, "failed to read new screenshot — skipping stage");
160+
continue;
204161
}
205162
};
206-
let len = bytes.len();
207-
let staged = StagedImage {
208-
bytes: Arc::new(bytes),
209-
mime,
210-
path: staged_path,
163+
let raw_len = raw_bytes.len();
164+
let raw_mime = mime_for(&path);
165+
let staged_raw = StagedImage {
166+
bytes: Arc::new(raw_bytes),
167+
mime: raw_mime,
168+
path: path.clone(),
211169
captured_at: SystemTime::now(),
212170
};
213-
// Cross thread back into tokio for the write. block_on inside
214-
// spawn_blocking is fine — we're not on a worker.
215-
let state_clone = state.clone();
216-
handle.block_on(async move {
217-
state_clone.set_staged_image(staged).await;
218-
});
171+
{
172+
let state_clone = state.clone();
173+
handle.block_on(async move {
174+
state_clone.set_staged_image(staged_raw).await;
175+
});
176+
}
219177
info!(
220178
path = %path.display(),
221-
bytes = len,
222-
mime = mime,
223-
"staged screenshot from inotify"
179+
bytes = raw_len,
180+
mime = raw_mime,
181+
"staged screenshot from inotify (raw, pre-compress)"
224182
);
183+
184+
// Now spawn the compression on a separate blocking thread so
185+
// the inotify loop can immediately serve the next event. If
186+
// compression produces smaller bytes, re-stage. If it errors
187+
// or produces the same bytes, leave the raw stage in place.
188+
let path_for_compress = path.clone();
189+
let state_for_compress = state.clone();
190+
let handle_for_compress = handle.clone();
191+
std::thread::spawn(move || {
192+
let result = compress::compress_for_attach_env(&path_for_compress);
193+
// Reuse the original compression decision tree but only
194+
// *replace* the stage if it actually wins on bytes — never
195+
// regress from a smaller raw to a larger compressed result.
196+
let compressed: Option<(Vec<u8>, &'static str, std::path::PathBuf)> = match result {
197+
Ok((b, m)) if m == "image/png" || m == "image/jpeg" => {
198+
// Pass-through (no real compression happened, or
199+
// shape was already acceptable). Only re-stage
200+
// if bytes actually shrank — otherwise the raw
201+
// stage we did synchronously above is better.
202+
if b.len() < raw_len {
203+
Some((b, mime_for_string(&m), path_for_compress.clone()))
204+
} else {
205+
None
206+
}
207+
}
208+
Ok((b, m)) => {
209+
// Re-encoded (likely WebP). Write sibling tmpfile.
210+
let tmp = make_compressed_tmp_path(&path_for_compress, &m);
211+
match std::fs::write(&tmp, &b) {
212+
Ok(()) => {
213+
info!(
214+
src = %path_for_compress.display(),
215+
dst = %tmp.display(),
216+
bytes = b.len(),
217+
mime = %m,
218+
"wrote compressed sibling for staging"
219+
);
220+
Some((b, mime_for_string(&m), tmp))
221+
}
222+
Err(e) => {
223+
warn!(
224+
path = %tmp.display(),
225+
error = %e,
226+
"failed to write compressed sibling — keeping raw stage"
227+
);
228+
None
229+
}
230+
}
231+
}
232+
Err(e) => {
233+
warn!(
234+
path = %path_for_compress.display(),
235+
error = ?e,
236+
"compress_for_attach failed — keeping raw stage"
237+
);
238+
None
239+
}
240+
};
241+
242+
if let Some((bytes, mime, staged_path)) = compressed {
243+
let len = bytes.len();
244+
let staged = StagedImage {
245+
bytes: Arc::new(bytes),
246+
mime,
247+
path: staged_path,
248+
captured_at: SystemTime::now(),
249+
};
250+
handle_for_compress.block_on(async move {
251+
state_for_compress.set_staged_image(staged).await;
252+
});
253+
info!(
254+
path = %path_for_compress.display(),
255+
bytes = len,
256+
mime = mime,
257+
raw_bytes = raw_len,
258+
"re-staged screenshot after background compression"
259+
);
260+
}
261+
});
225262
}
226263
}
227264
}

0 commit comments

Comments
 (0)