Author SHA1 Message Date
jaap-jan 93e35a0095 Stop the SDK's own trimmer version deciding whether CI can restore
ci / build and test (pull_request) Successful in 2m24s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 3m22s
ci / api image (pull_request) Successful in 21s
CI went red across the whole repository — main's run 125 and every open pull
request at once — on a restore that never reached a compiler:

    error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has
    changed from [10.0.10, ) to [10.0.11, ). The packages lock file is
    inconsistent with the project dependencies so restore can't be run in
    locked mode.

Nothing in any of those commits touched a package. .NET had shipped SDK 10.0.400.

◆ THE VERSION IN THE LOCK FILES WAS NEVER THIS REPOSITORY'S TO DECIDE.

Microsoft.NET.ILLink.Tasks is referenced by nothing here. The SDK adds it to any
project setting IsTrimmable or IsAotCompatible — DodoSSH.Contracts and
DodoSSH.Crypto do, and the Android head gets it from trimming being on by
default — and it supplies the version itself, from the KnownILLinkPack item in
its own Microsoft.NETCoreSdk.BundledVersions.props. 10.0.302 says 10.0.10;
10.0.400 says 10.0.11.

packages.lock.json records that as a Direct reference with a requested range, so
what the committed file actually means is "whichever SDK last ran a restore".
global.json says rollForward: latestMinor, so setup-dotnet installs the newest
10.x SDK that exists on the morning it runs. The gate did its job — an unreviewed
dependency change is exactly what it is there to stop — but the change it caught
was not one anybody could have reviewed, and it will recur on every servicing
release.

Regenerating the lock files alone would have been the worse repair, and not only
because it holds until the next release. It cannot be done from this machine at
all: every SDK installed here tops out at 10.0.302, which writes 10.0.10 straight
back and re-breaks CI. The recorded version would flip according to who restored
last — the precise state locking exists to prevent.

So the version is pinned in Directory.Build.targets and the three lock files are
regenerated against the pin. It is an Update on the SDK's item rather than a
PackageVersion in Directory.Packages.props because the reference is implicit:
the SDK supplies a version, so central package management is never consulted. It
sits in a target because the conditioning is on %(TargetFramework) — all the
KnownILLinkPack items share one identity and only that metadata separates
net10.0's from net8.0's — and item batching in a condition is legal inside a
target and MSB4191 during evaluation.

Pinned forward to 10.0.11 rather than back to 10.0.10, which would have been a
one-line change with no lock file churn. Holding the trimmer a release behind the
framework it analyses to dodge an error is how a missed trim warning happens, and
taking the newer one makes the bump a reviewed diff, which is what the gate was
asking for.

Verified against the SDK that broke it rather than only the one here:

  - sdk:10.0-alpine, 10.0.400, `dotnet restore DodoSSH.slnx --locked-mode` —
    exit 0. That is ci.yml's line, on CI's SDK.
  - the android workload on sdk:10.0-noble, 10.0.400, locked-mode restore of
    DodoSSH.Client.Android — exit 0. That is scripts/ci-android.sh's line.
  - locally on 10.0.302, the same locked-mode restore of the solution — exit 0.

One set of lock files satisfying both SDKs is the whole point of the pin, and the
third check is the one that demonstrates it.

Release build clean: 0 errors, and 0 IL-prefixed diagnostics from the newer
analyser on the two trimmable projects. 1,869 tests over 19 suites, none failing.

A caution for the next person, learned the hard way here: `--force-evaluate` on
Windows rewrites every lock file it touches with CRLF, and 23 of the 26 had no
content change at all. Only the three that really moved are in this commit.
2026-08-12 11:44:56 +02:00
5 changed files with 91 additions and 86 deletions
+68
View File
@@ -0,0 +1,68 @@
<Project>
<!--
◆ THE TRIMMER'S VERSION IS PINNED HERE BECAUSE OTHERWISE THE LOCK FILES ARE NOT LOCKED.
Microsoft.NET.ILLink.Tasks is not referenced by anything in this repository. The SDK adds it
on its own to any project that sets IsTrimmable or IsAotCompatible — DodoSSH.Contracts and
DodoSSH.Crypto do, and the Android head gets it from trimming being on by default there — and
the version it asks for is whatever the running SDK happens to bundle. That version lives in
the SDK's own Microsoft.NETCoreSdk.BundledVersions.props, as a KnownILLinkPack item.
Which makes it a dependency whose version is a property of the toolchain rather than of this
repository, and that is the whole problem: packages.lock.json records it as a Direct reference
with a requested range, so the lock file silently means "whichever SDK last ran a restore".
global.json says rollForward: latestMinor, so CI's setup-dotnet installs the newest 10.x SDK
that exists on the day it runs. The moment .NET ships a servicing release, CI's SDK asks for a
version the committed lock files do not have, and the locked-mode restore in ci.yml fails with
NU1004 before a single file is compiled.
That is not hypothetical. It closed the whole pipeline: main's run 125 and every open pull
request went red together, on
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
from [10.0.10, ) to [10.0.11, ).
with nothing in any of those commits touching a package. .NET had shipped SDK 10.0.400, which
bundles ILLink 10.0.11 where 10.0.302 bundled 10.0.10, and setup-dotnet installed it the next
time anything ran.
Worse than the outage is the shape of the repair without this pin. Regenerating the lock files
holds only until the next servicing release, and it cannot be done from a machine whose newest
SDK is older than the runner's: a restore on 10.0.302 writes 10.0.10 straight back and re-breaks
CI, so the recorded version becomes a fact about whoever ran restore last rather than about this
repository. That is exactly the state locking exists to prevent, and it is not a hypothetical
either — every SDK installed on the machine this pin was written on tops out at 10.0.302.
Pinning it makes the recorded version a decision this repository made, reviewable in a diff
like every other version in Directory.Packages.props, and identical on every machine whatever
SDK it has. Moving it is then a deliberate edit here plus a regenerated lock file, which is the
same ceremony any other dependency bump gets.
It is an Update on the SDK's item rather than a PackageVersion in Directory.Packages.props, and
it has to be: the reference is implicit, so the SDK supplies the version itself and central
package management never gets asked. ProcessFrameworkReferences reads @(KnownILLinkPack) when
it runs, which is why this lives in Directory.Build.targets — the item does not exist yet while
Directory.Build.props is being evaluated.
Keep this within a patch or two of the runtime the SDK ships. It is the trimming analyzer and
the ILLink task, so a small skew is harmless, but a version far behind the framework being
analysed is a real way to miss a trim warning.
-->
<Target Name="PinTheILLinkPackVersion" BeforeTargets="ProcessFrameworkReferences">
<!--
Inside a target, and not for tidiness. The SDK ships one KnownILLinkPack per target framework
and they all share the identity "Microsoft.NET.ILLink.Tasks", so the TargetFramework metadata
is the only thing telling net10.0's entry from net8.0's. A condition on %(...) is item
batching, which MSBuild permits in a target and rejects during evaluation with MSB4191 — so
an ItemGroup at the top of this file cannot express "only the net10.0 one" at all, and the
unconditioned Update it would have to become rewrites every framework's entry.
-->
<ItemGroup>
<KnownILLinkPack Update="Microsoft.NET.ILLink.Tasks"
Condition="'%(TargetFramework)' == 'net10.0'"
ILLinkPackVersion="10.0.11" />
</ItemGroup>
</Target>
</Project>
@@ -74,9 +74,9 @@
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
"requested": "[10.0.11, )",
"resolved": "10.0.11",
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
},
"MinVer": {
"type": "Direct",
+14 -77
View File
@@ -84,57 +84,14 @@ const RELEASE_FOCUS_MESSAGE = 'dodossh.release-focus';
const root = document.getElementById('root');
const statusBanner = document.getElementById('status');
/** @type {Map<number, {term: object, fit: object, pane: HTMLElement, notice: string}>} */
/** @type {Map<number, {term: object, fit: object, pane: HTMLElement}>} */
const sessions = new Map();
/** @type {WebSocket | null} */
let socket = null;
/** Whose pane is showing, or null before there is one — see activate(). */
let activeSessionId = null;
/*
── THE BANNER BELONGS TO ONE PANE AT A TIME ─────────────────────────────────────────────────────────
There is one #status element for the whole page, because there is one page for every terminal: the
panes are stacked in the same box and all but the active one are hidden. What goes in it comes from
two sources that are not the same size, and the difference is the whole of this.
The socket's troubles are the page's. There is a single socket behind every pane, so "the view is
reconnecting" is true of whatever is on screen and true of the panes behind it.
A session's last words are not. "The remote closed the session." is a fact about one terminal and says
nothing whatever about the others — so it is held on the session and drawn only while that session's
pane is the one showing. Written straight into the shared element, which is what this used to do, it
outlived the tab it described: switching to a live terminal left the dead one's epitaph sitting under
it, and opening or closing any other tab wiped the message whether or not it belonged to that tab.
The socket's half wins when both have something to say: a page whose socket is down is not showing
live output on any pane, which makes what became of one session the less urgent of the two.
*/
let transportStatus = statusBanner.textContent ?? '';
function renderStatus() {
const notice = activeSessionId === null ? '' : sessions.get(activeSessionId)?.notice ?? '';
statusBanner.textContent = transportStatus || notice;
}
/** Says something about the socket, which every pane shares. */
function setTransportStatus(text) {
transportStatus = text ?? '';
renderStatus();
}
/** Records what became of one session, to be drawn only while that session's pane is showing. */
function setSessionNotice(sessionId, text) {
const session = sessions.get(sessionId);
if (!session) {
return;
}
session.notice = text ?? '';
renderStatus();
function setStatus(text) {
statusBanner.textContent = text ?? '';
}
/** Builds a frame: opcode, big-endian session id, then payload. */
@@ -335,7 +292,7 @@ function createSession(sessionId) {
term.onResize(() => sendResize(sessionId, term, pane));
const session = { term, fit, pane, notice: '' };
const session = { term, fit, pane };
sessions.set(sessionId, session);
activate(sessionId);
@@ -349,11 +306,6 @@ function activate(sessionId) {
session.pane.dataset.active = String(id === sessionId);
}
// The banner follows the pane. Whatever this session has to say for itself replaces whatever the
// session that was showing had to say for its own, which is the point of holding it per session.
activeSessionId = sessionId;
renderStatus();
const active = sessions.get(sessionId);
if (active) {
active.term.focus();
@@ -421,10 +373,7 @@ function handleFrame(buffer) {
session.term.write(REPLAY_BANNER);
}
// This session's own line, and only this one's: a session that is open has nothing to say about
// how it ended. The page's own "Connecting…" is cleared by the socket opening, which happens
// before any frame can arrive.
setSessionNotice(sessionId, '');
setStatus('');
break;
}
@@ -477,14 +426,7 @@ function handleFrame(buffer) {
session.pane.remove();
sessions.delete(sessionId);
// The notice went with the session record it was held on, but the page can still be pointing at
// the pane that is now gone. Cleared rather than left dangling, so the banner stops describing a
// closed tab while the host decides which pane to show next.
if (activeSessionId === sessionId) {
activeSessionId = null;
}
renderStatus();
setStatus('');
break;
}
@@ -561,19 +503,14 @@ function handleFrame(buffer) {
const session = sessions.get(sessionId);
const reason = new TextDecoder().decode(payload);
if (!session) {
// No pane, so there is nothing this page can honestly hang the reason on. It used to go into
// the banner anyway, which printed one session's ending underneath whichever pane happened to
// be showing at the time.
break;
if (session) {
// The pane and its scrollback stay. The user was probably reading the last thing the
// remote said, and that is usually why the session ended.
session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`);
session.term.options.cursorBlink = false;
}
// The pane and its scrollback stay. The user was probably reading the last thing the
// remote said, and that is usually why the session ended.
session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`);
session.term.options.cursorBlink = false;
setSessionNotice(sessionId, reason);
setStatus(reason);
break;
}
@@ -598,7 +535,7 @@ function scheduleReconnect() {
return;
}
setTransportStatus('Reconnecting the terminal view…');
setStatus('Reconnecting the terminal view…');
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
@@ -618,7 +555,7 @@ function connect() {
socket.binaryType = 'arraybuffer';
socket.addEventListener('open', () => {
setTransportStatus('');
setStatus('');
// Back to the quick attempt for whatever the next failure turns out to be. Kept slow between
// attempts within one outage, reset once the outage is actually over.
+3 -3
View File
@@ -22,9 +22,9 @@
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
"requested": "[10.0.11, )",
"resolved": "10.0.11",
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
},
"MinVer": {
"type": "Direct",
+3 -3
View File
@@ -16,9 +16,9 @@
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
"requested": "[10.0.11, )",
"resolved": "10.0.11",
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
},
"MinVer": {
"type": "Direct",