Skip to content

Fix subscription leak from composer reloads#36828

Merged
peppy merged 1 commit intoppy:masterfrom
bdach:fixing-leaks-without-clanking
Mar 5, 2026
Merged

Fix subscription leak from composer reloads#36828
peppy merged 1 commit intoppy:masterfrom
bdach:fixing-leaks-without-clanking

Conversation

@bdach
Copy link
Collaborator

@bdach bdach commented Mar 5, 2026

The reproduction scenario for the subscription leak is as follows:

  1. Switch to a scrolling ruleset (anything but osu! from the standard ones).
  2. Select a beatmap to edit.
  3. Load the composer.
  4. Go to timing tab.
  5. Change a timing point.
  6. Go back to the composer.

At this point, EditorChangeHandler.OnStateChange will have multiple of the same delegate in the invocation list.

Screenshot 2026-03-05 at 11 15 55

That in turn is caused by the fact that changing a timing point does incur a full reload of the composer via the following flow:

private void expireComposeScreenOnControlPointChange() => editor?.ReloadComposeScreen();
public void ReloadComposeScreen()
{
screenContainer.SingleOrDefault(s => s.Type == EditorScreenMode.Compose)?.RemoveAndDisposeImmediately();
// If not currently on compose screen, the reload will happen on next mode change.
// That said, control points *can* change on compose screen (e.g. via undo), so we have to handle that case too.
if (Mode.Value == EditorScreenMode.Compose)
Mode.TriggerChange();
}

This flow is my "fault"; see #28444. The reason why a full composer reload is used is not clear to my recollection at this time, but it is likely because it's just the least likely to fail. A smarter solution that wouldn't require a full reload would also entail checking that there exists a safe insertion point that allows replacing timing points in a way that will reflect everywhere it must. Including all of the IScrollingAlgorithm machinery and such.

In general it is not uncommon in the codebase to not bother to clean up some event callbacks if it is implicitly or explicitly guaranteed that both objects bound by the callback will always get disposed in tandem at the same time. This was true with this particular flow to a point, which was until that full composer reload was implemented.

To address the elephant in the room

Someone will inevitably notice #36824 which was a clanked pull request pointing out this leak. And then someone will inevitably call this "AI discrimination"! Gasp!

So first of all, let me stop you right there. Yes, as far as I am personally concerned, it is "AI discrimination". I invoke the full force of the Butlerian Jihad.

The clank army's goal is to eradicate my job and make me work in an Amazon warehouse instead. Or, if not that, at least my job is to be rid of all remnants of fun I still get from it and for me to be reduced to that one guy from the meme "i guess we're doin circles now". You know the one.

I resent this. You attack me directly. I do not perceive the need to meet you halfway or be civil.

That said, I have too much respect for the users of this software to leave reports of potentially real issues unchecked. So I did check, and it was real. And you know what? Good job to the clanker. It did what it was designed to do: it parsed a code file, recognised a hole in a pattern it was designed to recognise, and invoked forms of language given to it to communicate this to the meatbag that opened that PR.

And here's the thing: my primary issue is with that meatbag that opened that PR. That meatbag served no functional purpose in any of this. The meatbag took a hose that spews 90% water and 10% raw sewage at random intervals and pointed it at my house directly, claiming that they just want to clean it. At no point did the meatbag appear to have the common decency to pull out a container, pour some magic liquid out, check if there's sewage in it, and filter it out if there is any. But no, that would take effort and thought, would it not? The effort and thought that is required of me to review the clanker's work?

The PR had no reproduction scenario, and had testing checkboxes that were presumably meant for me to check off. Why is it my job to figure all of this out rather than the submitter meatbag's?

I do not have obligations towards spew-hose-pointing meatbags. Point that hose at your own backyard at your peril.

If you actually manage to get the clanker to filter out all of the spew without fail itself, my only win condition is gone. But it is not yet that time. So at least have the decency to check for the spew yourself, rather than telling the clanker to put checkboxes in the PR descriptions telling me to check for it.

The reproduction scenario for the subscription leak is as follows:

1. Switch to a scrolling ruleset (anything but osu! from the standard
   ones).
2. Select a beatmap to edit.
3. Load the composer.
4. Go to timing tab.
5. Change a timing point.
6. Go back to the composer.

At this point, `EditorChangeHandler.OnStateChange` will have two of the
same delegate in the invocation list. That in turn is caused by the fact
that changing a timing point *does* incur a full reload of the composer
via the following flow:

https://github.com/ppy/osu/blob/15b6e28ebe888b1a87574891be1a0db3b04093b7/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.cs#L145
https://github.com/ppy/osu/blob/64a29313a852d50095ae4b7ea8f22fde23aa634f/osu.Game/Screens/Edit/Editor.cs#L1137-L1145

This flow is my "fault"; see ppy#28444. The
reason why a full composer reload is used is not clear to my
recollection at this time, but it is likely because it's just the least
likely to fail. A smarter solution that wouldn't require a full reload
would also entail checking that there exists a safe insertion point
that allows replacing timing points in a way that will reflect
everywhere it must. Including all of the `IScrollingAlgorithm` machinery
and such.

In general it is not uncommon in the codebase to not bother to clean up
some event callbacks if it is implicitly or explicitly guaranteed that
both objects bound by the callback will always get disposed in tandem at
the same time. This *was* true with this particular flow to a point,
which was until that full composer reload was implemented.
@bdach bdach self-assigned this Mar 5, 2026
@bdach bdach added the type/performance Deals with performance regressions or fixes without changing functionality. label Mar 5, 2026
@bdach bdach moved this from Inbox to Pending Review in osu! team task tracker Mar 5, 2026
@peppy
Copy link
Member

peppy commented Mar 5, 2026

private void expireComposeScreenOnControlPointChange() => editor?.ReloadComposeScreen();

that's a scary one, i checked on this and did not immediately find that fail case.

@peppy peppy merged commit 4389745 into ppy:master Mar 5, 2026
8 of 10 checks passed
@github-project-automation github-project-automation bot moved this from Pending Review to Done in osu! team task tracker Mar 5, 2026
@bdach bdach deleted the fixing-leaks-without-clanking branch March 5, 2026 14:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:editor size/S type/performance Deals with performance regressions or fixes without changing functionality.

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

2 participants