Files
claude-cloak/src/reload.rs
Jonas H ae454a3d2d Fullscreen the pane while an editor holds it
ctrl-g opens the prompt in $EDITOR, which takes the child's pty over via
the alternate screen — something Claude Code never does itself. The
compact frame looks for the input box's two rules, so an editor could
only ever be cropped by it. Follow the alternate screen instead: give
the pane the whole screen for as long as the editor lasts, then put it
back. Edge-triggered, so ctrl-f still wins.
2026-09-07 11:52:43 +02:00

631 lines
26 KiB
Rust

//! Hot reload: `execve` the binary on disk *into this process* instead of
//! restarting.
//!
//! This module builds nothing and watches nothing. You rebuild however you
//! normally would — `cargo build`, `cargo build --release`, a script — and then
//! press **ctrl-r** in each running instance to swap it onto the new binary.
//! Separating the two is the point: a build is your business, and a running
//! instance should not decide on its own when to become different code.
//!
//! `execve` replaces the program image but keeps the pid, the open file
//! descriptors and the child processes. That is the whole trick, and it is what
//! lets all three things the proxy cares about survive:
//!
//! - **the listener** — the accept socket is inherited by fd number, so the
//! port is never closed and never rebound. Claude Code keeps talking to the
//! same `127.0.0.1:<port>` across the swap;
//! - **the embedded `claude` pane** — still our child, still on the same pty
//! (`term::PtyHandoff` / `EmbeddedTerm::adopt`). It is never told anything; it
//! just gets a repaint;
//! - **the live feed** — sessions/entries/lanes travel in a JSON snapshot.
//!
//! Two rules keep it honest:
//!
//! 1. **Drain before the exec.** It destroys the tokio tasks relaying in-flight
//! responses, so the proxy is asked to stop accepting and finish what it has
//! first. The socket stays open throughout, so requests made during the swap
//! queue in the kernel backlog and are served by the new image.
//! 2. **The snapshot is advisory.** It is written by the *old* binary and read
//! by the *new* one, whose types may have just changed — the normal case
//! when the reason you rebuilt was editing `app.rs`. Every restore step is
//! best-effort: a snapshot that no longer fits costs the feed, never the
//! port and never the pane.
//!
//! Nothing here talks to the network, and nothing here runs a subprocess.
use anyhow::Context;
use crate::app::App;
use std::os::fd::{AsRawFd, FromRawFd, RawFd};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Env var pointing the new image at its snapshot file. Its presence is what
/// distinguishes "started by a reload" from "started by the user".
const HANDOFF_ENV: &str = "CT_RELOAD_HANDOFF";
// ---------------------------------------------------------------------------
// Relative-time serde for the `Instant` fields on Session/Lane
// ---------------------------------------------------------------------------
/// `Instant` has no absolute epoch, so it is snapshotted as "this many ms ago"
/// and rebuilt against the new image's clock. Idle/liveness logic
/// (`Lane::running`, `LANE_IDLE_MAX`) therefore reads the same before and after
/// a reload instead of every lane looking freshly active.
pub mod ms_ago {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::{Duration, Instant};
pub fn serialize<S: Serializer>(v: &Instant, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u64(v.elapsed().as_millis() as u64)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Instant, D::Error> {
let ms = u64::deserialize(d)?;
Ok(back(ms))
}
/// `checked_sub` because a monotonic clock that has not been running long
/// enough cannot represent the age — then "now" is the closest truth.
pub(crate) fn back(ms: u64) -> Instant {
let now = Instant::now();
now.checked_sub(Duration::from_millis(ms)).unwrap_or(now)
}
}
/// `ms_ago` for an `Option<Instant>`.
pub mod ms_ago_opt {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Instant;
pub fn serialize<S: Serializer>(v: &Option<Instant>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(i) => s.serialize_some(&(i.elapsed().as_millis() as u64)),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Instant>, D::Error> {
Ok(Option::<u64>::deserialize(d)?.map(super::ms_ago::back))
}
}
// ---------------------------------------------------------------------------
// Reload status (owned by App, rendered in the status bar)
// ---------------------------------------------------------------------------
#[derive(Default, Clone, PartialEq)]
pub enum Status {
/// Nothing in progress. ctrl-r starts a reload from here.
#[default]
Idle,
/// The proxy is finishing its in-flight responses; the exec follows.
Draining(std::time::Instant),
/// The exec failed, so the old code is still running and still serving.
/// Purely informational — usually "you pressed ctrl-r mid-build".
Failed(String),
}
impl Status {
/// One-line status-bar rendering, or `None` when there is nothing to say.
pub fn note(&self, in_flight: usize) -> Option<String> {
match self {
Status::Idle => None,
Status::Draining(_) if in_flight > 0 => {
Some(format!("⟳ reloading · draining {in_flight} turn(s)…"))
}
Status::Draining(_) => Some("⟳ reloading…".into()),
Status::Failed(e) => Some(format!("⚠ reload failed: {e}")),
}
}
}
/// Cap on the graceful drain. A stuck upstream response must not pin the
/// reload forever; past this the exec happens anyway and that one response is
/// cut — the same outcome as no drain at all, just far less likely.
pub const DRAIN_MAX: Duration = Duration::from_secs(30);
// ---------------------------------------------------------------------------
// The snapshot handed across the exec
// ---------------------------------------------------------------------------
/// Written by the outgoing image, read by the incoming one. The fd numbers in
/// here are only meaningful because `keep_open` cleared their FD_CLOEXEC.
///
/// Read side only — the write side is `HandoffRef`, which borrows the live
/// state instead of moving it, so a failed exec leaves the running app whole.
#[derive(Default, serde::Deserialize)]
#[serde(default)]
pub struct Handoff {
/// Inherited accept socket. `-1` means "bind a new one" (should not happen).
pub listener_fd: RawFd,
/// The embedded pane, when there was a live one.
pub pane: Option<crate::term::PtyHandoff>,
/// The feed, **left unparsed on purpose**. Schema churn is the normal case
/// for a dev tool — the edit that triggered the reload is usually the one
/// that changed these types — and parsing it inline would let one renamed
/// field take the port and the pane down with the feed. It is decoded
/// separately, session by session, in `restore`.
pub app: serde_json::Value,
pub pane_ui: PaneState,
/// Reloads so far, for the status line.
pub generation: u32,
}
/// The part of `App` worth carrying over. Deliberately a separate struct rather
/// than `#[derive(Serialize)] on App`: popups, caches and lazily loaded disk
/// views are cheap to rebuild and would only add schema churn.
#[derive(Default, serde::Deserialize)]
#[serde(default)]
pub struct AppState {
/// One `Value` per session, decoded individually: a session that no longer
/// fits is skipped instead of discarding the whole feed.
pub sessions: Vec<serde_json::Value>,
/// Session the highlight was on. Preferred over `selected`: disk stubs are
/// rescanned asynchronously, so their indices are not stable across the
/// exec, but their uuids are.
pub selected_key: Option<String>,
pub selected: usize,
pub scroll: usize,
pub follow: bool,
pub filters: Vec<bool>,
pub show_sessions: bool,
pub embed_session: Option<String>,
pub embed_token: Option<String>,
}
/// The pane-related UI state that lives on `EmbedUi`, not on `App`.
#[derive(Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct PaneState {
pub visible: bool,
pub focused: bool,
pub fullscreen: bool,
/// That fullscreen was entered for an editor on the child's alternate
/// screen, so it is undone when the editor exits (`ui::sync_alt_screen`).
/// Carried because the editor usually outlives the reload.
pub alt_fullscreen: bool,
pub past_embeds: Vec<String>,
pub compact_inner: u16,
}
// ---------------------------------------------------------------------------
// Startup side: did a reload just hand us the process?
// ---------------------------------------------------------------------------
/// Read (and consume) the handoff this process was exec'd with. Returns `None`
/// for a normal start, and also for a snapshot that no longer parses — an
/// expected outcome when the edit that triggered the reload changed the state
/// types, and the reason this is `Option` rather than `Result`.
pub fn take_handoff() -> Option<Handoff> {
let path = std::env::var_os(HANDOFF_ENV)?;
// Consume it either way: a stale file must never be picked up twice.
// Sound here and nowhere else — `main` has not spawned a thread yet.
unsafe { std::env::remove_var(HANDOFF_ENV) };
let raw = std::fs::read(&path).ok();
let _ = std::fs::remove_file(&path);
let parsed = raw.as_ref().and_then(|b| serde_json::from_slice::<Handoff>(b).ok());
if parsed.is_none() {
// Without the fd numbers the inherited socket and pty are unusable
// (still open, but anonymous), so the kernel closes them when we exit.
// A fresh bind is the safe outcome.
eprintln!("claude-cloak: reload handoff unreadable, starting fresh");
}
parsed
}
/// Rebuild a `std::net::TcpListener` from the inherited fd. The caller converts
/// it to a tokio listener inside the runtime.
///
/// The fd is verified to be a listening socket first: the number comes out of a
/// file on disk, and a stale handoff could name an fd this process has since
/// reused for something else entirely.
pub fn adopt_listener(fd: RawFd) -> anyhow::Result<std::net::TcpListener> {
anyhow::ensure!(fd >= 0, "no inherited listener");
anyhow::ensure!(is_listening(fd), "inherited fd {fd} is not a listening socket");
// Put CLOEXEC back: from here on it is a normal socket again, and the next
// reload clears the flag itself.
unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) };
let l = unsafe { std::net::TcpListener::from_raw_fd(fd) };
l.set_nonblocking(true)?;
Ok(l)
}
fn is_listening(fd: RawFd) -> bool {
let mut on: libc::c_int = 0;
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
let rc = unsafe {
libc::getsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_ACCEPTCONN,
(&raw mut on).cast(),
&mut len,
)
};
rc == 0 && on != 0
}
/// Duplicate the accept socket so the reload can hold the port open while
/// axum's own listener is dropped by the graceful shutdown.
pub fn dup_listener(l: &tokio::net::TcpListener) -> anyhow::Result<RawFd> {
let fd = unsafe { libc::fcntl(l.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) };
if fd < 0 {
return Err(std::io::Error::last_os_error()).context("dup listener");
}
Ok(fd)
}
/// Apply a restored snapshot to a fresh `App`. Every step is best-effort by
/// design — see the module docs.
pub fn restore(app: &mut App, raw: serde_json::Value) {
let s: AppState = serde_json::from_value(raw).unwrap_or_default();
let total = s.sessions.len();
app.sessions = s
.sessions
.into_iter()
.filter_map(|v| serde_json::from_value::<crate::app::Session>(v).ok())
.filter_map(sanitize_session)
.collect();
let kept = app.sessions.len();
app.selected = s.selected;
app.scroll = s.scroll;
app.follow = s.follow;
app.show_sessions = s.show_sessions;
app.embed_session = s.embed_session;
app.embed_token = s.embed_token;
// Length-tolerant: the filter set grows as entry kinds are added, and a
// snapshot from before that must not shift the toggles.
for (i, v) in s.filters.iter().take(app.filters.len()).enumerate() {
app.filters[i] = *v;
}
// The synthetic-lane counter is process-global; restart it past whatever
// the restored sessions already use.
crate::app::seed_server_tool_seq(max_server_tool_seq(&app.sessions) + 1);
app.after_restore(s.selected_key.as_deref());
app.reload_dropped = total - kept;
}
/// Make one restored session safe to render.
///
/// `Entry::lane`, `Lane::first_entry`, `Lane::anchor` and the values of
/// `Session::tool_ids` are all raw indices into vectors that a schema change
/// can shorten. `#[serde(default)]` covers a *missing* field and does nothing
/// for an *inconsistent* one, and the first draw indexes them directly — so
/// they are checked here, once, instead of defensively at every use site.
fn sanitize_session(mut s: crate::app::Session) -> Option<crate::app::Session> {
// `#[serde(default)]` is deliberately forgiving, which means an object that
// is not a session at all still decodes — into an empty one. A live session
// is always keyed (`proxy::forward_inner` falls back to `"unknown"`), so an
// empty key is the tell.
if s.key.is_empty() || s.lanes.is_empty() {
return None;
}
let lanes = s.lanes.len();
let entries = s.entries.len();
s.entries.retain(|e| (e.lane as usize) < lanes);
// Retaining shifts positions, so any index into `entries` is only sound
// when nothing was dropped.
let shifted = s.entries.len() != entries;
let entries = s.entries.len();
// Nothing is streaming into a process that no longer exists. Leaving these
// set would show a permanent running dot and keep `Lane::running` true
// forever — and `Tap::drop` runs on the tee task, so the snapshot can
// legitimately have caught a count that was about to be decremented.
s.active = 0;
for l in &mut s.lanes {
l.active = 0;
if shifted || l.first_entry.is_some_and(|i| i >= entries) {
l.first_entry = None;
}
if shifted || l.anchor.is_some_and(|i| i >= entries) {
l.anchor = None;
}
}
// A tool call whose result can no longer be attached is better than one
// attached to the wrong entry.
s.tool_ids.retain(|_, i| !shifted && *i < entries);
// A half-streamed entry never gets its remaining deltas: close it out so it
// renders as finished markdown instead of a permanently pending block.
if let Some(e) = s.entries.last_mut() {
e.done = true;
}
Some(s)
}
/// Highest `srvtool-<n>` index across the restored sessions.
fn max_server_tool_seq(sessions: &[crate::app::Session]) -> u64 {
sessions
.iter()
.flat_map(|s| s.lane_of_agent.keys())
.filter_map(|k| k.strip_prefix(crate::app::SERVER_TOOL_PREFIX))
.filter_map(|n| n.parse::<u64>().ok())
.max()
.unwrap_or(0)
}
/// Write side of `Handoff`. It *borrows* the live app: the feed can be tens of
/// megabytes, and — more importantly — an exec that fails must leave the
/// running process exactly as it was, which a moved-out `Vec<Session>` would
/// not. The field names mirror `Handoff`/`AppState` exactly; that is the whole
/// contract between the two.
#[derive(serde::Serialize)]
struct HandoffRef<'a> {
listener_fd: RawFd,
pane: &'a Option<crate::term::PtyHandoff>,
app: AppStateRef<'a>,
pane_ui: &'a PaneState,
generation: u32,
}
#[derive(serde::Serialize)]
struct AppStateRef<'a> {
sessions: &'a [crate::app::Session],
selected_key: Option<String>,
selected: usize,
scroll: usize,
follow: bool,
filters: &'a [bool],
show_sessions: bool,
embed_session: &'a Option<String>,
embed_token: &'a Option<String>,
}
impl<'a> AppStateRef<'a> {
fn of(app: &'a App) -> Self {
Self {
sessions: &app.sessions,
selected_key: app.selected_key(),
selected: app.selected,
scroll: app.scroll,
follow: app.follow,
filters: &app.filters,
show_sessions: app.show_sessions,
embed_session: &app.embed_session,
embed_token: &app.embed_token,
}
}
}
// ---------------------------------------------------------------------------
// Commit side: exec ourselves
// ---------------------------------------------------------------------------
/// Clear FD_CLOEXEC so `fd` survives the coming `execve`. Everything else we
/// hold keeps the flag and is closed by the kernel, which is what we want:
/// only the listener and the pty master are meant to cross over.
fn keep_open(fd: RawFd) -> bool {
unsafe { libc::fcntl(fd, libc::F_SETFD, 0) == 0 }
}
/// Undo `keep_open`. Only reached when the exec failed: the fds must not stay
/// inheritable, or the next `claude` we spawn would get a copy of the proxy
/// socket and the pane's pty.
fn close_on_exec(fd: RawFd) {
if fd >= 0 {
unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) };
}
}
/// Replace this process with `exe`, carrying the live state across.
///
/// On success this never returns: the new image continues from `main` with the
/// same pid, the same listener socket and the same `claude` child. It only
/// returns on failure — and then nothing has been consumed, so the caller just
/// keeps running the old code.
pub fn exec_into(
exe: &Path,
app: &App,
pane: Option<crate::term::PtyHandoff>,
pane_ui: &PaneState,
) -> anyhow::Error {
// Only these two fds are meant to cross. Everything else we hold keeps its
// FD_CLOEXEC and is closed by the kernel during the exec — including the
// established connections, which is why the reload waits for a quiet wire.
let listener_fd = if keep_open(app.listener_fd) { app.listener_fd } else { -1 };
let pane = match pane {
Some(p) if keep_open(p.master_fd) => Some(p),
// A pane whose fd cannot be kept open is dropped rather than handed
// over as a dangling number: the new image then simply has no pane.
_ => None,
};
let path = std::env::temp_dir().join(format!("claude-cloak-reload-{}.json", std::process::id()));
let snapshot = HandoffRef {
listener_fd,
pane: &pane,
app: AppStateRef::of(app),
pane_ui,
generation: app.reload_gen,
};
let written = serde_json::to_vec(&snapshot).map_err(anyhow::Error::from).and_then(|b| {
// Written and flushed before the exec: a crash in between would
// otherwise leave a truncated file the new image reads as garbage.
use std::io::Write;
let mut f = std::fs::File::create(&path)?;
f.write_all(&b)?;
f.sync_all()?;
Ok(())
});
let err = match written {
Err(e) => e.context("write reload snapshot"),
Ok(()) => {
// Same arguments we were started with, argv[0] aside.
let args: Vec<String> = std::env::args().skip(1).collect();
use std::os::unix::process::CommandExt;
let e = std::process::Command::new(exe).args(&args).env(HANDOFF_ENV, &path).exec();
// `exec` returns only on failure.
let _ = std::fs::remove_file(&path);
anyhow::Error::from(e).context(format!("exec {}", exe.display()))
}
};
// The reload did not happen, so put the fds back the way we found them and
// let the old code carry on.
close_on_exec(listener_fd);
if let Some(p) = &pane {
close_on_exec(p.master_fd);
}
err
}
// ---------------------------------------------------------------------------
// Which binary to exec
// ---------------------------------------------------------------------------
/// Resolve our own executable **path** (not inode) at startup.
///
/// Read once, in `main`, and then carried across every reload in the handoff —
/// because by the time you press ctrl-r the file has usually been replaced.
/// A linker writes the new binary and renames it over the old one, which
/// unlinks the inode we are running from; Linux then reports `/proc/self/exe`
/// as `…/claude-cloak (deleted)`. Resolving late would exec that literal name
/// and fail, so the suffix is also stripped defensively.
pub fn exe_path() -> PathBuf {
let raw = std::env::current_exe()
.ok()
.or_else(|| std::env::args_os().next().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from(env!("CARGO_PKG_NAME")));
strip_deleted(raw)
}
fn strip_deleted(p: PathBuf) -> PathBuf {
match p.to_str().and_then(|s| s.strip_suffix(" (deleted)")) {
Some(s) => PathBuf::from(s),
None => p,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{Entry, Kind, Session};
use std::time::Instant;
/// A session as the *old* image would have snapshotted it.
fn live_session() -> Session {
let mut s = Session::new("sess".into(), "opus".into());
s.push(0, Entry { kind: Kind::Text, content: "hi".into(), ..Default::default() });
s.active = 2;
s.lanes[0].active = 2;
s.tool_ids.insert("toolu_1".into(), 0);
s
}
#[test]
fn snapshot_round_trips_through_json() {
let s = live_session();
let json = serde_json::to_string(&s).unwrap();
let back: Session = serde_json::from_str(&json).unwrap();
assert_eq!(back.key, "sess");
assert_eq!(back.entries.len(), 1);
assert_eq!(back.entries[0].content, "hi");
assert_eq!(back.lanes.len(), 1);
}
/// `Instant` is snapshotted as an age, so a lane restored from disk must
/// still look as old as it was — that is what `Lane::running` reads.
#[test]
fn instants_survive_as_ages_not_as_now() {
let mut s = live_session();
let old = Instant::now() - Duration::from_secs(120);
s.last_activity = old;
s.lanes[0].last_event = Some(old);
let back: Session = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert!(back.last_activity.elapsed() >= Duration::from_secs(119));
assert!(back.lanes[0].last_event.unwrap().elapsed() >= Duration::from_secs(119));
// …and therefore reads as idle, not as freshly running, once the
// in-flight counters are cleared.
assert!(!sanitize_session(back).unwrap().lanes[0].running());
}
/// Nothing streams into a process that no longer exists.
#[test]
fn sanitize_clears_in_flight_counters() {
let s = sanitize_session(live_session()).unwrap();
assert_eq!(s.active, 0);
assert_eq!(s.lanes[0].active, 0);
assert!(s.entries.last().unwrap().done, "a cut entry is closed out");
}
/// The dangerous case: a snapshot whose entries point at lanes the new
/// binary's session no longer has. Indexing those panics on the first draw.
#[test]
fn sanitize_drops_entries_with_dangling_lanes() {
let mut s = live_session();
s.entries.push(Entry { kind: Kind::Text, content: "ghost".into(), lane: 7, ..Default::default() });
let s = sanitize_session(s).unwrap();
assert_eq!(s.entries.len(), 1);
assert!(s.entries.iter().all(|e| (e.lane as usize) < s.lanes.len()));
// Positions shifted, so index-valued maps are dropped rather than
// left pointing at the wrong entry.
assert!(s.tool_ids.is_empty());
}
#[test]
fn sanitize_drops_a_session_without_lanes() {
let mut s = live_session();
s.lanes.clear();
assert!(sanitize_session(s).is_none());
}
#[test]
fn sanitize_clears_out_of_range_lane_indices() {
let mut s = live_session();
s.lanes[0].anchor = Some(99);
s.lanes[0].first_entry = Some(99);
let s = sanitize_session(s).unwrap();
assert_eq!(s.lanes[0].anchor, None);
assert_eq!(s.lanes[0].first_entry, None);
}
/// The synthetic-lane counter is process-global, so a reload must not
/// restart it at 0 and re-enter a lane the restored session already holds.
#[test]
fn server_tool_seq_is_seeded_past_restored_lanes() {
let mut s = live_session();
s.lane_of_agent.insert(format!("{}4", crate::app::SERVER_TOOL_PREFIX), 0);
s.lane_of_agent.insert("deadbeef".into(), 0);
assert_eq!(max_server_tool_seq(&[s]), 4);
}
/// The binary is expected to have been replaced while we run: a rename
/// over the running image makes Linux report `/proc/self/exe` with a
/// ` (deleted)` suffix, and exec'ing that literal name would fail.
#[test]
fn exe_path_drops_the_deleted_suffix() {
let p = strip_deleted(PathBuf::from("/x/target/debug/claude-cloak (deleted)"));
assert_eq!(p, PathBuf::from("/x/target/debug/claude-cloak"));
// An ordinary path is untouched, including one that merely contains
// the word.
let p = PathBuf::from("/x/deleted/claude-cloak");
assert_eq!(strip_deleted(p.clone()), p);
}
/// A handoff whose feed no longer parses must still surrender the fd
/// numbers: losing the code's state is survivable, losing the port and the
/// pane is not.
#[test]
fn unparseable_feed_keeps_the_port_and_the_pane() {
let raw = serde_json::json!({
"listener_fd": 9,
"pane": {
"master_fd": 11, "child_pid": 4242, "session_id": "s",
"pane_token": "t", "pty_rows": 20, "cols": 80
},
"app": {"sessions": [{"this": "is not a session"}, {"key": "keeper"}], "selected": 3},
"pane_ui": {"visible": true},
"generation": 2,
});
let h: Handoff = serde_json::from_value(raw).unwrap();
assert_eq!(h.listener_fd, 9);
assert_eq!(h.pane.as_ref().unwrap().child_pid, 4242);
assert_eq!(h.generation, 2);
let mut app = App::new();
restore(&mut app, h.app);
let keys: Vec<_> = app.sessions.iter().map(|s| s.key.as_str()).collect();
assert_eq!(keys, ["keeper"], "the junk entry is skipped, the real one kept");
}
}