Skip to content

64 bit rewrite of natlink in Python.#226

Open
LexiconCode wants to merge 62 commits into
dictation-toolbox:devfrom
LexiconCode:marshall_64bit
Open

64 bit rewrite of natlink in Python.#226
LexiconCode wants to merge 62 commits into
dictation-toolbox:devfrom
LexiconCode:marshall_64bit

Conversation

@LexiconCode

@LexiconCode LexiconCode commented Apr 7, 2026

Copy link
Copy Markdown
Member

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?

  • Out of process only
  • Doesn't it require registry edits, edits to Dragons ini or admin privileges
  • Uses a standard Python environment and does not rely on an embedded Python interpreter
  • Natlink manages its own life cycle detecting dragon, initializing com, loaders and tear down
  • packaged within a standard python package
  • Works with Python 64 bit and can work with a wide variety of Python versions 3.10 +
natlink_architecture

Key Design Choices

  • Out of process STA apartment (coinit_flags=2) serializes COM calls on the main thread
  • PostMessage deferral prevents deadlock between Dragon's RPC and Python callbacks
  • Vendored comtypes wrappers (_gen_v15_v16.py) eliminate runtime codegen fragility
  • Per-process marshal registration (no HKCU registry writes needed)
  • IServiceProvider kept alive for lazy dictation QueryService
  • Dual-release strategy (comtypes wrappers + raw pointers + GC) ensures Dragon sees every Release in the right order
    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.

natlink_proposed_dispatch

*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:

  • In the following zip file whl file which can be installed in Python 10+ 64 bit environment.
    natlink_3.zip
  • UV is not required but the scripts in the repo leverage uv. all commands could work in a python standard environment or virtual environment.
   install uv: powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
   uv venv --python 3.14
   uv pip install natlink-6.0.0-py3-none-any.whl
   uv run natlink-ui --install-shortcuts

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.

uv run pytest tests -m minimal -v  
uv run natlink-ui --uninstall

@LexiconCode

LexiconCode commented Apr 16, 2026

Copy link
Copy Markdown
Member Author

I've been asked to merge on Telegram. The following needs to be completed post which may contain breaking changes:

  • Testing with DNS14 and15
  • Complete send keys implementation
  • Formalize loaders to work as modules or as entry points
  • Documentation cleanup
  • Simplify Architecture see Post deferral
    For bug reports please include Dragon and Natlink logs.

@LexiconCode LexiconCode changed the base branch from master to dev April 17, 2026 18:48
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.
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.
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.
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.
@kendonB

kendonB commented May 7, 2026

Copy link
Copy Markdown

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.

@kendonB

kendonB commented May 7, 2026

Copy link
Copy Markdown

Another GPT 5.5 review finding:

"""
scripts/reset_dragon_logs.ps1 looks like a useful developer debugging helper, but I think it needs guardrails before landing.

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:

  • remove self-elevation; if admin is required, print the command for the maintainer to rerun
  • print the exact log paths before deletion
  • require explicit confirmation before deleting
  • consider archiving/renaming logs instead of deleting them outright
  • document that this is a local troubleshooting tool, not part of normal install/use

That keeps the debugging workflow available without normalising automatic elevation and log removal in the repo.
"""

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

(DNS/DPI) API WIP Work in progress (Do not Merge)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants