Skip to content

Commit 4e7e8ad

Browse files
pyricauclaude
andcommitted
Set a verdict beside the panes, not in a dialog over them
Setting one was an `AlertDialog`, and the step that lists the verdicts a new one contradicts is what that shape got wrong. Two things a reader wants there are behind the scrim: why two verdicts can disagree at all, which every other label in this window answers with a `?` that opens the reference in a tab — and a tab opened behind a modal is a tab you cannot reach, so the only way to explain it was to draw the paragraph above the list, all 265 characters of it, every time, for somebody who read it once. And the objects themselves. Each verdict being overruled is an object, and the reason somebody typed for it is the case for the other reading, so weighing it against yours is sometimes going and looking — which a dialog can only offer by being dismissed, and dismissing it throws away the half typed reason it was holding. So `SettingVerdict` is state `HeapDumpDive` keeps per tab id, and `LeakStatusSetter` draws it where a note goes: under the tab's title, above the panes whose chain and sizes are the evidence the verdict is drawn from. The paragraph is now the `CONFLICTING_VERDICTS` page, each conflicting verdict is a link to its object, and the tab you came from is still half set when you come back to it. Tab ids are never reused, so an entry can only be about the tab it was made for, and closing that tab drops it. The three surviving dialogs are all about getting a heap dump rather than reading one — `Take heap dump…`, `Bitmaps from the live process`, and the one a `shark://` link asks — so there is nothing behind them worth reaching. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 1106cdc commit 4e7e8ad

9 files changed

Lines changed: 372 additions & 131 deletions

File tree

docs/shark-dive-changelog.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,14 @@ uses, without the one for a newly recognized library leak:
140140
* 🔀 The line above the map saying no object is held only through a `java.lang.ref.Reference` now says
141141
`Nothing here is held only weakly.`, in the words of the `Soft`, `Weak`, `Phantom` and `Finalizer` rows it
142142
sits under. Which class those four have in common is the page behind the `?` beside it.
143+
* 🔀 **Setting a verdict happens beside the panes, not in a dialog over them.** It is a panel of the tab it
144+
was started from now, under the tab's title where a note goes, so everything the window can do still works
145+
while one is half set. Which is what the step listing the verdicts a new one contradicts needed: each of
146+
those is an object, and the reason somebody typed for it is the case for the other reading — so each is now
147+
a link, and going to look at it opens a tab and leaves your verdict where it was. Why two verdicts can
148+
disagree at all moved out of a paragraph drawn above that list every time and behind the `?`, as
149+
[Verdicts that disagree](shark-dive-reference.md). A tab per window can have one going at once, and closing
150+
the tab is what abandons it.
143151
* 🔀 **An empty list says what fills it, and stops there.** `Nothing starred` used to name the panel the star
144152
is in, the empty **Agent logs** screen explained what an agent is and how to point one at the window, and
145153
the window with its last tab closed reassured you that the heap dump was still read. Each of those is now

docs/shark-dive-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ sentence below.
1919

2020
--8<-- "docs/shark-dive-reference/stuck-shading.md"
2121

22+
--8<-- "docs/shark-dive-reference/conflicting-verdicts.md"
23+
2224
--8<-- "docs/shark-dive-reference/weaker-references.md"
2325

2426
--8<-- "docs/shark-dive-reference/other-ways.md"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
## Verdicts that disagree
2+
3+
Everything a stuck object holds is stuck too, so a verdict set by hand can contradict one set earlier.
4+
5+
It runs both ways. Anything a stuck object dominates is only still in memory because that object is, so it
6+
is stuck as well; and anything holding an object that belongs in memory belongs there too, since something
7+
has to be keeping it. `Stuck` above and `Expected` below is a pair of verdicts that cannot both be right,
8+
so before writing anything Shark Dive walks the references and works out which of the verdicts already set
9+
by hand the new one runs into.
10+
11+
**Every one of them is listed rather than counted**, with the reason it was given, because that reason is
12+
the case for the other reading. Whoever is about to overrule it is the only person who can weigh the two,
13+
and they can only do that if they can read what they are overruling. Each is a link to the object it is
14+
about, and going to look at it opens a tab of its own: the verdict you were setting stays where it was,
15+
half set, in the tab you left.
16+
17+
Keeping this one flips the verdicts that disagreed to the opposite verdict, with what they said kept as
18+
part of the new reason — flipped rather than deleted, so the sentence somebody wrote is still in the file
19+
after somebody else has disagreed with it.
20+
21+
Undoing writes nothing at all: nothing reaches the disk until the last question is answered.

shark/shark-dive/AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ Each of the following is a change already made to this app, not a preference:
9898
see the bugs everybody else is hitting, and the hint that was in the way on day one is the one they go
9999
looking for on day thirty and can't find. So don't propose progressive onboarding here — the `?` is
100100
what that job was given to.
101+
- **A modal is a claim that nothing else will be needed, so a step of reading is never one.** The `?` is
102+
the rule above, and it opens a tab — behind a scrim, that tab is unreachable, so a dialog is a surface on
103+
which the only way to explain something is to print the paragraph on it. Setting a verdict was an
104+
`AlertDialog` for exactly one release and the conflicts step is what broke it: each verdict being
105+
overruled is an object worth going to look at, and looking meant dismissing the dialog and losing the
106+
half typed reason. It is now `LeakStatusSetter`, a panel of the tab it was started from, with its state
107+
kept per tab id by `HeapDumpDive`. The three surviving dialogs — `Take heap dump…`, `Bitmaps from the
108+
live process`, and the one a `shark://` link asks — are all about *getting* a heap dump rather than
109+
reading one, which is why nothing behind them is worth reaching.
101110
- **Prose belongs in the KDoc, `notes/decisions.md`, or a reference page.** Those are the three places
102111
something long enough to be worth writing goes. `notes/agent-surface.md` measures the same idea on the
103112
agent surface — ~80 tokens of name and description at rest, the ~2 k-token body only for a session that

shark/shark-dive/notes/decisions.md

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,8 +1051,8 @@ knows what the inspectors can't, and weighing the two would mean a status that c
10511051
inspector already picked. So `setByHandStatus` takes the reason someone typed and keeps the inspectors as the
10521052
record of what was overruled, exactly the way a conflict between two inspectors is recorded.
10531053

1054-
**A status without a reason is not a status.** `LeakStatusOverride` throws on a blank one and the dialog's
1055-
button is disabled until there is one. A status set by hand overrules the heap dump, so without the why it is
1054+
**A status without a reason is not a status.** `LeakStatusOverride` throws on a blank one and the button
1055+
that sets it is disabled until there is one. A status set by hand overrules the heap dump, so without the why it is
10561056
an assertion the next reader — a colleague, an agent, the same person in a month — has no way to check, and
10571057
one of those makes every other status in the dump worth less. `SET_BY_HAND` marks the reason wherever it is
10581058
read, so a green object somebody decided about is never mistaken for one an inspector recognized.
@@ -1089,7 +1089,7 @@ The propagation rules are what make it possible: a leaking object above forces e
10891089
leaking, and an object still needed below forces everything holding it to be needed. So two hand-set statuses
10901090
disagree when one of the objects is above the other, which is `HeapDominatorTreemap.reaches` asked **both ways
10911091
round** — one walk up `ReferrerIndex` per status already set, a question somebody asked rather than one the
1092-
pointer asks. `leakStatusConflictsWith` answers it before anything is written, and the dialog then lists every
1092+
pointer asks. `leakStatusConflictsWith` answers it before anything is written, and the panel then lists every
10931093
one of them by name, with the reason it was given, because whoever is about to overrule it is the only person
10941094
who can weigh the two.
10951095

@@ -1113,7 +1113,25 @@ who can weigh the two.
11131113
- **Nothing is written until the choice is made**, which is what makes "Undo" free, and the write is one
11141114
`LeakStatusFile.write` of the lot rather than one per status: a save that stopped half way through would
11151115
leave a heap dump whose statuses contradict each other, which is the one state this step exists to
1116-
prevent. It runs `NonCancellable` because the dialog closes as soon as it has.
1116+
prevent. It runs `NonCancellable` because the panel closes as soon as it has.
1117+
1118+
**Setting a verdict is a panel of the tab, not a dialog over the window.** It was an `AlertDialog` first,
1119+
and that shape is what made the conflicts step read badly: a modal is a promise that nothing outside it will
1120+
be needed, and this is the one step in the app where that is plainly false. Why two verdicts can disagree is
1121+
a paragraph — it was 265 characters of prose drawn above the list, every time, for a reader who had read it
1122+
once — and every `?` in the window answers that shape by opening the reference in a tab. A tab opened from
1123+
behind a scrim is a tab you cannot reach. Worse, each verdict being overruled is an *object*, and the reason
1124+
somebody typed for it is the case for the other reading: weighing it against yours is sometimes going and
1125+
looking at that object, which a modal can only offer by being dismissed, and dismissing it throws away the
1126+
half typed reason it was holding.
1127+
1128+
So `SettingVerdict` is state `HeapDumpDive` keeps per tab id, and `LeakStatusSetter` draws it where
1129+
`NoteSection` draws a note — under the tab's title, above the panes whose chain and sizes are the evidence
1130+
for the verdict. What falls out: the `?` on `CONFLICTING_VERDICTS` opens the reference in a tab, each
1131+
conflicting verdict opens the object it is about in a tab, and the tab you came from is still half set when
1132+
you come back to it. Tab ids are never reused (see `Tab.id`), so an entry can only ever be about the tab it
1133+
was made for, and closing a tab drops it. The other three dialogs stay dialogs: they are about acquiring a
1134+
heap dump rather than reading one, so there is nothing behind them to go and look at.
11171135

11181136
**One tab separated file per heap dump, in `~/.shark-dive/leak-statuses`.** Named after the dump the way
11191137
its notes are, and beside them rather than next to the dump, for the same reason: dumps come from device

shark/shark-dive/shark-dive-app/src/main/java/shark/dive/app/HeapDumpDive.kt

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import androidx.compose.material3.VerticalDivider
2525
import androidx.compose.runtime.Composable
2626
import androidx.compose.runtime.LaunchedEffect
2727
import androidx.compose.runtime.getValue
28+
import androidx.compose.runtime.mutableStateMapOf
2829
import androidx.compose.runtime.mutableStateOf
2930
import androidx.compose.runtime.remember
3031
import androidx.compose.runtime.rememberCoroutineScope
@@ -234,8 +235,17 @@ internal fun HeapDumpDive(
234235
val starring = rememberCoroutineScope()
235236
/** Which objects of this heap dump have a status someone set, which is what every chain is read with. */
236237
val overrides = leakStatuses.overrides
237-
/** Whether the dialog that sets one is open, for the object the tab is on. */
238-
var setsLeakStatus by remember { mutableStateOf(false) }
238+
/**
239+
* The verdict being set in each tab, by tab id, and empty in a window where nobody is setting one.
240+
*
241+
* Per tab rather than per window, because setting a verdict is not a dialog: it is drawn inside the tab
242+
* it was started from, so the reader can open the reference, go and look at a verdict this one disagrees
243+
* with, or read the chain, and come back to the reason half typed. Tab ids are never reused, so an entry
244+
* here can only ever be about the tab it was made for. See [SettingVerdict] and [LeakStatusSetter].
245+
*/
246+
val settingVerdicts = remember { mutableStateMapOf<Int, SettingVerdict>() }
247+
/** And the one in the tab on screen, which is the only one drawn. */
248+
val settingVerdict = tabs.selectedId?.let { settingVerdicts[it] }
239249

240250
// Nothing is under the pointer while a list is showing: the view isn't there, so the rectangle it was on
241251
// last is neither where the pointer is now nor what the tab is about.
@@ -419,9 +429,6 @@ internal fun HeapDumpDive(
419429
// And on the statuses set by hand, because they are half of what a chain says: setting one is asking the
420430
// window to read the heap dump through it, which is this read again.
421431
LaunchedEffect(session, place, overrides) {
422-
// Whatever was being decided was being decided about the object this is leaving, and a dialog that
423-
// outlived the tab it was opened from would set a status on an object nobody is looking at.
424-
setsLeakStatus = false
425432
if (place == null || place.viewRootObjectId == null) {
426433
details = null
427434
return@LaunchedEffect
@@ -741,22 +748,6 @@ internal fun HeapDumpDive(
741748
)
742749
}
743750

744-
if (setsLeakStatus && describedLeakStatus != null) {
745-
LeakStatusDialog(
746-
status = describedLeakStatus,
747-
// A walk up the references per status already set, which is a read of the heap dump like any other —
748-
// and one asked for, so it is not on the path the pointer takes. See [leakStatusConflictsWith].
749-
onFindConflicts = { override ->
750-
session.read("what setting ${hexObjectId(override.objectId)} to ${override.status} disagrees with") {
751-
it.tree.leakStatusConflictsWith(override, overrides)
752-
}
753-
},
754-
onSet = { override, solved -> leakStatuses.set(override, solved) },
755-
onClear = { leakStatuses.clear(describedLeakStatus.objectId) },
756-
onDismiss = { setsLeakStatus = false }
757-
)
758-
}
759-
760751
Column(modifier) {
761752
ScreenBar(
762753
starredCount = stars.objectIds.size,
@@ -770,7 +761,12 @@ internal fun HeapDumpDive(
770761
titleOf = { it.title ?: placeTitles[it] ?: NAMING_TAB },
771762
hasNote = { notes.hasNote(it) },
772763
onSelect = { id -> tabs = tabs.select(id) },
773-
onClose = { id -> tabs = tabs.close(id) },
764+
onClose = { id ->
765+
tabs = tabs.close(id)
766+
// A verdict being set belongs to its tab, so closing the tab is abandoning it — and leaving the
767+
// entry behind would hand it to whichever tab took that id, if ids were ever reused.
768+
settingVerdicts.remove(id)
769+
},
774770
onCopyLink = copyLink
775771
)
776772
// Two things in one row, and a rule between them: how the tab got here, then what it is on. The height is
@@ -798,6 +794,30 @@ internal fun HeapDumpDive(
798794
}
799795
}
800796
}
797+
// Above the note and the panes, directly under the verdict it is changing, and inside the tab rather
798+
// than over the window: the evidence for a verdict is the chain and the panes below, which a dialog
799+
// would have been covering. See [LeakStatusSetter].
800+
settingVerdict?.let { setting ->
801+
LeakStatusSetter(
802+
setting = setting,
803+
// A walk up the references per status already set, which is a read of the heap dump like any other —
804+
// and one asked for, so it is not on the path the pointer takes. See [leakStatusConflictsWith].
805+
onFindConflicts = { override ->
806+
session.read(
807+
"what setting ${hexObjectId(override.objectId)} to ${override.status} disagrees with"
808+
) {
809+
it.tree.leakStatusConflictsWith(override, overrides)
810+
}
811+
},
812+
onSet = { override, solved -> leakStatuses.set(override, solved) },
813+
onClear = { leakStatuses.clear(setting.status.objectId) },
814+
// In a tab of its own and in front, the way a `?` opens a page: going to look at a verdict this one
815+
// disagrees with is reading, and this tab keeps what was typed into it.
816+
onOpenObject = { objectId -> openInNewTab(Place.Object(objectId)) },
817+
onExplain = explain,
818+
onDone = { tabs.selectedId?.let { settingVerdicts.remove(it) } }
819+
)
820+
}
801821
// Under the title it is about and above everything that describes it, so that what it is a note about is
802822
// the whole of what this tab is showing rather than whichever pane it happens to sit against.
803823
if (tabNote != null) {
@@ -884,7 +904,12 @@ internal fun HeapDumpDive(
884904
leakStatus = describedLeakStatus,
885905
isLeakStatusRead = leakStatuses.isRead,
886906
leakStatusProblem = leakStatuses.problem,
887-
onChangeLeakStatus = { setsLeakStatus = true },
907+
onChangeLeakStatus = {
908+
val tabId = tabs.selectedId
909+
if (tabId != null && describedLeakStatus != null) {
910+
settingVerdicts[tabId] = SettingVerdict(describedLeakStatus)
911+
}
912+
},
888913
onOpen = openObject,
889914
onCopyLink = copyObjectLink,
890915
onListInstances = { className ->

0 commit comments

Comments
 (0)