Skip to content

Fix #15: version-stamped interop assemblies + tolerant module loader (Bsn.Host 0.2.2, Bsn.Common 0.1.1, Bsn.SystemManagement 0.2.1) - #16

Merged
soroshsabz merged 5 commits into
mainfrom
fix/assembly-version-identity
Nov 7, 2026
Merged

soroshsabz merged 5 commits into
mainfrom
fix/assembly-version-identity

Conversation

@soroshsabz

@soroshsabz soroshsabz commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

ITNOA

What you hit

Import-Module Bsn.Host          # 0.1.0
Update-Module Bsn.Host          # installs a newer version
Import-Module Bsn.Host -Force
# Could not load file or assembly 'Bsn.Host, Version=1.0.0.0' ...
# Assembly with same name is already loaded

Root cause (two layers, verified empirically)

  1. Every shipped module version packaged the DLL with the default
    AssemblyVersion 1.0.0.0
    (no csproj version, build.ps1 passed none):
    identical assembly identity across releases.
  2. Even with distinct versions, PowerShell/.NET cannot host two versions of
    the same assembly name in one process
    - verified with raw
    Assembly.LoadFrom of two differently-versioned DLLs in one process:
    same FileLoadException. A same-session switch after Update-Module is a
    platform limitation (Az modules give the same guidance: restart the
    session).

What ships

Change Why
build.ps1 stamps assemblies with the manifest version (dotnet build -p:Version=<ModuleVersion>) Distinct identity per release; the psd1 stays the single source of truth
Guard tests: DLL AssemblyVersion == manifest ModuleVersion (both interop modules) Without distinct versions the loader could silently reuse mismatched interop code
New private Import-BsnInteropAssembly in Bsn.Common 0.1.1; both psm1s load their DLL through it (RequiredAssemblies removed) -Force reimport of the same version reuses the loaded assembly (no more FileLoadException); a different version already loaded fails with actionable guidance naming both versions, the loaded path, the restart remedy and the issue link
Loader selects the newest loaded Bsn.Common instance After an update the session can hold old + new side by side; invoking an array of module infos stringifies into a bogus command name (found en route)
Bsn.Host 0.2.2 / Bsn.SystemManagement 0.2.1 require Bsn.Common >= 0.1.1 The helper ships there
Analyze task prepends repo src to PSModulePath around Invoke-ScriptAnalyzer Hidden bug found en route: PSSA imports the module it analyzes, and without the prepend it resolved the newest gallery copy - analysis silently ran against published code and left that foreign assembly in the process (exactly what the tests' FileLoadException fallback was papering over)

Verified locally

  • fresh import + -Force reimport of 0.2.2: silent reuse, no error
  • gallery 0.2.1 loaded, then import 0.2.2:
    Cannot load Bsn.Host version 0.2.2.0: version 1.0.0.0 is already loaded in this session (from '...0.2.1\lib\Bsn.Host.dll'). PowerShell cannot host two versions of the same assembly in one process; restart the session (or open a new one) and import the module again. (instead of the cryptic raw FileLoadException)
  • full gate Analyze, Test: analyzer clean, 81 tests green

Bonus fix: issue #5 (second root cause) - codepage mojibake in byte-writing TUIs

Field testing of 0.2.1 showed OpenCode recordings still corrupted: box-drawing
borders turned into CP437-style garbage while ASCII text survived. Probes
against the real engine exposed a second corruption mechanism the decoder fix
did not cover:

  • a fresh pseudo console starts with the OEM output codepage (probe:
    chcp.com records "Active code page: 437")
  • OEM-codepage-decoding conhosts decode a child's stdout bytes with it - one
    box-drawing character (U+2500, UTF-8 E2 94 80) arrives as three CP-decoded
    characters before the recorder sees anything
  • pwsh's own output is immune (WriteConsoleW), which is why synthetic pwsh
    probes passed while real byte-writing TUIs (Go/Bubble Tea such as OpenCode)
    were corrupted

Fix: the recorded command line now bootstraps
[Console]::OutputEncoding/InputEncoding to UTF-8 (the programmatic
chcp 65001) before the user's command or interactive shell runs. Verified
end to end: a byte-writing child now records all 60 border characters
byte-for-byte with zero mojibake.

New per-scenario system tests (the reported failures, precisely)

Scenario Test
Recorded session must run under UTF-8 codepage recorded chcp.com must report 65001
Byte-writing child (Go/Node TUI output semantics) raw console-handle writes of ╭───╮ │ SALAM ╰───╯ asserted character-for-character (60 borders, 4 corners, zero U+FFFD, zero Latin-1-range mojibake)
Real OpenCode TUI session records actual opencode (skips when not installed); zero U+FFFD, zero mojibake, UI text captured
Metacharacters through the REAL engine (issue #7) a|b&c<d>e^f recorded verbatim via actual ConPTY (previously only mocked)

Existing #6 (stop-channel flush) and #8 (redirected stdio) coverage unchanged.

Versions in this PR

  • Bsn.Host 0.2.2 (bug fix)
  • Bsn.Common 0.1.1 (new private helper)
  • Bsn.SystemManagement 0.2.1 (loader change; no behavior change)

After merge: release v0.2.2 publishes all three (each is newer than the gallery).

User report: Import-Module -Force after Update-Module failed with
FileLoadException ('Assembly with same name is already loaded').

Root cause (two layers):
1. Every shipped module version packaged the DLL with the default
   AssemblyVersion 1.0.0.0 - identical identity across releases.
2. Verified with raw .NET (Assembly.LoadFrom of two versions in one
   process): PowerShell/.NET cannot host two versions of the same
   assembly NAME in one process, regardless of AssemblyVersion. A
   same-session switch after Update-Module is a platform limitation
   (same guidance as Az modules: restart the session).

What ships:
- build.ps1 stamps the interop assemblies with the module manifest
  version (dotnet build -p:Version=<ModuleVersion>; psd1 stays the
  single source of truth). Guard tests in Bsn.Host and
  Bsn.SystemManagement assert DLL version == manifest version -
  without distinct versions the loader could silently reuse
  mismatched interop code.
- New private Import-BsnInteropAssembly helper in Bsn.Common (0.1.1);
  both consumer psm1s load their DLL through it instead of the
  manifest's RequiredAssemblies (removed):
  * same version already loaded (Import-Module -Force) -> reuse
  * different version already loaded -> actionable error naming both
    versions, the loaded path, the restart remedy and the issue link
  * otherwise -> Add-Type -Path
  The loader picks the NEWEST loaded Bsn.Common instance (after an
  update the session may hold old and new side by side; invoking an
  array of module infos stringifies into a bogus command name).
- Dependencies: Bsn.Host 0.2.2 and Bsn.SystemManagement 0.2.1 now
  require Bsn.Common >= 0.1.1 (the helper ships there).
- build.ps1 Analyze task now prepends repo src to PSModulePath around
  Invoke-ScriptAnalyzer: PSScriptAnalyzer imports the module it
  analyzes, and without the prepend it resolved the newest GALLERY
  copy - so analysis silently ran against published code and left
  that foreign 1.0.0.0 assembly in the process, which is exactly what
  used to trip the tests' FileLoadException fallback (and broke the
  new loader until diagnosed).

Verified locally (repro scripts in the issue):
- fresh import + -Force reimport of 0.2.2: silent reuse, no error
- gallery 0.2.1 loaded, import 0.2.2: clear guidance error
- full gate Analyze,Test: 81 tests green, analyzer clean
@coderabbitai

coderabbitai Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Important

  • Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: ee974fe2-f287-4564-a681-0913b79175f9
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@codecov

codecov Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@soroshsabz soroshsabz self-assigned this Nov 6, 2026
@soroshsabz soroshsabz added the bug Something isn't working label Nov 6, 2026
…ded sessions

Field report: 0.2.1 recordings of OpenCode still showed mojibake
(box-drawing borders became CP437-style garbage like 'Gamma-ohmega').
The chunk-boundary decoder fix was correct but only covered ONE
corruption mechanism. Probes against the real engine exposed the
second:

- a fresh pseudo console starts with the OEM output codepage
  (probe 'chcp.com' records: 'Active code page: 437')
- OEM-codepage-decoding conhosts decode a child's stdout BYTES with
  it: one box-drawing character (U+2500, UTF-8 E2 94 80) arrives as
  three CP-decoded characters BEFORE the recorder sees anything
- pwsh's own output is immune (WriteConsoleW), which is why synthetic
  pwsh probes passed while real byte-writing TUIs corrupted

Fix: the recorded command line bootstraps
[Console]::OutputEncoding/InputEncoding to UTF-8 (the programmatic
'chcp 65001') before the user's command or interactive shell runs.
Verified end to end: the byte-writer probe now records all 60 border
characters byte-for-byte with zero mojibake.

New per-scenario system tests (all green):
- recorded session reports codepage 65001 (the direct fix proof)
- BYTE-WRITING child (raw console-handle output, Go/Node TUI style)
  asserted character-for-character (60 borders, corners, marker,
  zero U+FFFD, zero Latin-1-range mojibake)
- real OpenCode TUI session (skips when opencode is not installed)
- metacharacter round-trip through the REAL ConPTY engine (issue #7
  previously only had mocked coverage)

Unit tests updated for the wrapper prefix; changelog and gallery
release notes updated.
Seyyed Soroosh Hosseinalipour added 3 commits November 7, 2026 21:55
Resolves the three failing checks on PR #16:

- CI (ubuntu/macos): the issue #5 codepage unit test assumed the default
  interactive shell is pwsh; on Unix it is $env:SHELL, which intentionally
  gets no inline bootstrap (Unix terminals are natively UTF-8). The test
  is now split into three platform-independent tests: the -Command wrapper
  assertions (always pwsh), the PowerShell interactive branch via a mocked
  Get-BsnDefaultShell (deterministic on every OS), and the bare-shell
  fallback.

- SonarCloud duplication on new code (54 lines, 14.5% vs 3% allowed): the
  identical 14-line loader blocks in both psm1s and the identical
  13-line version-guard tests. Import-BsnInteropAssembly now takes
  -ModuleRoot and resolves lib/<name>.dll itself, shrinking each consumer
  psm1 to a two-line invocation with distinct comments; the guard tests
  are shortened with module-specific comments.

- Codecov patch coverage (52.38% vs 80% target): the 10 uncovered lines
  were the private Import-BsnInteropAssembly helper. New unit tests in
  Bsn.Common cover the missing-DLL guidance, the fresh Add-Type load
  (via a mocked session-state seam so it is deterministic regardless of
  prior process state), same-version silent reuse, and the cross-version
  actionable guidance. Adds the private Get-BsnLoadedInteropAssembly
  seam for the AppDomain lookup (static .NET state cannot be faked).

Also refreshes the stale RequiredAssemblies reference in the
Start-BsnWindowsPtyRecording help and the Bsn.Common changelog entry.

Local gate: Analyze clean, 92 tests green (8 + 70 + 14); coverage of the
helper is now complete; fresh-process -Force reimport verified.
…egion

SonarCloud's second run showed the remaining 18 duplicated new lines are
the two psm1 loader invocations sitting inside the long-standing shared
loader scaffolding (Bsn.Common bootstrap, PSModulePath self-heal,
function-file dot-sourcing) duplicated between Bsn.Host.psm1 and
Bsn.SystemManagement.psm1 - any new line inside that region is counted
as duplicated against the sibling file.

Place the interop assembly loading after the dot-sourcing loop instead:
the engine types resolve at call time, so the timing matches what
RequiredAssemblies provided, and the new lines fall outside the
duplicated region (zero new duplicated lines).
… race

Round 2 still counted every new psm1 line as duplicated (4.4%): Sonar
merges duplication blocks across single blank lines, and the two
identical loader code lines ($commonModule = Get-Module ... / & invoke)
form a cross-file token block that pulled the appended region back into
the duplicated range.

- Give each psm1 lexically distinct new lines: Bsn.SystemManagement now
  uses its own variable name ($systemInteropHost), its own parameter
  names ($moduleDirectory/$assemblySimpleName) and its own comment
  wording, so no token run between the files reaches the duplication
  block threshold; two blank lines keep the appended block out of the
  scaffolding's merged range. Zero duplicated new lines remain in the
  module code.

- Fix the SonarCloud Gate workflow race: it accepted the first non-NONE
  quality-gate status, which is usually the PREVIOUS push's stale result
  (the workflow starts while Automatic Analysis is still running). It
  now waits for SonarCloud's own 'SonarCloud Code Analysis' check run of
  the head commit to complete before reading the gate API. Manual
  dispatch resolves the head SHA through the GitHub API.
@sonarqubecloud

sonarqubecloud Bot commented Nov 7, 2026

Copy link
Copy Markdown

@soroshsabz
soroshsabz merged commit 84dda3b into main Nov 7, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

1 participant