Skip to content

fix: leaks, hangs and visible bugs from the idiom audit - #6026

Merged
norman-abramovitz merged 121 commits into
cloudfoundry:developfrom
nabramovitz:norm/fix/6019-6021-audit
Oct 19, 2026
Merged

norman-abramovitz merged 121 commits into
cloudfoundry:developfrom
nabramovitz:norm/fix/6019-6021-audit

Conversation

@nabramovitz

@nabramovitz nabramovitz commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

The fixes for #6019 (leaks), #6020 (hangs) and #6021 (visible bugs), one commit per fix. Each fix has a unit test that fails on the code before it and passes after. Rebased on develop; release note in changelog.d/0013-audit-leaks-hangs-rendering.md.

Shared pieces. @stratosui/store gains three helpers in helpers/signal-interop.ts:

  • observeSignal(source, injector): unlike toObservable(), whose effect starts at call time and ends only with the injector, its effect starts on subscribe and ends on unsubscribe.
  • combineLatestOrEmpty(sources): emits [] for an empty list, where combineLatest([]) completes without a value and leaves take(1) callers and spinners waiting.
  • createSignalWrapper(): one BehaviorSubject-backed copy of the callable signal wrapper that four files defined privately.

#6019, leaks.

  • Per-call root watches now end with their subscription: route guards, the feature-flag permission check, autoscaler availability, the SCM endpoint lookup, the permission fallback, org and space quota lookups, endpoint connects, the endpoint-name cell, and the helm release tabs.
  • The app-stats poll, the metrics window poll and the metrics observe() job stop with their page; the stats poll used to send one request per Cloud Foundry every 30 s until a reload.
  • Disconnecting or unregistering a Kubernetes endpoint clears its cached data and errors, and late responses no longer write them back.
  • Unused release hooks and counters in five CF registries are removed. Failed git lookups are no longer cached for the session.

#6020, hangs.

  • UAA setup wizard: step 1 checks the credentials; a failure clears the "Saving configuration" overlay so the error shows (the local account wizard too).
  • Kube config import no longer stalls at the first Connect and moves on past a cluster that fails.
  • Role picker (Add User, Manage Roles) no longer loads forever; a failure shows Retry.
  • Manage users loads past a bad user guid and fetches each user once.
  • Git waitForValue$() errors when the lookup fails; deploy step 2 reports it.
  • The routing bar ends on a failed navigation (it stuck at 95 % after a chunk 404).
  • The stepper's 5 ms first-step poll is replaced by an effect.
  • After a failed session check near expiry, navigation no longer hangs until a reload (auth reset left verifying set).

#6021, visible bugs.

  • Deploy keeps its completion notice after you leave the page, keeps every log line, and labels a second deploy "Deploy again".
  • SSH shows its progress and server errors; the header switches to Disconnect once connected.
  • Git tab, autoscaler gauges and "no policy" text, broker card Space and Broker Link rows, invite results, helm sidebar, resource viewer header and Delete, kube dashboard flag, chart spinners, home and compact app cards.
  • The Autoscale tab is registered again (lost in the NgModule removal), shows the autoscaler's reason for a refusal, and its history and metric calls no longer fail with 400; its pages get the app services they need (NG0201 since the V3 cutover).
  • The endpoints page renders a custom "no endpoints" component again.
  • Helm release pages no longer show earlier data after a failed reload; Kubernetes error lists keep the newest 50.
  • An active user is no longer logged out as idle after a short session check failure.
  • Metric charts with a time range selector render (NG0203).

Removed. The users/remove page and its three routes; it had no in-app link since 44cacd1, and the users tabs remove users inline.

Test helpers. Two cloud-foundry test providers bound the wrong HTTP backend, so every request in 29 specs threw. All 29 pass before and after the fix.

Fixes #6019, fixes #6020, fixes #6021.

AI disclosure (RFC-0047): prepared with AI coding assistance; reviewed by the author.

toObservable() creates its effect when called and ends it only when
the injector is destroyed, so every call with the root injector
leaves an effect running for the rest of the session. observeSignal()
creates the effect on subscribe and destroys it on unsubscribe, with
the same first-emission timing.

combineLatestOrEmpty() emits [] for an empty list, where
combineLatest([]) completes without a value and leaves take(1) and
loading indicators waiting.

createSignalWrapper() is a shared copy of the callable signal wrapper
that four files define privately. Its asObservable() is backed by a
BehaviorSubject: synchronous, one emission per write, no injection
context, and it outlives the view that created it.

Callers move to these helpers in the following commits.
The feature-flag check built a toObservable() on the root injector for
every subscription, and each one kept an effect running for the rest
of the session (non-admin users, on every CF endpoint page, orgs list
and manage-users). Watch the flags with observeSignal(), which ends
with the subscription. The build-after-load factory stays.
isAutoscalerEnabled() and fetchAutoscalerInfo() created a root
toObservable() effect at call time, one per app page visit, even when
nothing subscribed. Use observeSignal(), which starts on subscribe and
stops on unsubscribe.
Every API lookup for a registered private or enterprise git endpoint
created a root toObservable() effect that take(1) could not end; the
deploy-from-git name validator did it once per debounced keystroke.
Use observeSignal() so take(1) ends the watch.
The fallback branch of CurrentUserPermissionsService watched a fresh
endpointById() computed through a root toObservable() per call, which
outlived its subscribers. Use observeSignal().
The release shell is a reused route, so its helper service lives
across tab switches. Each tab visit and graph node click built a new
toObservable() on that injector, and the effects piled up until the
user left the release. Build the per-call streams with observeSignal()
so they end with their subscription.
The import passed each row's signal wrapper to subscribe() as the
observer. RxJS took the callable as next(), and the wrapper's callable
only reads its signal, so every connect status was dropped: each
Connect row stalled and the clusters after it were never imported.
Forward each status to the row explicitly, complete the row as failed
when the connect errors, and move on to the next action when a row
has no user or no usable auth data.
asObservable() returned a new observable on every call. A template
that binds it directly would resubscribe on every change detection
pass and get the BehaviorSubject's last value replayed each time.
Build it once, as the private copies did.
The deployer's signal wrappers used toObservable() on the step's
injector. Leaving the deploy page destroyed that injector in the same
pass that subscribed the completion notice, so the stream completed
empty: no success or failure snackbar and no close(). toObservable()
also coalesced writes per change detection pass, so a burst of log
frames lost lines in the deploy log.

Use the shared BehaviorSubject-backed createSignalWrapper(), and build
the deploy state stream once instead of a new toObservable() on every
open().
Drop the private createSignalWrapper() copies in the kube config
helper, the config import and the namespaces filter for the store
one. The import's per-row observable cache goes too: asObservable()
now returns the same instance on every call.
With no CF endpoint connected, the per-endpoint scope, role and
feature-flag checks combined an empty list with combineLatest(), which
completes without a value, so they never answered. can([]) did the
same in CurrentUserPermissionsService. Use combineLatestOrEmpty(): an
empty set answers false, and an empty permission config is denied.

reduceChecks([]) answered true for both combinators, so with no CF
connected the admin check said "admin" and every org, space and
complex CF permission without an endpoint passed (the app wall showed
Create Application). An empty list now gives some([]) = false for ||
and every([]) = true for &&.
A scope check across all connected CF endpoints mapped each guid to a
check of the outer, undefined endpoint guid, so every element answered
false and a user with the scope on a connected CF was denied (e.g. the
read-only admin and global auditor entries of APPLICATION_VIEW and
SPACE_VIEW, FIREHOSE_VIEW). Check each endpoint's own guid.
Step 1 started the scope check, then read the setup state through a
toObservable() stream that still replayed the previous value, so the
first click succeeded with no scopes whatever the credentials were,
and a retry reported the previous attempt. Finish could then save an
empty admin scope, which leaves the backend in setup mode.

Finish waited with take(10) on replayed streams; when the backend
never confirmed it ended in an EmptyError without clearing the
"Saving configuration" overlay, which hid the error until a reload.

Await each request and read the state it left. Step 1 fails when the
UAA returns no scopes; Finish is enabled only for a returned scope.
After the save, waitForSetupConfirmed() polls session verification
with a 30 s deadline, and any failure clears the overlay so the error
shows.
The local account Finish waited on replayed streams with take(10).
When the save failed or the backend never left setup mode the wait
ended in an EmptyError and the "Saving configuration" overlay stayed
up over the error. Await the save, read its state, confirm with
waitForSetupConfirmed(), and clear the overlay on any failure.
UaaSetupSignalService only re-exposed UaaSetupDataService.state for
the two setup wizards, which now read the data service directly.
A scope check or save that never answered kept the wizard busy, or
the "Saving configuration" overlay up, forever. Time the requests
out: the check after 130 s (the backend's own UAA call gives up at
120 s, so its clearer error arrives first) and the save after 60 s,
with a message saying the server did not answer.
A failed scope check in step 1 was reported as "Failed to save
configuration." although nothing was saved. Say "Failed to check the
UAA credentials." instead; the save wording is unchanged.
A register failure only skipped the cluster's connect when the error
carried a message, so a failure without one left the connect queued
against an endpoint that was never registered, and the register row
showed nothing. Skip on any failure and fall back to "Failed to
register endpoint".
getAuthDataForConnect() read authTypes[type].component for a type
the catalog does not know, and the TypeError was thrown inside the
previous row's subscriber, which stopped the import. Throw a clear
"Unsupported authentication type" error from the helper, and have
the import mark that connect failed and move on when the helper
throws.
getAuthDataForConnect() creates the auth type's form component to
read its values, and destroyed it only when nothing threw on the way,
so a failing getValues() or getBody() left it alive. Destroy it in a
finally block.
The fixture used authType 'token', which parseAuth() never produces.
Use a client certificate user and the kube-cert-auth data parseAuth()
builds for it.
A connect that failed without a message, resolved or rejected, left
the import row with an empty error. Fall back to "Failed to connect
endpoint".
The Add User and Manage Roles picker showed "Loading organizations..."
until a reload in three cases:

- No orgs: the per-org edit check combined an empty list with
  combineLatest(), which completes without a value.
- A failed fetch: catchError() turned it into that same empty list.
- Every later open: publishReplay() on the root service cached the
  dead stream, so no new request was sent.

The component cleared its spinner only on next. Combine with
combineLatestOrEmpty(), let a failed fetch error and cache it with
shareReplay(), which resets on error, and clear the spinner on next,
error and complete. A failure shows "Could not load organizations."
with a Retry button. The component spec now awaits whenStable().
The org-name lookup for existing roles caught a failed request before
publishReplay(), so "no names" was cached on the root service for the
rest of the session. Cache with shareReplay(), which resets on error,
and recover outside the cache: the call still resolves without names
and the next one fetches again.
The roles service is a root singleton. Its existing and new roles
streams used publishReplay(1), whose subject outlives the refCount,
and toObservable() of the selected users, which holds the last value
it pushed. A new Manage Roles dialog therefore got the previous
dialog's roles first, and the picker could pre-pick the previous
users' orgs. Watch the users with observeSignal() and share with
shareReplay({ refCount: true }) so each dialog starts clean.
With no organizations to manage and nothing searched, the role picker
said "No organizations match." Say "You have no organizations where
you can manage roles." instead; a search with no hits keeps the old
text.
Expanding an org whose space fetch failed left "Loading spaces..." up
for good and raised an unhandled error. Clear the spinner on next,
error and complete, and show "Could not load spaces." with a Retry
for that org instead of "No spaces in this organization."
The users/remove page lost its last in-app link when the old users
list config was deleted in 44cacd1. The users tabs now remove
users with inline bulk actions (Remove from Space, Remove from Spaces,
Remove from Org and Spaces), so the page was only reachable by typing
its URL, and it hung on an empty selection and fetched each user four
times. Remove its three routes, the component, its URL helper and its
e2e page object, and reword the comments that pointed at it.
The page read its users from the users= / user= query parameter:

- One lookup per guid in combineLatest() with no error handler, so a
  single deleted or mistyped guid failed the whole stream and the
  roles step spun forever.
- The cold stream had two subscribers (the title and the seeding), so
  each user was fetched twice.
- users=, gave no guids and combineLatest([]) never emitted; users=
  fell through to empty wizard state.

Parse the guids once, drop empty ones, and load each user once with
its own error handling. The page shows the users that loaded with an
error naming each guid that did not, and "No users selected" (or
"None of the selected users could be loaded") with a link back when
none load. The spec now awaits whenStable().
observe() created a root effect and dropped its ref, so stop() only
cleared the current timer: a later request change fetched again and
re-armed polling. Keep the ref, clear the timer in the effect cleanup
and destroy the effect in stop(). The kube node metric stream now uses
observeSignal so its watch ends when the stats card unsubscribes.
The org and space services looked their quota up again on every
emission of the org or space, so a rename, which patches the org or
space but keeps its quota, fetched the same quota again. Skip
repeated quota GUIDs.
The no-endpoints customization never rendered. Since the strict-mode
change the view query named a template ref that does not exist, so
the container was undefined and the create call threw inside a
try/catch. The template now renders the custom component with
ngComponentOutlet in place of the default message, and the unused
templates and subscription are gone.

The persistence-flag check now also ends with the page, so a flag
that never arrives no longer holds the destroyed component.
Each connect lifted three endpoint signals into observables with
toObservable(). Those effects end only with the injector they are
given, which is the page or stepper that outlives every connect, so
each connect (dialog, stepper entry, kube cluster import) left three
effects running after destroy().

Keep the effect and replay subject for each bridge and end them in
destroy(). Values and timing are unchanged: one effect per signal,
created at construction, latest value replayed to later subscribers.
The connect step of the register stepper made a new connect each time
it was entered and destroyed only the last one when the stepper
closed. Going back with Previous and on with Next left the earlier
connect's subscriptions and signal bridges running. Destroy the
previous connect first.
CfInfoDataRegistry, OrgDataRegistry and SpaceDataRegistry counted
acquire/release calls, but nothing read the count and nothing
evicted at zero; entries live for the session and leave only
through evict() when an org or space is deleted. Remove refCount,
release() and the destroy hooks and effects that existed only to
call it, and say in each registry that entries are sticky on
purpose.
The Broker Link row held only a launch icon, so screen readers
announced the link as "launch". Show the broker URL as the link
text, hide the icon from assistive tech, and say that the link
opens in a new tab. Add rel="noopener noreferrer" to match the
other new-tab links.
When a kubernetes endpoint was disconnected or unregistered, only the
per-endpoint registry entry was dropped. Pods, nodes, services, the
generic resource caches (Secrets included), analysis reports and helm
releases kept the data fetched with the old credentials for the rest
of the session.

Add a small root eviction bus. Each cache registers an evictor and the
endpoint hook calls it on departure, so every cache drops the departed
endpoint's entries and leaves other endpoints alone. Remove the
namespace service's direct-fetch cache, which was written but never
read.
The apps list config is a root singleton, so the stats poll and the
page-change effect it started kept running after the user left the
page: one app-stats request per Cloud Foundry every 30 seconds until
reload. Callers now pass their DestroyRef and the poll stops when that
page is destroyed. A later visit starts a fresh poll, and a page that
is already gone does not start one.

The application wall also kept its create-permission subscription
alive after it was destroyed; it now ends with the wall.
The endpoint name cell made a new signal-to-observable bridge each
time its row changed, and each bridge kept watching the endpoints
map until the cell was destroyed. Read the endpoint through one
computed signal keyed on the row's id instead, and render it
directly from the signal.

The template-facing field endpoint$ becomes the signal endpoint, and
an endpoint removed from the map now renders nothing rather than its
last name.
A failed reload of a helm release's detail or history left the data
fetched before it in place. After a reconnect with narrower
credentials the page went on showing release data the user can no
longer read. Blank the detail and history signals on error, the way
endpoint eviction does, so the page keeps its signals and shows the
empty state.
The kubernetes data caches capped their error lists with
slice(0, 50), which keeps the oldest entries: once 50 errors
existed, new ones were never recorded. Keep the newest 50.

Errors recorded against an endpoint stayed after it was
disconnected or unregistered. Each cache's eviction now also
drops errors that affect only the departed endpoint, and the
namespace cache registers an evictor for its errors.

Pod errors were never cleared. A pod error now names its
scope, and a later successful load of that scope clears it,
as the analysis cache does for its endpoints.
A full apps drain (loadApps/refreshApps) replaced the app list but
left the recent apps list as load() fetched it, so the home card kept
deleted or renamed apps and old states after a refresh. Rebuild it
from the drain with the backend's rule: newest updatedAt first,
capped at ten.

removeApp only filtered the recent list, so it shrank below ten
after a delete. Refill the freed slot from the full list when one is
loaded.
The recent apps card could source its own apps from the endpoint
data registry when no allApps$ was given. Every caller passes
allApps$, so that branch never ran; it acquired registry data it
never released and bridged signals on the component injector.
Remove it.
load() never started loadDetails(); detail views load the full lists
themselves. Say so.
The service broker card and the broker table cell started a space
load in an effect and never ended it, so the subscription stayed
open after the broker's space changed or the component was
destroyed. The effect now keys on the space by value, ends the load
in its cleanup, and runs the acquire untracked so the load's own
signals do not re-run it.
Eviction cleared the root kube caches but not the requests already
on the wire, so a response landing after a disconnect wrote the old
credentials' data (or error) back. The eviction bus now keeps a
per-endpoint epoch; pods, analysis, node, service and generic
resource loads check it before writing and drop a late response.

namespacesForEndpoint held the per-endpoint service it got at call
time; after the endpoint left and came back, a page kept reading
the orphaned instance. The projection now looks the service up
again after each eviction.
A releases request in flight across an eviction wrote the departed
endpoint's releases back; the load now snapshots every endpoint's
eviction epoch when it starts and drops the evicted ones.

A pod request finishing after an eviction or refresh deleted the
newer request's dedupe entry, so the next read sent a duplicate.
It now removes only its own entry. Pod reads no longer touch the
endpoint registry: the connect hook creates the entry, and a read
for a departed endpoint re-created it.

An analysis list request replaced by refresh() still wrote its
result and dropped the newer request's handle. A response now
writes only while the cache entry still holds its request.
A short invalid session (a failed keepalive near expiry) stops the
user activity listener. The listener was re-created only when it had
never existed, and a closed Subscription still counts as existing, so
it never came back. The user then looked idle and was warned and
logged out while working. Re-create it when it is closed, as the
session poll already does.
A chart with a projected time range selector subscribed to it in
ngAfterContentInit with takeUntilDestroyed() and no DestroyRef. That
hook is outside the injection context, so it threw NG0203 and the
chart never rendered. Pass a DestroyRef injected as a field.
The keepalive verify runs near session expiry. When it fails, auth
resets to the default state, which since 2025-11 starts with
verifying set for app init. Nothing verifies again, and the auth
guard waits for verifying to clear, so every later navigation hung
until a reload. Reset with verifying cleared: the next navigation
goes to the login page, which verifies and returns the user if the
session is still valid.
Release note for cloudfoundry#6019, cloudfoundry#6020 and cloudfoundry#6021: the pages that waited
forever, the views that did not show, the background work that kept
running, and the removed users/remove page.
The scaling history and metric history handlers always forwarded
start-time, end-time, page, results-per-page and order, empty or not.
The frontend sends the sort as order-direction, so order went out
empty, and the autoscaler (14.x and later) rejects that with 400
("order": invalid value). Every bound app's Autoscale tab showed the
raw HTTP error, and the metric charts stayed empty.

Forward only the parameters the caller sent, including
order-direction, which both endpoints read (order is deprecated and
ignored on scaling histories).
Since the V3 cutover (fff9dc8) ApplicationService needs
AppDetailDataService and the services it depends on, which only the
app page's base component provided. The autoscaler pages provided
ApplicationService alone, so the policy editor, credential, metric
and history pages all failed with NG0201 and rendered nothing.

Export the app page's provider list as APP_DETAIL_PROVIDERS and use
it for both base components.
@nabramovitz
nabramovitz force-pushed the norm/fix/6019-6021-audit branch from 920092b to 5858eb4 Compare October 19, 2026 18:27

@norman-abramovitz norman-abramovitz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - Live testing will be able to find more things broken

@norman-abramovitz
norman-abramovitz marked this pull request as ready for review October 19, 2026 22:01
@norman-abramovitz
norman-abramovitz merged commit 8f8cfed into cloudfoundry:develop Oct 19, 2026
24 checks passed
@nabramovitz
nabramovitz deleted the norm/fix/6019-6021-audit branch October 20, 2026 07:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants