Skip to content

Key pageTabs by GObject pointer to fix silent map lookup misses - #5

Merged
charliek merged 1 commit into
mainfrom
fix/page-tabs-uintptr
Apr 28, 2026
Merged

Key pageTabs by GObject pointer to fix silent map lookup misses#5
charliek merged 1 commit into
mainfrom
fix/page-tabs-uintptr

Conversation

@charliek

@charliek charliek commented Apr 28, 2026

Copy link
Copy Markdown
Owner

Summary

Why

gotk4 may return a fresh *adw.TabPage Go wrapper from getter calls like view.SelectedPage() and view.NthPage() — even though it references the same underlying AdwTabPage GObject inserted earlier via view.Append. With pageTabs map[*adw.TabPage]int64, lookups against the fresh wrapper silently missed.

PR #3 hit this when wiring Cmd-R rename and worked around it with an iteration. PR #4 hit it again in activeSession and copied the workaround. Each one-off fix unblocked the immediate feature but left the broader bug class in place.

Sites that were silently failing soft

After grepping every pageTabs[page] access, these are the ones that previously returned (0, false) on a wrapper mismatch and just took a fallback path:

  • selected-page notify handler — sess.da.GrabFocus() was skipped on miss, so focus on tab switch (Ctrl-1/2/3 or clicking a tab) didn't reliably land on the new terminal.
  • close-page handler — silent skip of session cleanup on miss.
  • projectIDForPage — fell through to activeProjectID instead of the real owner.
  • Several tab-lifecycle paths (close, badge update, etc.) that just no-op'd on miss.

After this PR, every lookup goes through pageKey(page) = page.Native() and these silent failures are gone.

Test plan

  • go vet ./... clean
  • go test ./... passes (config, core, ghostty, ipc, osc, pty, store, and the cmd/roost pure-logic tests)
  • ./build/build.sh produces working binary
  • ./roost launches without panic; IPC socket binds
  • Manual on macOS — deferred to author. Things to spot-check:
    • Open ≥3 tabs in a project. Press Ctrl-1, Ctrl-2, Ctrl-3. After each switch, type immediately — characters should appear in the new tab's terminal without needing to click.
    • Cmd-R rename popover still works.
    • Cmd-W close still works without leaks.
    • PR Terminal usability: clipboard, scrollback, mouse, modifier keys #4 features (paste / scrollback / mouse selection) unaffected.

The change is mechanical (key type swap, every site routed through one helper). It can only resolve silent misses, not introduce new ones — Native() returns a stable identity, which the existing activeSession workaround already relied on.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Refactor
    • Improved internal data handling for tab management to enhance stability.

gotk4 may return a fresh Go wrapper from getter calls like
view.SelectedPage() and view.NthPage() that doesn't pointer-equal
the wrapper inserted via view.Append. The pageTabs map was keyed by
*adw.TabPage, so lookups against a freshly-returned wrapper would
silently miss — even though both wrappers reference the same
underlying GObject.

PR #3 worked around this in renameActiveTab by iterating tabPages
and comparing via page.Native(); PR #4 added a second copy of the
same workaround in activeSession. Beyond those two, eleven other
pageTabs[page] sites were silently failing soft on miss:

  - selected-page notify handler — sess.da.GrabFocus() skipped on
    miss, so focus didn't reliably land on the new terminal after
    Ctrl-1/2/3 / clicking a tab.
  - close-page handler, project-id resolver, tab-cleanup paths —
    on a stale lookup these returned 0/false and let the calling
    branch fall through silently.

Fix: change pageTabs from map[*adw.TabPage]int64 to
map[uintptr]int64, keyed by the underlying GObject pointer
(page.Native()) which is stable across wrapper churn. Add a
pageKey() helper and route every read/write/delete through it.
Collapse renameActiveTab and activeSession to direct map lookups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5054f3cc-97bb-4f5a-b058-7f266f7025dc

📥 Commits

Reviewing files that changed from the base of the PR and between 213ced1 and fd6de58.

📒 Files selected for processing (1)
  • cmd/roost/app.go

📝 Walkthrough

Walkthrough

The App.pageTabs map is refactored to use stable GObject identities (uintptr from TabPage.Native()) instead of Go wrapper pointers (*adw.TabPage). A new pageKey() helper centralizes key derivation, and all read/write call sites are updated to use it. Functions like activeSession and renameActiveTab now resolve directly through the refactored map.

Changes

Cohort / File(s) Summary
Tab page identity refactoring
cmd/roost/app.go
Replaced pointer-based keys with stable uintptr keys in pageTabs map. Introduced pageKey() helper function. Updated all call sites (active/selected checks, IPC identify, header updates, focus/grab, tab closing, project deletion, CWD inheritance, session resolution, and tab renaming) to use the new keying mechanism.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐰 ✨ The tabs now hold their ground so true,
With stable keys, not pointers new,
A pageKey() hop to organize the way,
The map stays steady, come what may! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: switching pageTabs key from Go wrapper pointer to GObject pointer to fix silent map lookup misses. This directly reflects the core refactoring described in the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/page-tabs-uintptr

Comment @coderabbitai help to get the list of available commands and usage tips.

@charliek
charliek merged commit cc8e7fa into main Apr 28, 2026
5 checks passed
@charliek
charliek deleted the fix/page-tabs-uintptr branch April 28, 2026 02:26
charliek added a commit that referenced this pull request May 23, 2026
…ail-hard, entitlements cleanup, PtySupervisor double-emit

Address four findings from the M6-M9 sub-agent review:

**1. Linux reorder events for parity with Mac.** Sub-agent #8
caught that the Mac M9 polish wired `Workspace.tabsReordered` /
`projectsReordered` events but Linux's
`crates/roost-linux/src/daemon/state.rs::reorder_tabs` /
`reorder_projects` mutate positions in-place without emitting.
Added two new `WorkspaceEvent` variants (`TabsReordered`,
`ProjectsReordered`) with the same payload shape as Mac (the
post-reorder display order — supplied prefix + sorted unlisted).
Both reorder methods now `events.send(...)` after persist. The
GTK app.rs match arm currently drops the events with a comment —
the UI's drag-reorder path already updates AdwTabBar inline
before firing the RPC, so re-applying the broadcast would be
double-work. Cross-client convergence is a follow-up slice; the
event is emitted so a future `events.subscribe` consumer (or a
sibling GTK process) can react.

**2. `bundle.sh` codesign fail-hard.** Sub-agent #5 caught that
the previous `codesign … || echo "warn ... (continuing)"` form
swallowed signature failures with exit 0. A botched signature
silently produces a Gatekeeper-rejected app + a notarization
failure at release time. Replaced with a `codesign_or_die`
helper that exits 1 on failure unless `ROOST_ALLOW_UNSIGNED=1`
is set (for the rare dev case where Xcode CLT codesign is
unavailable).

**3. `Roost.entitlements` cleanup.** Sub-agent #6 caught that
`com.apple.security.cs.disable-library-validation` was included
based on a misreading of the docs — library validation governs
in-process `dlopen` / framework loads, not `execve` of a
separately-signed embedded binary (the `Contents/Resources/bin/
roostctl` exec path is governed by Gatekeeper / quarantine,
not library validation). Apple notarization will scrutinize
any unnecessary hardened-runtime entitlements, so removed.
Plist is now an empty dict with a long comment block listing
what we INTENTIONALLY don't need (allow-jit, network.*, sandbox)
and the conditions under which we'd add each.

**4. PtySupervisor double-`.tabExited` race.** Sub-agent #3.b
walked through a scenario where the read source yields `.eof`
and the bg-reap teardown yields `.forcedExit` onto the same
AsyncStream FIFO; the drain task would emit `.tabExited` twice
(once from `reapAndCleanup` on `.eof`, once from `.forcedExit`).
Added an `emittedExit` bool to the drain task that suppresses
the second emit; `reapAndCleanup` is now `@discardableResult ->
Bool` so the drain knows whether it actually emitted (vs. the
session-already-gone-from-close-race case where it returns
false without emitting and the subsequent `.forcedExit` is the
one that should fire). All emit-once paths preserved.

`swift test`: 132/132. `cargo test -p roost-linux`: 20/20
state-machine tests + 34 IPC-dispatch + the rest still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
charliek added a commit that referenced this pull request May 23, 2026
* fix(linux): Linux IPC robustness sweep (closes #80)

Address the deferred CR items from the inline-core refactor (PR #78).
Reconciled against the merged code: #1 (parse-error reply), #10 (pending
HashSet), #13 (smoke-test ordering) were already done; the rest are fixed
here. Project is early-stage, so the bar is long-term correctness.

PTY supervisor (crates/roost-linux/src/daemon/pty.rs, tab_session.rs):
- #11: wait task now removes the session on child exit (sessions behind
  Arc<Mutex>), so writes to a dead PTY return NotFound instead of
  silently succeeding.
- #12: close() escalates SIGHUP -> SIGKILL after a grace period. The
  cloned portable-pty ChildKiller only sends SIGHUP, so a shell that
  traps/ignores it used to outlive close(); a reaped flag + pid watchdog
  force-kill it, mirroring the Mac teardown.
- #8: send_input/send_resize feed one per-tab serial channel drained by a
  single task, so keystrokes can't reorder (the old per-call tokio::spawn
  could under the multi-thread runtime).

Workspace state (crates/roost-linux/src/daemon/state.rs):
- #4: new project/tab position uses max(position)+1, not len()/count(),
  so a delete-then-create can't collide.
- #5: every mutator now publishes its event while still holding `inner`,
  so broadcast order matches commit order; persist runs after the drop.
  The events-resync Resync path is preserved.

Server framing (crates/roost-ipc/src/server.rs):
- #2: on an envelope decode failure, peel `id` from the raw JSON (string-
  or number-encoded) so the parse-error reply lands at the client's id,
  not 0.

Lifecycle (crates/roost-linux/src/main.rs, ipc.rs, app.rs, messages.rs):
- #7: IPC bind is now a synchronous startup requirement; bind failure
  aborts startup instead of leaving the UI socket-less.
- #6: a second launch dials the new `app.activate` op; the handler
  forwards to the GTK thread which raises the running window.
- #9: events.subscribe returns not-implemented (Linux + Mac) instead of a
  false {} ACK, so clients don't wait forever. Real streaming is deferred
  to its first consumer (roostctl watch).

Tests: write-after-exit -> NotFound, SIGHUP-ignoring child force-killed,
send_input ordering, position max+1 after delete, id-preserving parse
error, bind-failure surfaced, events.subscribe not-implemented. Rust
workspace + Mac build/clippy/tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(mac): IPC handler dispatch harness (#80 follow-up)

The Rust handler has tests/ipc_dispatch.rs; the Mac IPCHandlerImpl had
no equivalent, leaving its hand-written cross-cutting logic untested —
strict unknown-field rejection (decodeParams), ipcDim u16 validation,
mapWorkspace/mapPty error-code mapping, the not-implemented/unknown-op
paths, and result encoding. The two handlers must stay behaviorally
convergent over the shared wire contract, so this guards that.

Calls IPCHandlerImpl.handle(op:params:) directly — no socket. Exercises
only non-PTY-spawning ops to avoid the forkpty/swift-testing SIGTRAP that
disables the PTY paths elsewhere (tab.open stays on the manual pass; the
error-mapping ops reach the supervisor only on the lookup-fails path).

Seeds 8 tests: events.subscribe→not-implemented (the #9 path),
unknown-op, unknown-field strictness, project.rename→not-found,
tab.resize out-of-range→invalid-param, tab.resize missing→not-found,
identify profile echo, project.create→tab.list round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(linux): address CodeRabbit review on #80 sweep

Resolves 4 of CodeRabbit's 6 findings on PR #86 (the persistence-ordering
finding is pre-existing/orthogonal and tracked separately).

pty.rs:
- Major: spawn() acquired the master reader/writer AFTER spawn_command,
  so a try_clone_reader/take_writer failure returned with a live child
  and no wait task — an orphaned PTY. Acquire reader+writer BEFORE
  spawning, making spawn_command the last fallible step (no orphan).
- Major: the exit wait task removed the session by tab_id alone, which
  could evict a newer session that reused the same tab_id (close() frees
  the slot synchronously; pty_smoke reuses id 42). Gate the remove on
  Arc::ptr_eq of the per-spawn `reaped` identity so only the owning
  waiter deletes. (My prior "monotonic ids" comment over-assumed caller
  discipline; the supervisor can't rely on it.)
- Major: input and resize were unified at TabSession but re-split into
  the supervisor's two channels (select!), so mixed input/resize wasn't
  FIFO end-to-end. Merge them into one WriterCmd channel drained by a
  single ordered loop — genuine submission-order delivery.

ipc.rs + messages.rs:
- Minor: app.activate ACK'd any payload. Add an empty strict
  AppActivateParams and decode it so the op validates its envelope
  (rejects unknown fields) like every other op.

IPCHandlerTests.swift:
- Minor: replace `as? T != nil` (SwiftLint prefer_type_checking) with a
  meaningful empty-array assertion on a fresh project's tabs.

Rust clippy --all-targets clean; roost-ipc + roost-linux tests green
(A3 ordering + dup-spawn still pass under the new channel/guard). Mac
swift build + 140 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(linux): order state.json persistence by commit seq (CR #3)

Resolves the last CodeRabbit finding on PR #86. persist_async did
synchronous file I/O on the caller thread after dropping `inner`, so two
concurrent mutators could write state.json out of commit order — a slow
earlier commit could clobber a newer one, regressing restart durability.

Keep persistence synchronous (state_persist.rs and restart-reload depend
on it; an async writer thread would break that), but make it ordered:
- Inner.persist_seq: a monotonic commit counter bumped under the lock when
  a snapshot is taken, so the seq reflects commit order.
- snapshot_for_persist now returns (SnapshotFile, seq).
- persist(seq, snapshot) serializes on a new persist_guard mutex and drops
  any snapshot whose seq is <= the highest already written, so the newest
  committed snapshot always wins regardless of which thread races first.

Newest-wins verified by persist_drops_stale_out_of_order_writes. Rust
clippy --all-targets clean; roost-ipc + roost-linux suites green (incl.
state_persist).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style: rustfmt ipc.rs import block

The AppActivateParams import addition left the use block un-reflowed;
CI's rust-lint (cargo fmt --all -- --check) caught it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant