Repository navigation
Conversation
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>
|
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>
❌ Test Results: Failed
Ran on: Virtual Machine, macOS 27.0.1 (26A434) FailuresHammerspoon 2Tests › HSTaskIntegrationTests/testTimeoutPattern() |
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>
| elementCallbacks.removeValue(forKey: key)?.detach(from: self) | ||
| guard let cb = JSCallback(value: callback, owner: self) else { return nil } |
There was a problem hiding this 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.
| 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.
Fixes #267.
JavaScript garbage collection must be unobservable, but collecting an object the user had dropped could stop real work: an unreferenced running
hs.taskwas killed, sohs.task.shell()promises could never settle; an unstoredhs.hotkey.bind()stayed registered but silently stopped firing; a visiblehs.menubaritem 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:
JSCallback.ModuleRootnow pins each module's JS wrapper until shutdown, soJSCallback(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 losths.foo.jsproperties. Every raw storedJSFunction/JSValuecallback is converted, except modal one-shots (text prompt, file picker), which hold theirs untilshow()invokes it.HSObjectTracker<T>(renamed fromHSWeakObjectSet). It tracks children weakly, andsetActive(_:_:in:)pins an active child's JS wrapper (not just the Swift object, or itsJSCallbacks would still be collected).setActiveis called only from thedidSetof the state that defines activity, so every stop, finish or failure path un-pins it. Pins are stored centrally, andremoveAllObjects()inshutdown()drops them all, so a missed un-pin lasts until the next reload at most and can't keep an oldJSContextalive.selfRetain,Unmanagedself-retains and the strong child registries are gone.Behaviour changes
hs.uiwindow, canvas, chooser or menubar item you don't keep a reference to. Previously these were held untildestroy().hs.activeObjects()lists everything currently running.Proposed release note:
Bugs found along the way
hs.ui.webviewcallbacks 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 newJSCallback.transferOwnership(from:to:).onClick/onHoverclosures 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-ownedJSCallbacks.Thread.sleepin synchronous tests, which can never see a task's termination arrive.Enforcement
JSTestHarnessfails any test that ends with an object still active that should have finished on its own (tasks, one-shot timers, pings, sounds:finishesOnItsOwninsetActive).scripts/check-object-lifetimes.js, run bynpm run docs:test, rejects newselfRetain = selfand strongHS*collections. The 5 remaining collections are listed with their justification.Testing
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 andnpm run docs:generateoutput are updated.🤖 Generated with Claude Code