Skip to content

Keep running objects alive without a reference (#267) - #282

Open
cmsj wants to merge 14 commits into
mainfrom
t3/3e38ec92
Open

cmsj wants to merge 14 commits into
mainfrom
t3/3e38ec92

Conversation

@cmsj

@cmsj cmsj commented Oct 13, 2026

Copy link
Copy Markdown
Owner

Fixes #267.

JavaScript garbage collection must be unobservable, but collecting an object the user had dropped could stop real work: an unreferenced running hs.task was killed, so hs.task.shell() promises could never settle; an unstored hs.hotkey.bind() stayed registered but silently stopped firing; a visible hs.menubar item vanished. This adopts the rule Node and the HTML spec use for pending activity: an object that is running keeps running, with its callbacks, until it finishes or is stopped, whether or not the user kept a reference. Once it's inactive it's ordinary garbage.

Two retention mechanisms

Retention is unified across every module on exactly two mechanisms:

  • Callbacks: always JSCallback. ModuleRoot now pins each module's JS wrapper until shutdown, so JSCallback(owner: self) works on modules too. That fixes the module-owned callbacks in hs.notify, hs.urlevent and hs.midi, and the hs.window.js helpers are garbage collected shortly after startup #185 class of lost hs.foo.js properties. Every raw stored JSFunction/JSValue callback is converted, except modal one-shots (text prompt, file picker), which hold theirs until show() invokes it.
  • Child objects: the module's HSObjectTracker<T> (renamed from HSWeakObjectSet). It tracks children weakly, and setActive(_:_:in:) pins an active child's JS wrapper (not just the Swift object, or its JSCallbacks would still be collected). setActive is called only from the didSet of the state that defines activity, so every stop, finish or failure path un-pins it. Pins are stored centrally, and removeAllObjects() in shutdown() drops them all, so a missed un-pin lasts until the next reload at most and can't keep an old JSContext alive.

selfRetain, Unmanaged self-retains and the strong child registries are gone.

Behaviour changes

  • Running hotkeys, timers, taps, watchers, queries, searches, tasks, servers, sockets, ports, pings, shares, sounds and shown UI keep working without a reference.
  • Inactive objects are now collectable, e.g. a hidden hs.ui window, canvas, chooser or menubar item you don't keep a reference to. Previously these were held until destroy().
  • New hs.activeObjects() lists everything currently running.

Proposed release note:

Objects that are running now keep running until they finish or you stop them, even if you don't keep a reference to them: bound hotkeys, started timers and watchers, running tasks, shown windows and menu bar items, and so on. Previously, garbage collection could silently stop them. If your config relied on dropping a reference to make something stop, call its stop(), disable(), hide() or destroy() instead. Hidden windows, canvases and choosers you don't keep a reference to are now released. hs.activeObjects() lists everything currently running.

Bugs found along the way

  • hs.ui.webview callbacks were owned by the inline-built element, so they could be collected while the user still held the window. They now belong to the window, via a new JSCallback.transferOwnership(from:to:).
  • hs.ui onClick/onHover closures captured their callback strongly. A callback closing over its own window made a Swift/JS cycle the GC can't break. They now use window-owned JSCallbacks.
  • Bonjour advertise callbacks were owned by a private object with no JS wrapper, so they were collectable at the first GC.
  • Four hs.task tests used Thread.sleep in synchronous tests, which can never see a task's termination arrive.

Enforcement

  • JSTestHarness fails any test that ends with an object still active that should have finished on its own (tasks, one-shot timers, pings, sounds: finishesOnItsOwn in setActive).
  • scripts/check-object-lifetimes.js, run by npm run docs:test, rejects new selfRetain = self and strong HS* collections. The 5 remaining collections are listed with their justification.
  • The HSModule and HSTests skills document the rules.

Testing

  • Each migrated object has GC tests in both directions: active and unreferenced keeps working; inactive and unreferenced is collected.
  • I confirmed each "keeps working" test fails with pinning disabled. The audio device test is the exception, because a device is also kept alive by its own watcher emitter, so only its "collected" half is meaningful.
  • No GC test for hs.sharing (needs system UI), the Stream Deck and camera device watchers (need hardware), or Bonjour advertising (publishing doesn't fire in the VM). Their mechanisms are covered generically.
  • Full suite in the VM: 2366 tests passed, 3 known issues.

Docs

The getting-started lifecycle section told users to keep a reference to everything or it would stop. It's rewritten for the new rule (new anchor #object-lifecycle-running-things-keep-running), and the other guides, docstrings and npm run docs:generate output are updated.

🤖 Generated with Claude Code

cmsj and others added 10 commits October 13, 2026 14:43
JS garbage collection must be unobservable, but collecting an unreferenced
running HSTask killed its process (so hs.task.shell() promises could never
settle), and collecting a timer's wrapper silently dropped its callback.
Active objects now stay alive, with their callbacks, until they finish or are
stopped, as Node and the HTML spec do for pending activity.

Retention is unified on two mechanisms:
- Child objects live in the module's HSObjectTracker (renamed from
  HSWeakObjectSet). It tracks them weakly and pins an active object's JS
  wrapper. Activity is set only from the didSet of the state that defines it,
  and removeAllObjects() drops every pin on shutdown, so a missed un-pin can't
  outlive a reload. HSTask gets a State enum, replacing TaskTracker.
- Callbacks use JSCallback. ModuleRoot now pins module wrappers, so callbacks
  owned by a module are no longer collected, nor are properties hs.foo.js sets.

Enforcement: JSTestHarness fails tests that end with objects still active,
scripts/check-object-lifetimes.js (in docs:test) rejects new selfRetain and
strong child collections, and hs.activeObjects() lists active objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unstored hs.hotkey.bind() is the most common config line, but its
callbacks were only kept alive by the hotkey's JS wrapper, so after a GC
the key stayed registered and silently did nothing. Enabled hotkeys are now
active in the module's HSObjectTracker, and HotkeyManager's dispatch table
holds them weakly.

The harness teardown check now only flags objects whose activity finishes on
its own (tasks, one-shot timers), via a new finishesOnItsOwn flag on
setActive. Things that run until stopped are left to module shutdown, as in
the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hs.eventtap taps and hotkeys, hs.fs path and volume watchers, and
hs.network reachability, configuration watchers and pings now stay active in
their module's HSObjectTracker while running. This replaces selfRetain (which
kept the object alive but let its callback be collected) and the eventtap
module's strong arrays; its dispatch list now holds hotkeys weakly. Volume
watchers and pings were previously collected outright mid-activity.

Each gets GC tests in both directions, and JSTestHarness gains a shared
collectGarbage().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hs.serial ports while open, hs.sharing shares in flight, and shown hs.canvas
canvases and hs.ui windows, alerts and dialogs are now active in their
module's HSObjectTracker. That replaces selfRetain and the strong registries,
which kept those objects alive but let their callbacks be collected, and also
means a hidden canvas or window nobody references is now ordinary garbage.

A connected Stream Deck stays owned by the module, but is now also active
while it has a handler, so the handlers survive the user dropping it.

A web view's callbacks now belong to the window it's added to, via a new
JSCallback.transferOwnership(), since the user holds the window rather than
the inline-built element. Dialog button callbacks become JSCallbacks too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hs.location watchers and hs.spotlight queries while started, hs.sound sounds
while playing, and hs.chooser choosers while shown were collected, and so
stopped, as soon as the user dropped them. They're now active in their
module's HSObjectTracker. A paused sound or hidden chooser nobody
references can never be resumed or shown again, so it stays collectable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es alive

hs.bonjour searches (and found services while resolving or monitored),
hs.httpserver servers, hs.http WebSockets, visible hs.menubar items and
hs.midi devices with a callback were collected, and so stopped or removed, as
soon as the user dropped them; a visible status item would vanish from the
menu bar. They're now active in their module's HSObjectTracker. A hidden
menubar item nobody references can never be shown again, so it stays
collectable.

hs.midi's selfRef (a manual retain while input was connected) is replaced by
the same mechanism, and bonjour's advertised-service callbacks are now owned
by the module, since their private owner had no JS wrapper and was
collectable at the first GC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Now that ModuleRoot pins module wrappers, the module-level watcher callbacks
that were stored as strong JSFunctions (hs.camera, hs.keycodes, hs.usb,
hs.pasteboard, hs.locale, hs.streamdeck, hs.serial, hs.power, hs.audiodevice,
hs.screen) use JSCallback(owner: self) like everything else.

HSCamera and HSAudioDevice drop selfRetain: a watching camera or device is
now active in its module's tracker instead. Audio device listener blocks no
longer capture the JS listener strongly; they call a JSCallback owned by the
device. With these, scripts/check-object-lifetimes.js has nothing left
awaiting migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hs.application and hs.ax watcher records, hs.wifi and hs.userdefaults
per-event callbacks, and hs.notify's pre-send callback stored raw
JSFunctions. They're now JSCallbacks with a JS-exported owner (the module, or
the notification).

hs.ui element onClick/onHover closures captured their callback strongly. Now
that a hidden window nobody references is collectable, a callback closing
over its own window made a Swift/JS cycle the GC couldn't break, so the
window leaked until reload. The callbacks are now owned by the window.

Modal one-shots (hs.ui text prompt and file picker) keep their strong
callback until invoked, which is called within show().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The getting-started guide told users to keep a reference to every hotkey,
timer and watcher or it would silently stop. That's no longer true, so the
lifecycle section now describes the new rule: running things keep running
until they finish or are stopped, keep a reference to control them later,
inactive objects are ordinary garbage, and hs.activeObjects() lists what's
running. The other guides' links and advice are updated to match.

The HSModule skill and the lifetime lint no longer describe a migration in
progress.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[High impact] The PR appears safe to merge, with a non-blocking fix recommended for invalid callback replacements.

Findings

  1. P2 Bad replacements disable working handlers ▶
Fix with agent prompt
### Issue 1
Hammerspoon 2/Modules/hs.ui/Core/HSUIWindow.swift:909-910
Replacing `onClick` or `onHover` with a non-function now disables the working callback. `elementCallback` detaches the old callback before checking the replacement. When `JSCallback` rejects the new value, the element still holds its old closure, but that closure can no longer call anything.

Create the new callback successfully before detaching the old one.

```suggestion
        guard let cb = JSCallback(value: callback, owner: self) else { return nil }
        elementCallbacks.removeValue(forKey: key)?.detach(from: self)
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR keeps active objects and their callbacks alive through garbage collection. Inactive objects can still be collected, and hs.activeObjects() lists running work.

  • Running objects keep working until they finish or you stop them.
  • Shown windows and menu items stay alive until hidden.
  • Lifecycle guides and checks now teach the active-object rule.

Reviews (2) · Last reviewed commit: "Stop monitoring a search's services when..." · Reviewed by Greptile

Comment thread Hammerspoon 2/Modules/hs.sound/HSSound.swift Outdated
Comment thread Hammerspoon 2/Modules/hs.ui/Core/HSUIWindow.swift Outdated
Comment thread Hammerspoon 2/Modules/hs.bonjour/HSBonjourModule.swift
The completion handler deferred clearing the sound's activity until after the
callback returned, so a play() from inside the callback had its new pin
overwritten. An unreferenced sound restarted that way could be collected
mid-playback, losing its next callback. Clear the activity before calling
back, holding the wrapper and callback alive across the call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

❌ Test Results: Failed

Total Passed Failed Skipped Expected Failures Duration
2370 2197 1 170 2 11m 59s

Ran on: Virtual Machine, macOS 27.0.1 (26A434)

Failures

Hammerspoon 2Tests › HSTaskIntegrationTests/testTimeoutPattern()

Issue recorded: Test ended with 1 object(s) still active: <HSTask: /bin/sleep>. Wait for them to finish or stop them, or set allowsActiveObjectsAtTeardown if that's deliberate.

From this run of 62c6414.

cmsj and others added 3 commits October 13, 2026 22:59
The window appended every onClick()/onHover() callback to a list, so calling
one again on the same element kept the old closure, and everything it
captured, alive until the window was destroyed. Track them by element and
event, detaching the previous callback on replacement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A service the user is resolving or monitoring is pinned by the module's service
tracker, but shutdown only dropped those pins. A service whose search had
already stopped was out of reach of the search's cleanup, so it kept
monitoring and calling back into the old context until it was collected.
Destroy every tracked service before dropping the pins, as with searches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A destroyed search cleared its services' callbacks and stopped resolving, but
left TXT record monitoring running. Use the service's destroy(), which stops
both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cmsj

cmsj commented Oct 13, 2026

Copy link
Copy Markdown
Owner Author

@greptile

Comment on lines +909 to +910
elementCallbacks.removeValue(forKey: key)?.detach(from: self)
guard let cb = JSCallback(value: callback, owner: self) else { return nil }

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.

P2 Bad replacements disable working handlers

Replacing onClick or onHover with a non-function now disables the working callback. elementCallback detaches the old callback before checking the replacement. When JSCallback rejects the new value, the element still holds its old closure, but that closure can no longer call anything.

Create the new callback successfully before detaching the old one.

Suggested change
elementCallbacks.removeValue(forKey: key)?.detach(from: self)
guard let cb = JSCallback(value: callback, owner: self) else { return nil }
guard let cb = JSCallback(value: callback, owner: self) else { return nil }
elementCallbacks.removeValue(forKey: key)?.detach(from: self)
Prompt To Fix With AI
This is a comment left during a code review.
Path: Hammerspoon 2/Modules/hs.ui/Core/HSUIWindow.swift
Line: 909-910

Comment:
**Bad replacements disable working handlers**

Replacing `onClick` or `onHover` with a non-function now disables the working callback. `elementCallback` detaches the old callback before checking the replacement. When `JSCallback` rejects the new value, the element still holds its old closure, but that closure can no longer call anything.

Create the new callback successfully before detaching the old one.

```suggestion
        guard let cb = JSCallback(value: callback, owner: self) else { return nil }
        elementCallbacks.removeValue(forKey: key)?.detach(from: self)
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

This branch has not been deployed

No deployments
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.

hs.task: a garbage collection kills running tasks, and hs.task.shell() can never settle

1 participant