fix(server): stop client-less session servers from burning a core - #18
fix(server): stop client-less session servers from burning a core#18m-szymanska wants to merge 13 commits into
Conversation
A starting server unlinked the session socket file unconditionally and bound its own. The previous server kept running - unreachable, with zero clients, and with no reaper that ever collects it. Session discovery made this routine: a server too busy to answer a ConnStatus probe within 250ms was reported as nonexistent, so a new server was spawned and stole the name. Probe the path for ownership before touching it: on Unix a successful connect means some process holds the listening end, whatever its health; on Windows the marker PID answers. Only a missing, stale or non-socket path is cleaned up and re-bound. Anything else - including an unclassifiable transport error - refuses the start with a log line. Since the losing server now exits instead of stealing, its client can reach the surviving server and ask it for a new session. Reject that too: re-initializing would replace a live session's state wholesale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both chrome target sets are derived from active_tab_ids, so the last detach empties them and nobody is told it stopped being visible. The plugin instances outlive their client: a session rail latched visible kept re-arming its 1Hz timer and re-reading every live session's KDL metadata on a server nobody was watching - the dominant idle CPU cost, quadratic in the number of live sessions on the machine. Remember which targets were told they are visible and park those whose client is no longer connected. Targets of still-connected clients stay governed by all-minus-active, so tab switching and projector bindings are untouched. Attach re-activates through the existing active-target path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A plugin-initiated read sent its result to Screen, which broadcast SessionUpdate to every plugin - including the caller, which rebuilt its model and asked again on its next timer. One visible rail therefore woke every plugin in the session once a second, and the payload is the sum of all live sessions' metadata on the machine. Screen's cache is owned by the session-metadata background job, which already refreshes it on its own cadence, so the read stays a read. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
An organization admin can view or raise the cap at claude.ai/admin-settings/claude-code. The cap resets at the start of the next billing period.
Once the cap resets or is raised, reopen this pull request to trigger a review.
|
🤖 Hi @m-szymanska, I've received your request, and I'm working on it now! You can track my progress in the logs for more details. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 13394b1d16
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if alive { | ||
| SocketOwnership::Live |
There was a problem hiding this comment.
Verify named-pipe ownership instead of trusting a reused PID
On Windows, if a server crashes and leaves its marker behind, its PID can later be reused by an unrelated process. OpenProcess then classifies the vacant session as Live, so the startup path exits before attempting the named-pipe bind and that session name remains unusable until the marker is manually removed or the unrelated process exits. Probe the named pipe itself or validate that the PID belongs to the expected server before refusing startup.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed, and taken further in c5c7779: the non-Unix probe is removed rather than reworked. Probing the named pipe by connecting would occupy the target server's accept loop (every accepted stream is paired with a blocking accept on the reply pipe), and no probe is needed there in the first place — the OS refuses to hand out the pipe name twice and ipc_bind writes the marker only after the bind succeeds, so the bind already decides ownership. Docs now state that the probe is Unix-only.
| _ => {}, | ||
| } | ||
| match ipc_connect_timeout(path, SOCKET_OWNERSHIP_PROBE_TIMEOUT) { | ||
| Ok(_stream) => SocketOwnership::Live, |
There was a problem hiding this comment.
Avoid dropping the ownership probe during server initialization
When two processes create the same session concurrently, the winner can have bound its socket while session_data is still None; this successful probe then connects and immediately drops the stream. The listener registers that stream as a client, and route_thread_main sends ServerInstruction::RemoveClient on EOF, whose regular-client branch unconditionally unwraps session_data.as_ref(). That panics the winning server during its startup window while the probing server exits because it observed Live, potentially leaving both clients failed or stuck retrying a stale socket. The probe connection or pre-initialization removal path needs to avoid this cleanup panic.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 99fd26a. The unwrap on session_data in the regular-client RemoveClient branch is now a read() + map(), so a connection that ends before this server initialized a session — the racing probe, or any client that gives up in the startup window — no longer panics the server that won the socket. Screen and the plugin thread are notified only when a session actually exists.
| ), | ||
| }, | ||
| ); | ||
| remove_client!(client_id, os_input, session_state, session_data); |
There was a problem hiding this comment.
Keep the rejected client ID reserved until its route exits
When a losing create-session client reaches an already initialized server, this removes its SessionState entry immediately, but its inbound route thread remains alive and later sends ServerInstruction::RemoveClient(client_id) when it receives ClientExited or EOF. Because SessionState::new_client always reuses the lowest vacant ID, a new attach accepted during that window can receive the rejected client's ID; the stale route cleanup then removes and disconnects that unrelated new client. Defer freeing the ID until the route terminates, or associate cleanup with a connection generation so it cannot act on a reused ID.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 99fd26a. The rejection path no longer frees the client id; the route thread is still alive and sends its own RemoveClient when the connection ends, so the id stays reserved until then and can no longer be handed to an attach accepted in that window.
Two cleanup paths acted on state that need not exist yet. RemoveClient unconditionally unwrapped `session_data` to notify Screen and the plugin thread. A connection can end before this server ever initialized a session — the socket-ownership probe of a racing server connects and drops immediately, and so does any client that gives up inside the startup window. The unwrap then panicked the server that had just won the socket, leaving both racers with nothing. Notify only when a session is actually there. The rejection path for a losing create-session client freed its client id right away, while its route thread stayed alive and would send its own RemoveClient later. Since `SessionState::new_client` always reuses the lowest vacant id, an attach accepted in that window could receive the rejected id and be disconnected by the late cleanup. Leave the id reserved until the route terminates and sends the removal itself.
The Windows implementation answered ownership from the PID written in the session marker file. A PID recycled after a crash reads as live, which strands the session name until someone removes the marker by hand — strictly worse than not probing at all. No probe is needed there. Off Unix the listener is a named pipe whose name the OS refuses to hand out twice, and `ipc_bind` writes the marker only after that bind succeeds, so the bind itself already decides ownership. Probing by connecting to the pipe would be worse still: it occupies the target server's accept loop, which pairs every accepted stream with a blocking accept on the reply pipe. Document the probe as the Unix-only mechanism it is.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c5c7779f91
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // running but unreachable: an orphan with zero clients that no | ||
| // reaper ever collects, burning CPU until reboot. A stale file | ||
| // left by a crashed server stays legal to clean up. | ||
| match zellij_utils::sessions::probe_socket_ownership(&socket_path) { |
There was a problem hiding this comment.
Gate the Unix-only ownership probe at its call site
When building zellij-server for Windows, this unconditional call cannot resolve because probe_socket_ownership is now declared only under #[cfg(unix)]. As a result, the Windows server fails to compile before it can use the intended named-pipe bind path; wrap the probe-and-unlink block in #[cfg(unix)] and let non-Unix platforms proceed directly to ipc_bind.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 622227c. The call site and the SocketOwnership import are both #[cfg(unix)] now, so the non-Unix server goes straight to ipc_bind, matching where the probe is actually defined.
The comment left in place says why no non-Unix probe is owed rather than that one is missing: off Unix the session path is a marker file and the listener is a named pipe whose name the OS refuses to hand out twice, so ipc_bind — which writes the marker only after that bind succeeds — already decides ownership.
Verified: Build (Windows) passes on 622227c (run 31530847119), where it had failed on the previous commit. Local gates on the same tree: cargo check -p zellij-server, cargo fmt --check, cargo test -p zellij-utils --lib sessions (9 passed) and cargo test -p zellij-server --lib (1309 passed).
`probe_socket_ownership` only exists under `#[cfg(unix)]`, but the server startup path called it unconditionally, so the Windows build stopped resolving it. Gate the call and its import the same way and say in place why the non-Unix path needs no probe: there the session path is a marker file and the listener is a named pipe whose name the OS refuses to hand out twice, so `ipc_bind` already decides ownership.
…ons on a focused rail - LOCK (^g) routes raw keys to the focused pane; a focused rail consuming bare Up/Down became a hidden, mode-proof session switcher - product key-contract v3: arrow session switching lives only in the ^T tab-mode binds (vc_rail_nav pipe) and the always-on Super chords - rail keeps Enter/ordinals/bucket hotkeys/+/-/Esc; mouse hover+click untouched - new contract test rail_ignores_bare_arrow_keys (RED observed pre-cut); session-manager suite 120/120, fmt + clippy -D warnings clean Authored-By: claude <agents@vetcoders.io>
Fix held-command reruns by reserving active PTY identifiers before respawn, harden E2E readiness around exact accepted frames, and isolate command fixtures. Extend triage startup budgets for real fresh-session latency, normalize wire-contract line endings, reject symlink traversal during legacy migration, refresh deterministic snapshots and plugin assets, and inventory the clinic current-exe diagnostic in the Semgrep evidence surface. Authored-By: codex <agents@vetcoders.io> session_id: 019ff225-49ff-72b0-9078-139cdc37ff61 time: 2026-08-11T23:06:24:z runtime: headless
Update the canonical plugin SHA256 receipt for the deterministic session-manager WASM produced by the focused-rail change. This restores the asset-integrity contract on Windows and no-default-features CI jobs. Authored-By: codex <agents@vetcoders.io> session_id: 019ff225-49ff-72b0-9078-139cdc37ff61 time: 2026-08-11T23:18:06:z runtime: headless
Serialize the three intentional-panic pinned-executor tests. Windows backtrace symbolization writes through the global panic hook slowly enough that concurrent probes consumed one another’s five-second receipt deadlines even though the executor caught the panics and remained healthy. Authored-By: codex <agents@vetcoders.io> session_id: 019ff225-49ff-72b0-9078-139cdc37ff61 time: 2026-08-11T23:29:26:z runtime: headless
Make mirrored-session snapshot readiness prove that the Vibecrafted brand is rendered in the first terminal row. The prior anywhere-in-frame predicate could match guide content while the top bar was still a transient blank frame. Authored-By: codex <agents@vetcoders.io> session_id: 019ff225-49ff-72b0-9078-139cdc37ff61 time: 2026-08-11T23:57:52:z runtime: headless
Add an explicit foreground-server runtime contract for isolated supervisors and enable it in the triage runtime fixture. This prevents Unix double-fork reparenting from breaking the harness process-ownership proof while preserving normal daemon behavior outside the fixture. Authored-By: codex <agents@vetcoders.io> session_id: 019ff225-49ff-72b0-9078-139cdc37ff61 time: 2026-08-12T00:09:45+02:00 runtime: headless
There was a problem hiding this comment.
Pull request overview
This PR addresses sustained high CPU usage in vc-frame session servers that have no attached clients by preventing socket “theft” between competing servers, ensuring chrome plugins are explicitly “parked” on detach, and eliminating a plugin-driven feedback loop that caused quadratic work.
Changes:
- Add a Unix-only socket ownership probe to prevent unlink+rebind when a live server is still listening; add a guard to prevent re-initializing an already-running session.
- Fix chrome visibility targeting so the last client detach triggers explicit hide/park messages and stops background refresh loops; stop
get_session_listfrom fanning outUpdateSessionInfos. - Improve E2E and runtime harness robustness around foreground server ownership and snapshot readiness, plus related fixture/snapshot updates.
Reviewed changes
Copilot reviewed 77 out of 79 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap | Snapshot updates for default config/keybind rendering |
| zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap | Snapshot updates for config/keybind rendering |
| zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap | Snapshot updates for config/keybind rendering |
| zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap | Snapshot updates for config/keybind rendering |
| zellij-utils/src/sessions.rs | Add SocketOwnership + ownership probe and tests |
| zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap | Snapshot updates for KDL serialization |
| zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap | Snapshot updates for KDL serialization (with comments) |
| zellij-utils/src/envs.rs | Add VC_FRAME_SERVER_FOREGROUND env flag helper |
| zellij-utils/src/consts.rs | Harden legacy-path migration behavior and add symlink test |
| zellij-utils/src/client_server_contract/mod.rs | Normalize line endings for contract hashing + tests |
| zellij-utils/assets/plugins/SHA256SUMS | Update plugin artifact checksum(s) |
| zellij-server/src/unit/screen_tests.rs | Add unit test covering “last client detach parks chrome” |
| zellij-server/src/screen.rs | Track last visible chrome targets to park on detach |
| zellij-server/src/plugins/zellij_exports.rs | Remove UpdateSessionInfos side-effect from get_session_list |
| zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__override_layout_plugin_command.snap | Snapshot update for plugin layout override |
| zellij-server/src/plugins/unit/snapshots/zellij_server__plugins__plugin_tests__dump_layout_success_plugin_command.snap | Snapshot update for layout dump output |
| zellij-server/src/plugins/pinned_executor.rs | Serialize panic-related tests to reduce Windows flakiness |
| zellij-server/src/os_input_output.rs | Reserve terminal IDs for rerun and resolve reserved-spawn failures safely |
| zellij-server/src/os_input_output_windows.rs | Implement terminal-id reservation for rerun on Windows |
| zellij-server/src/os_input_output_unix.rs | Implement terminal-id reservation for rerun on Unix + tests |
| zellij-server/src/lib.rs | Add socket-ownership startup guard, foreground option, and session init guard |
| zellij-client/src/lib.rs | Support foreground server mode by detaching child process handling |
| tools/triage_runtime_e2e_test.py | Add tests for isolated foreground server ownership proof |
| tools/semgrep_inventory.py | Extend current-exe policy inventory for clinic path |
| src/tests/fixtures/append-echo-script.sh | Make CLI-run fixture deterministic across reruns |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__watcher_client_functionality.snap | E2E snapshot updates for new chrome/layout |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__watcher_client_functionality-2.snap | E2E snapshot updates for new chrome/layout |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__use_custom_layout_with_relative_path.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__undo_rename_tab.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__undo_rename_pane.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__typing_exit_closes_pane.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__toggle_pane_fullscreen.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__toggle_floating_panes.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__tmux_mode.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__status_bar_loads_custom_keybindings.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__starts_with_one_terminal.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__start_without_pane_frames.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__split_terminals_vertically.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__send_command_through_the_cli.snap | E2E snapshot updates for rerun output |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__send_blocking_command_through_the_cli.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__scrolling_inside_a_pane.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__scrolling_inside_a_pane_with_mouse.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__resize_terminal_window.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__resize_pane.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__quit_and_resurrect_session.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__quit_and_resurrect_session_with_viewport_serialization.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__pin_floating_panes.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__override_layout_from_default_to_compact.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__open_new_tab.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__multiple_users_in_same_pane_and_tab.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__multiple_users_in_same_pane_and_tab-2.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__multiple_users_in_different_tabs.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__multiple_users_in_different_tabs-2.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__multiple_users_in_different_panes_and_same_tab.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__multiple_users_in_different_panes_and_same_tab-2.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__move_tab_to_right.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__move_tab_to_right_until_it_wraps_around.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__move_tab_to_left.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__move_tab_to_left_until_it_wraps_around.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__mirrored_sessions.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__mirrored_sessions-2.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__lock_mode.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__load_plugins_in_background_on_startup.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__focus_pane_with_mouse.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__detach_and_attach_session.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__close_pane.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__cannot_split_terminals_vertically_when_active_terminal_is_too_small.snap | E2E snapshot updates |
| src/tests/e2e/snapshots/vc_frame__tests__e2e__cases__bracketed_paste.snap | E2E snapshot updates |
| src/tests/e2e/remote_runner.rs | Improve readiness predicate stability and add chrome detection helpers |
| src/tests/e2e/cases.rs | Make E2E snapshot normalization more robust; tighten readiness waits/retries |
| src/run_triage_cli.rs | Increase timeouts for session readiness and inventories |
| security/semgrep/EVIDENCE.md | Update semgrep evidence text for clinic current_exe usage |
| security/semgrep/baseline.json | Update semgrep baseline metadata counts/hashes |
| scripts/triage-runtime-e2e.py | Enforce foreground server ownership in isolation and improve cleanup proofs |
| docs/VC_FRAME_OPERATOR_SURFACE.md | Document detach parking and session socket ownership behavior |
| default-plugins/session-manager/src/main.rs | Stop consuming bare arrow keys in focused rail; add regression test |
| CHANGELOG.md | Add unreleased entries for the fixes and perf improvement |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| let source_metadata = match std::fs::symlink_metadata(source) { | ||
| Ok(metadata) => metadata, | ||
| Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), | ||
| Err(error) => return Err(error), | ||
| }; | ||
| if target.exists() { | ||
| return Ok(()); | ||
| } |
Problem
A vc-frame session server with no client attached burns a steady 30–50% of a
core, indefinitely. Triage buckets (
Failed runs,Finalized runs,Live runs)are created automatically after failed runs, never get a client, and never exit —
so the cost accumulates with every bucket the machine collects.
Measured on two fleet machines independently (different users, different session
sets). Sum on one macbook: 1.3–1.8 cores burned continuously; on another host,
four client-less servers summed to ~97%.
Measurement method matters here.
ps %CPUand Activity Monitor report alifetime average, which hides this for long-lived processes. All numbers below
are delta CPU-time over a known window (
ps -o timebefore/after ÷ window).The signature that rules out "normal work": delta in the window matches the
lifetime average almost 1:1. That means a constant burn rate from process start,
uncorrelated with any workload — the fingerprint of a fixed-frequency loop, not
of doing something.
Two independent defects
1. Socket theft —
zellij-server/src/lib.rs:810Startup did
remove_file+bindon the session socket without probing whetherthe current owner is alive. Trigger:
SESSION_PROBE_TIMEOUTof 250 ms — a livebut busy server fails to answer
ConnStatusin time and is declared dead. Itssocket gets unlinked and rebound by the newcomer, and now two servers back one
session name. On one machine
zellij.logrecorded 14 server starts against 1registered exit in a single day.
Fix: new
probe_socket_ownership()inzellij-utils/src/sessions.rs. Asuccessful
connect()means the owner is alive, regardless of whether it getsaround to replying — which is precisely what kills the 250 ms trigger. Only
Vacant(file missing,ConnectionRefused, or not-a-socket) permitsremove_file+bind;LiveandUnknownlog and exit cleanly.This needs a companion guard on
FirstClientConnected: a client of the losingserver is routed to the winner, and without the guard it would call
init_sessionover live state and wipe an existing session.2. The 1 Hz loop between session-manager and host
Root cause of the latch: both sets of status-bar plugin targets are derived from
active_tab_ids. When the last client detaches that set is empty, so nobodyreceives
vc.status-bar-visibility.v1 = "false"— the rail keeps its timerrunning forever, on a server no one is looking at.
The cost is O(N²): N servers each re-parse metadata for N sessions, every second,
on
wasmi(an interpreter, not a JIT). Per-thread measurement confirms it: ~94%of the process CPU sits on a single thread,
plugin-exec-1, wherepinned_executorholdssession-managerandvc-tab-title.Fix:
status_bar_plugin_target_transition()now sends hide to the targetsthat were previously visible to the departing client. The loop dies within ~1 s
of detach; attach resumes through the existing path (
AddClient→ subscribe →refresh).
Third commit is a smaller, related win:
get_session_listno longer re-broadcastsUpdateSessionInfosto every plugin. A plugin-initiated read shouldn't fan out awrite; freshness is already maintained by the existing metadata job.
Result (measured on the built binary)
Same machine, same moment, 60 s settle then a 30 s window — the only variable is
the binary:
Failed runsbucketFinalized runsbucketRoughly a 20–30× reduction on client-less servers.
Gates
The 6 failures in
zellij-utilsare the default-KDL-config snapshots; verifiedby stash that they fail identically on clean
f3e2570ed. New tests cover"0 clients → chrome parked", "busy-but-alive socket = Live", and
"stale socket = Vacant".
rustfmtclean,clippyreports nothing new.Docs in the same cut:
docs/VC_FRAME_OPERATOR_SURFACE.md(new "Session SocketOwnership" section plus the detach contract) and three
CHANGELOG.mdentries.Known limits
dominant trigger is gone — but not closed to zero. Full closure needs a
per-session advisory lock.
in the host (that cost shows up with a client attached), a content-hash gate on
SessionUpdate,wasmi→ JIT, and duplicate detection invc-frame doctor.Note for reviewers
f3e2570edin the commit list is an empty merge commit inherited from the branchpoint — its tree is identical to
origin/develop, so the diff of this PR isexactly the three commits above.