64 bit rewrite of natlink in Python.#226
Conversation
a249e4e to
3203c10
Compare
|
I've been asked to merge on Telegram. The following needs to be completed post which may contain breaking changes:
|
Delete NatlinkSource (C++ COM DLL), InstallerSource (Inno Setup), create_natlink (COM registration tool), pythonsrc (old Python package), documentation (old Sphinx docs), and obsolete build/config files.
Replace runtime comtypes.client.GetModule() + per-user cache directory with pre-generated vendored wrappers (_gen_v13_v14.py, _gen_v15_v16.py). This eliminates the fragile LOCALAPPDATA/natlink/comtypes_gen cache, MD5 hash invalidation, and runtime TLB parsing. - Add vendored comtypes wrappers generated from Dragon type libraries - Simplify _tlb.py to import vendored modules with auto-detection - Move detect_dragon_major() from _connection.py to _config.py to avoid circular imports between _tlb.py and _connection.py - Add unified build.ps1 script (DLLs + TLBs + vendored wrappers) - Add gen_tlb_wrappers.py for regenerating vendored wrappers from TLBs - Remove build_dll.ps1 (superseded by build.ps1) - Remove .tlb from shipped package data (no longer needed at runtime) - Add .gitattributes to collapse generated files in PR diffs - Document interface change workflow in docs/contributing.md
displayText was writing to sys.__stdout__ which goes nowhere in the headless launcher. Now routes through notify_text so loader output (via natlinkcore's stdout redirect) reaches all UI providers.
- Display version only for discovered loaders, not hardcoded natlinkcore - Log all discovered loaders with enabled/disabled status at DEBUG - Warn when no loaders are discovered or all are disabled - Silence comtypes DEBUG spam in logging setup
Three independent loaders each load a grammar (alpha, bravo, charlie), verifying the loader framework supports multiple active loaders and grammars concurrently.
The tray menu dispatches callbacks on a worker thread, but loader start/stop/reload call COM (GrammarLoad, GrammarUnload) which must happen on the STA main thread. Add push_to_com() to the hidden window — posts WM_DEFERRED_CALL so the callable runs during DispatchMessage on the thread that owns COM. toggle_loader and reload_grammars in _actions.py now defer their COM work via push_to_com while keeping INI updates immediate. The handler is registered in create() so it survives the disconnect/reconnect cycle where unregister_all_handlers clears the handler dict.
NatlinkState.loaders (module name strings) → loader_states with (name, enabled, running) tuples. Loader state is computed once by _compute_loader_states() and cached in _state.last_loader_states. _on_loaders_changed() in _loaders.py fires automatically when start_and_register, add_loader, remove_loader, reload_loader, or register_running_loader modify the loader list — no manual invalidation needed in _actions.py. build_state_snapshot() reads the cache under lock, same pattern as mic/user state. Removes the dual-cache (_loader_states_cache + _state.last_loader_states) and the threading.Lock that guarded it.
455c860 to
314f943
Compare
Replace _state.loaders (List[object]) + _loader_module_names (dict) with _state.loader_registry (List[_LoaderEntry]). Each entry holds the loader object, module name, and human-readable name in one place. add_loader now delegates to start_loader when connected, eliminating duplicated registration logic. The old start_loader (just calls start(), no registration) is now _start_impl. start_loader accepts _notify=False so callers that batch multiple loaders (startup in _activate, slow-path reload) fire _on_loaders_changed once after the batch instead of per-loader.
- Declare missing categories: natlink.ui, natlink.com.{dragon,sendinput,
pump,timer,tlb}, natlink.com.sink.{engine.attrib_changed,action,grammar,
dict}, natlink.callbacks.{begin,change,phrase_finish,dict_begin}, and
natlink.compat.{launcher,loaders}. Drop unused natlink.compat.tray.
- Pin high-frequency children to file-INFO so normal operation stays
legible; bump to DEBUG via [Logging.Levels] to investigate.
- Move loggers to their correct layer: _pump/_hidden_wnd to com.pump,
_action_sink/_grammar_sink from callbacks to com.sink.*, _orchestrator
to compat.launcher, _loaders to compat.loaders.
- _callback_trace uses log.getChild(name) so per-callback-type children
(timer, begin, change, etc.) can be tuned independently.
- _grammar_sink adds dedicated hypothesis child for the per-partial
PhraseHypothesis callback.
- _tlb.py: log.info on entry and on successful TLB load under natlink.com.tlb. Type library loading was previously silent. - _marshaling.py: log.info on entry and on DLL load; promote the "Registered N/M marshal interfaces" line to INFO under natlink.com.marshal. Marshal registration was previously silent. - _gram_obj.py: log.debug after successful Activate/Deactivate so rule-state transitions are observable when debugging recognition. - _dict_sink.py: DictSink ErrorHappened escalated from WARNING to ERROR — these are real Dragon errors, not warnings.
…line pump dispatch
Replace the parallel '_state.last_loader_states' field and '_compute_loader_states / _refresh_loader_states' helpers with a single module-level cache in _actions.get_loader_states() that returns (name, enabled, running) tuples and is invalidated via invalidate_loader_cache(). build_state_snapshot now pulls loader state from the unified cache; toggle_loader and _on_loaders_changed invalidate the cache and notify the UI. Narrows exception handling in get_loader_states and _dispatch_or_run to ImportError so real failures surface.
- Switch grammar/dict registries to WeakValueDictionary so they track without owning lifetime, matching the C++ raw-pointer linked list. - Consolidate the three drains at the natlink_compat layer: grammars -> results -> dicts in _teardown_objects, mirroring CDragonCode::releaseObjects ordering. Move the ResObj drain call out of NatlinkCOM.disconnect up to _lifecycle. - Reorder GramObj.unload and DictObj.destroy to Release -> NULL -> registry-pop, matching the C++ unload/destroy sequence. - Rename DictObj._destroy -> DictObj.destroy so the public teardown name matches the C++ CDictationObject::destroy symbol and mirrors the symmetry with GramObj.unload. - Stop swallowing COM errors in unload/destroy; __del__ still catches separately so finalization never propagates out. - Update test_reload_grammar to compare ComGramObj wrapper identity (handle values are raw pointer addresses that can recycle), add offline and live tests that drop the last GramObj reference and assert the registry empties, and rename _destroy call sites.
Fail any online test that leaves grammars or dictation objects in the registry it didn't start with. Pure diagnostic -- does not drain -- so leaking tests are named rather than silently masked.
314f943 to
edf33e6
Compare
…line pump dispatch
Replace the parallel '_state.last_loader_states' field and '_compute_loader_states / _refresh_loader_states' helpers with a single module-level cache in _actions.get_loader_states() that returns (name, enabled, running) tuples and is invalidated via invalidate_loader_cache(). build_state_snapshot now pulls loader state from the unified cache; toggle_loader and _on_loaders_changed invalidate the cache and notify the UI. Narrows exception handling in get_loader_states and _dispatch_or_run to ImportError so real failures surface.
- Switch grammar/dict registries to WeakValueDictionary so they track without owning lifetime, matching the C++ raw-pointer linked list. - Consolidate the three drains at the natlink_compat layer: grammars -> results -> dicts in _teardown_objects, mirroring CDragonCode::releaseObjects ordering. Move the ResObj drain call out of NatlinkCOM.disconnect up to _lifecycle. - Reorder GramObj.unload and DictObj.destroy to Release -> NULL -> registry-pop, matching the C++ unload/destroy sequence. - Rename DictObj._destroy -> DictObj.destroy so the public teardown name matches the C++ CDictationObject::destroy symbol and mirrors the symmetry with GramObj.unload. - Stop swallowing COM errors in unload/destroy; __del__ still catches separately so finalization never propagates out. - Update test_reload_grammar to compare ComGramObj wrapper identity (handle values are raw pointer addresses that can recycle), add offline and live tests that drop the last GramObj reference and assert the registry empties, and rename _destroy call sites.
Fail any online test that leaves grammars or dictation objects in the registry it didn't start with. Pure diagnostic -- does not drain -- so leaking tests are named rather than silently masked.
|
One security hygiene thing to introduce is to have the DLLs built by the CI. GPT 5.5 xhigh says the process is: GitHub Actions checks out the reviewed source, runs the Windows CMake/MIDL build for the marshal DLLs and TLBs, builds the Python wheel from those CI-produced files, uploads the wheel as the release artifact, and publishes SHA-256 hashes plus artifact provenance/attestation. |
|
Another GPT 5.5 review finding: """ The sensitive parts are that it self-elevates with RunAs, runs with ExecutionPolicy Bypass, stops/restarts DragonLoggerService, and deletes Dragon/Natlink log files. I don’t think that implies bad intent, but it is a surprising amount of privilege and evidence deletion behavior for a checked-in helper script. Could we make this developer-only and less automatic? My preference would be:
That keeps the debugging workflow available without normalising automatic elevation and log removal in the repo. |
… into marshall_64bit
…ion-toolbox#228 loader/process ownership Review fixes (natlink_com / natlink_compat / natlink_ui): - ComResObj now owns/releases the IUnknown it is built from, fixing a per-recognition COM refcount leak at two sites (_grammar_sink, _dict_obj/_res_obj) that defeated the refcount-drain teardown. - input_from_file disables/closes Dragon's file-audio source in finally so a pump timeout no longer wedges future calls. - Dict-sink callbacks lock via ComDictObj._auto_lock (no _lock_count desync); force_release documents its STA-only invariant; FreeLibrary gets argtypes. - _system raises natlink.ValueError (not the builtin) for except-parity. - Loader reload slow path removes the old module's callbacks; setTimerCallback no longer requires a live connection. - UI: WM_NULL menu dismiss, lazy loader/log submenus, command-ID range guard, stream-redirect fallback to original stream, idempotent uninstall_startup. - UI imports go through natlink_compat (re-exported seams) instead of natlink_com; pytest minversion aligned; natlink.loaders entry-point group documented in docs/third_party. Issue dictation-toolbox#228 (single Dragon connection, process ownership): - NatlinkConnectionActive mutex is now a hard limit: natConnect raises ConnectionInUse if another process holds it. - Launcher-active gating: when the launcher owns the process, a loader's natConnect returns a no-op handle and natDisconnect is a no-op; real teardown (_disconnect) stays with the launcher (shutdown/restart/inactive). Standalone processes keep legacy behavior. - New tray "Inactive (release Dragon)" item + Deactivate/Activate launcher events release/reclaim the connection (+ mutex) so another process can take ownership; set_inactive() public API and PHASE_INACTIVE state. Layering / cleanup: - Loader-state cache moved from _actions module globals onto _state, breaking the _state.reset -> _actions cycle. - msgbox yes/no constants (MB_YESNO/IDYES/IDNO/MB_ICONQUESTION) added to the natlink_com._win32 seam and re-exported; UI drops inline literals. - _connection.disconnect drains on sink COM refcount (comtypes _refcnt -> 0, bounded ~0.5s) instead of a fixed sleep. Tests: 469 passed, 198 skipped (Dragon-free suite). Adds TestLauncherActiveGating; updates restart/actions/ui-install tests for the renamed internal _disconnect, the _state-owned loader cache, and re-export.
…ictation-toolbox#228) Global begin/change/timer callbacks were attributed to a loader only by inference on __module__, which fails for lambdas and cross-package helpers. Make ownership explicit: - _state callback lists now hold _CB(fn, owner) records, where owner is the top-level package of the loader that registered the callback (a package name, not the object, since natlinkcore self-registers an instance distinct from the module passed to start()). _CB is callable so dispatch invokes entries uniformly and bare callables (used in tests) still work. - loader_registration(loader) context tags every callback registered within it. Wrapped around loader setup(), start()/run(), and trigger_load() so all loader-code registration paths are attributed. - _remove_callbacks_for prefers the explicit tag (entries tagged for another loader are never removed) and falls back to package inference for untagged entries, preserving legacy behavior. When removing a loader empties the timer list while connected, Dragon's COM timer is now disabled. stop() is intentionally NOT tagged for removal: frameworks own their own cleanup (enforced by test_*_does_not_guess_callback_cleanup), and reset() clears everything on disconnect. Tests: 472 passed. Adds 3 ownership tests (uninferable-lambda removal, cross-loader isolation, COM-timer disable on empty); updates identity assertions to read _CB.fn.
- Route callback dispatch through _entry_fn so list entries can be bare callables or _CB records; drop _CB.__call__ indirection. - Extract _apply_com_timer as the single guarded path to Dragon's COM timer; callers decide the desired state. - Dedup named-event signaling via _signal_named_event in natlink_com. - Replace _noop_cm with contextlib.nullcontext for re-entrant natConnect. - Hoist _disconnect import to module scope in compat launcher.
This rewrites natlink Python 64 bit using C types and cross marshaling to enable cross process communication between Dragon 32 bit and Python 64 bit.
Disclosure: Human input was used with AI assist to create this pr.
How does this differ compared to v5.x Nalink?
Key Design Choices
regardless of comtypes internals
All com calls need to be made within the main thread in order to manage events between Dragon and Natlink. If these events are out of order two things can occur Dragon can freeze or actions can be processed out of order.
Most of this complexity comes from Callbacks that are registered with Dragon through Natlink. A callback is distinguished by requiring a message back from Dragon to Natlink. Dragon and Natlink (including Legacy implementation) does not distinguish what framework or grammar registers callbacks or timers. Effectively global registration. In special cases Natlink can defer a callback. Playback callback is a good example. There can be an edge case where Dragon can be trying to process an utterance (even background noise from the microphone) and trying to execute a playback. So we guard against this with a deferral. Below is a diagram giving a detailed overview of the deferral system in combination the event life cycle.
*if Dragon reports playback failed natlink will retry.
The Natlink UI is abstracted so that it has limited interaction with the main thread and natlink state. More details on that can be found in the documentation
Install instructions:
natlink_3.zip
For now I'd ask you to test on dns v13 and v16. Please let me know if if v14 or v15 work as expected. Test to catch issues across versions.