From 79d7165f919edcbdf2ff69c57261c7105d8cd2b6 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 3 Sep 2026 10:01:41 +0800 Subject: [PATCH 1/4] ci: add a Windows lane for the unit/typecheck/build suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pure-logic code carries real win32 branches (shell parsing, WSL path projection, the PowerShell installer) that mock-injection tests alone cannot validate. Mirror the ci job on windows-latest (Git Bash as the step shell) for typecheck / test / build / consumer-type-surface; the plugin-mount lane (real DSH + Playwright) stays ubuntu-only. check-consumer-types.sh: force MSYS winsymlinks:nativestrict so Git Bash's ln -s creates real symlinks instead of deep-copying the repo (node_modules + full .git history) — inert on Linux/macOS. --- .github/workflows/ci.yml | 43 +++++++++++++++++++++++++++++++++ scripts/check-consumer-types.sh | 7 ++++++ 2 files changed, 50 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a119c37..f10c662 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,49 @@ jobs: - name: Check consumer type surface run: pnpm check:consumer-types + # Windows parity lane for the pure-logic suite. The codebase carries real + # win32 branches (shell parsing, WSL path projection, the PowerShell + # installer) that mock-injection tests alone cannot validate — this lane + # runs the same typecheck / test / build / consumer-type-surface steps on + # a real Windows runtime. The plugin-mount lane (real DSH + Playwright + # Chromium) stays ubuntu-only and is deliberately NOT mirrored here. + ci-windows: + runs-on: windows-latest + timeout-minutes: 30 + defaults: + run: + # The runner ships Git Bash: multi-line steps and + # scripts/check-consumer-types.sh work verbatim. + shell: bash + steps: + - uses: actions/checkout@v7 + with: + # Tests run git log against this repository and need the full history. + fetch-depth: 0 + - uses: pnpm/action-setup@v6 + with: + version: 11 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + # Same rationale as the ci job: smoke tests spawn git without an + # identity and rely on ambient config, which the runner lacks. + - name: Configure git identity for tests + run: | + git config --global user.name "dsh-better-sidebar-ci" + git config --global user.email "ci@dsh.invalid" + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Typecheck + run: pnpm typecheck + - name: Test + run: pnpm test + - name: Build + run: pnpm build + - name: Check consumer type surface + run: pnpm check:consumer-types + # Pack the plugin as an npm tarball, mount it into a REAL DSH instance # through the official `dsh plugin --profile web add` channel, and render # the DSH web UI headlessly (Playwright Chromium) to prove the plugin diff --git a/scripts/check-consumer-types.sh b/scripts/check-consumer-types.sh index 2e3f22e..7f0e822 100755 --- a/scripts/check-consumer-types.sh +++ b/scripts/check-consumer-types.sh @@ -10,6 +10,13 @@ set -euo pipefail cd "$(dirname "$0")/.." +# Git Bash (MSYS) `ln -s` deep-copies directories by default — copying this +# repo (node_modules + full .git history) would be catastrophic. Force real +# native symlinks instead: the GitHub Windows runners (Developer Mode, admin +# user) can create them, and nativestrict makes ln fail loudly rather than +# silently falling back to a copy. Inert on Linux/macOS. +export MSYS="winsymlinks:nativestrict" + # The check reads the BUILT declaration surface (lib/types) — fail loudly # instead of silently passing when the build output is missing. if [ ! -f lib/types/client/service.d.ts ]; then From 24db20a79643081ccc6f63d260f7c9c2848cb5c0 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 3 Sep 2026 10:13:55 +0800 Subject: [PATCH 2/4] test: make five suites Windows-runner clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First ci-windows run exposed four platform assumptions in the fixtures: - smoke: the scratch repo inherited the runner's system-wide core.autocrlf=true (Git for Windows default), so every index restore smudged LF to CRLF and broke the byte-exact assertions — pin core.autocrlf=false in the fixture (eol policy is not what these destructive-op tests are about). - git / git-worktree: Windows TEMP is the 8.3 short form (C:\Users\RUNNER~1\...) while git reports long paths, and realpathSync does not expand 8.3 aliases — switch the canonicalizer to realpathSync.native (libuv resolves via GetFinalPathNameByHandle, which does); same trick as the existing macOS /var workaround. - bundle-route: the ETag memo revalidates on stat (mtime+size) and Windows updates file timestamps lazily, so the equal-size rewrite still looked unchanged — change the length too. - market-manifest: GNU tar (Git Bash) reads the drive-letter colon of absolute Windows paths as a remote-host spec — run it from the pack dir with the bare tarball name instead. --- tests/bundle-route.spec.ts | 7 ++++++- tests/git-worktree.spec.ts | 9 ++++++--- tests/git.spec.ts | 8 ++++++-- tests/market-manifest.spec.ts | 5 ++++- tests/smoke.spec.ts | 6 ++++++ 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/tests/bundle-route.spec.ts b/tests/bundle-route.spec.ts index 1585cd5..501585d 100644 --- a/tests/bundle-route.spec.ts +++ b/tests/bundle-route.spec.ts @@ -84,7 +84,12 @@ describe('/sidebar/bundle route', () => { try { const first = fakeRes() await handler(req('GET', '/sidebar/bundle/editor.js'), first as unknown as ServerResponse) - writeFileSync(join(dir, 'client-editor.js'), 'window.__ModuleLoader__ && 1;') + // The rewrite must change the LENGTH too: the ETag memo revalidates on + // stat (mtime+size), and Windows updates file timestamps lazily — an + // equal-size rewrite right after the first read can still look + // unchanged (stale 304). A size delta busts the memo deterministically + // on every platform. + writeFileSync(join(dir, 'client-editor.js'), 'window.__ModuleLoader__ && 1; // rotated') const second = fakeRes() await handler(req('GET', '/sidebar/bundle/editor.js', { 'if-none-match': first.headers.etag! }), second as unknown as ServerResponse) expect(second.status).toBe(200) diff --git a/tests/git-worktree.spec.ts b/tests/git-worktree.spec.ts index 2f4aca3..c0b4e2e 100644 --- a/tests/git-worktree.spec.ts +++ b/tests/git-worktree.spec.ts @@ -68,8 +68,11 @@ describe('linked Git worktrees', () => { git(main, ['worktree', 'add', '-q', '-b', 'agent', agent]) writeFileSync(join(agent, 'tracked.txt'), 'changed by agent\n') // macOS tmpdir() keeps the /var symlink while git reports the resolved - // /private/var prefix — canonicalize every path handed to git APIs. - const agentPath = realpathSync(agent) + // /private/var prefix; on Windows TEMP is often the 8.3 short form + // (C:\Users\RUNNER~1\...) while git reports the long path. The NATIVE + // realpath resolves both aliasings (libuv's GetFinalPathNameByHandle + // expands 8.3 names) — canonicalize every path handed to git APIs. + const agentPath = realpathSync.native(agent) const listed = await worktrees(main) expect(listed).toHaveLength(2) @@ -87,7 +90,7 @@ describe('linked Git worktrees', () => { rmSync(agent, { recursive: true, force: true }) const remaining = await worktrees(main) expect(remaining).toHaveLength(1) - expect(resolve(remaining[0]!.path)).toBe(resolve(realpathSync(main))) + expect(resolve(remaining[0]!.path)).toBe(resolve(realpathSync.native(main))) expect(remaining[0]!.current).toBe(true) await expect(resolveWorktree(main, agentPath)).rejects.toThrow('unknown linked worktree') } finally { diff --git a/tests/git.spec.ts b/tests/git.spec.ts index 9932d23..b5fccc6 100644 --- a/tests/git.spec.ts +++ b/tests/git.spec.ts @@ -11,8 +11,12 @@ import { parseLogLines, parsePorcelainZ, repoRoots, status } from '../src/git.ts const execFileAsync = promisify(execFile) const normalizePath = (path: string): string => path.replaceAll('\\', '/') // macOS tmpdir() is the /var symlink while git reports the resolved -// /private/var prefix — canonicalize both sides before comparing. -const canonical = (path: string): string => normalizePath(realpathSync(path)) +// /private/var prefix; on Windows TEMP is often the 8.3 short form +// (C:\Users\RUNNER~1\...) while git reports the long path. The NATIVE +// realpath resolves both aliasings (libuv canonicalizes on Windows via +// GetFinalPathNameByHandle, which expands 8.3 names) — canonicalize both +// sides before comparing. +const canonical = (path: string): string => normalizePath(realpathSync.native(path)) describe('git parsing', () => { it('discovers and selects direct child repositories under a workspace directory', async () => { diff --git a/tests/market-manifest.spec.ts b/tests/market-manifest.spec.ts index 945e459..0c30693 100644 --- a/tests/market-manifest.spec.ts +++ b/tests/market-manifest.spec.ts @@ -57,7 +57,10 @@ function packedManifest(): Record { // spawn directly (ENOENT). Run it through the shell so the same call // works on every platform; `tar` is available on all supported OSes. execFileSync('pnpm', ['pack', '--pack-destination', dir], { cwd: ROOT, stdio: 'pipe', shell: process.platform === 'win32' }) - execFileSync('tar', ['-xzf', join(dir, `${pkg.name}-${pkg.version}.tgz`), '-C', dir, 'package/package.json'], { stdio: 'pipe' }) + // Never hand tar an absolute Windows path: Git Bash's GNU tar reads the + // drive-letter colon as a remote-host spec ("Cannot connect to C:"). + // Run it from the pack dir with the bare tarball name instead. + execFileSync('tar', ['-xzf', `${pkg.name}-${pkg.version}.tgz`, 'package/package.json'], { cwd: dir, stdio: 'pipe' }) return JSON.parse(readFileSync(join(dir, 'package/package.json'), 'utf8')) as Record } finally { rmSync(dir, { recursive: true, force: true }) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 990edad..99a834d 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -409,6 +409,12 @@ describe('git destructive operations (scratch repository)', () => { const makeScratchRepo = (): string => { const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-git-')) gitRun(dir, ['init', '-q']) + // Pin the eol policy: Git for Windows defaults to core.autocrlf=true + // (system gitconfig on the CI runner, and many dev machines), which + // smudges LF→CRLF on every index restore and breaks the byte-exact + // assertions below. The destructive-op behavior under test is orthogonal + // to the machine's eol policy. + gitRun(dir, ['config', 'core.autocrlf', 'false']) gitRun(dir, ['checkout', '-q', '-b', 'main']) writeFileSync(join(dir, 'a.txt'), 'one\ntwo\nthree\n') gitRun(dir, ['add', '-A']) From 6d877471d48d966f18dcc55d2862a7e36540bffe Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 3 Sep 2026 10:28:24 +0800 Subject: [PATCH 3/4] fix(pty): contain node-pty's deferred Windows resize after pty exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node-pty's WindowsTerminal queues resize calls that arrive before the ConPTY control socket's first data flush (_deferNoArgs). If the pty exits before the flush, the deferred resize throws 'Cannot resize a pty that has already exited' inside the socket's 'data' handler — uncatchable by any caller and fatal to the host process. The Windows CI lane caught exactly this as an unhandled error while all 1233 tests passed. Park pre-first-output resizes in our own gate (armed at every spawn site) and replay them via setImmediate once output has flushed the deferred list — by then node-pty executes resizes synchronously, so the exited throw lands in tryResizePty's catch instead of the host. POSIX terminals resize synchronously (no deferral), so the gate is armed on Windows only and the other platforms keep their exact behavior. Also route AgentPtyRegistry.resize through tryResizePty: it previously called pty.resize bare, missing the just-exited throw guard the tool and attach-socket paths already had. --- src/agent-pty.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++-- src/index.ts | 5 +++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/agent-pty.ts b/src/agent-pty.ts index e7a0e72..561bfc2 100644 --- a/src/agent-pty.ts +++ b/src/agent-pty.ts @@ -40,12 +40,58 @@ export function clampDims(cols: number, rows: number): { cols: number; rows: num return { cols: clamp(cols), rows: clamp(rows) } } +/** + * Per-pty Windows resize-gate state (see {@link armPtyResizeGate}). + */ +interface ResizeGateState { + /** Whether the pty has produced any output yet (ConPTY flush completed). */ + sawData: boolean + /** Latest dims requested before the first output; replayed once it arrives. */ + pending?: { cols: number; rows: number } +} + +/** + * node-pty's Windows terminal queues resize calls that arrive before the + * ConPTY control socket's first data flush (`_deferNoArgs` in + * windowsTerminal.js). If the pty exits before the queue flushes, the + * deferred resize throws inside the socket's 'data' handler — uncatchable + * by any caller and fatal to the host process. POSIX terminals have no such + * queue (resize is synchronous), so the gate is armed on Windows only: + * {@link tryResizePty} parks dims requested before the first output and + * replays them after the flush, when node-pty executes resizes + * synchronously (and the throw for an exited pty is catchable). + */ +const ptyResizeGates = new WeakMap() + +/** + * Arm the Windows pre-ready resize gate for one freshly spawned pty. + * No-op on POSIX and for injected ptys without `onData`. + */ +export function armPtyResizeGate(pty: IPty): void { + if (process.platform !== 'win32') return + if (typeof pty.onData !== 'function' || ptyResizeGates.has(pty)) return + const state: ResizeGateState = { sawData: false } + ptyResizeGates.set(pty, state) + pty.onData(() => { + if (state.sawData) return + state.sawData = true + const dims = state.pending + state.pending = undefined + if (dims === undefined) return + // node-pty's deferred flush shares this very 'data' dispatch; hop past + // it so the replayed resize executes synchronously (catchable) instead + // of being queued into the same deferred list we are draining. + setImmediate(() => { tryResizePty(pty, dims.cols, dims.rows) }) + }) +} + /** * Best-effort resize for WebSocket-driven terminal views. Layout animation * can briefly produce unusable dimensions, and node-pty can reject a resize * after the socket setup's outer try/catch has returned. Ignore that one * frame so the host stays alive and a later valid measurement can retry. - * Returns whether node-pty accepted the resize. + * Returns whether node-pty accepted the resize (or parked it for replay on + * the first output — the Windows pre-ready window). */ export function tryResizePty( pty: Pick, @@ -54,6 +100,11 @@ export function tryResizePty( ): boolean { if (!Number.isFinite(cols) || !Number.isFinite(rows)) return false const dims = clampDims(cols, rows) + const gate = ptyResizeGates.get(pty) + if (gate !== undefined && !gate.sawData) { + gate.pending = dims + return true + } try { pty.resize(dims.cols, dims.rows) return true @@ -249,6 +300,7 @@ export class AgentPtyRegistry { cwd, env: { ...process.env }, }) + armPtyResizeGate(pty) const handle: AgentTerminalHandle = { uuid, sessionId, @@ -376,7 +428,11 @@ export class AgentPtyRegistry { resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } { const handle = this.expect(uuid) const dims = clampDims(cols, rows) - if (!handle.exited) handle.pty.resize(dims.cols, dims.rows) + // Through tryResizePty: node-pty throws on a just-exited pty (the exit + // event may not have flipped `handle.exited` yet), and on Windows a + // pre-ready resize must go through the gate instead of node-pty's own + // deferred queue (which flushes uncatchably inside its socket handler). + if (!handle.exited) tryResizePty(handle.pty, dims.cols, dims.rows) return dims } diff --git a/src/index.ts b/src/index.ts index e87ff4b..85c53d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,7 +42,7 @@ import { launchExternal } from './open-external.ts' import * as git from './git.ts' import { SettingsConflictError } from '@deepseek-ai/dsh-settings' import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts' -import { AgentPtyRegistry, tryResizePty, type AgentTerminalHandle } from './agent-pty.ts' +import { AgentPtyRegistry, armPtyResizeGate, tryResizePty, type AgentTerminalHandle } from './agent-pty.ts' import { DSH_NODE_PTY_RANGE, depsStatus, @@ -1209,6 +1209,9 @@ async function attachTerminal( // terminals opened from now on (existing pty handles keep their shell). const overrides = shellOverridesOf(getSettings) const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24, overrides.shell, overrides.shellArgs) + // Windows pre-ready gate for the resize frames this socket may deliver + // (see armPtyResizeGate; inert on POSIX). + armPtyResizeGate(handle.pty) // Replay the transcript, then follow live output. if (handle.transcript !== '') ws.send(handle.transcript) const onData = (data: string): void => { From c4c4d73389b8cfa6c5d334d029d701b4b75953dd Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 3 Sep 2026 10:36:03 +0800 Subject: [PATCH 4/4] fix(ci): resolve consumer-type link targets to their physical path On the Windows lane the @deepseek-ai/cordis link produced TS2307 even though the link was visibly created: under pnpm its target, node_modules/@deepseek-ai/cordis, is itself a junction into .pnpm, and a native symlink pointing AT that junction does not traverse for tsc. Resolve both link targets with node's native realpath first (GetFinalPathNameByHandle also expands junctions, which bash-level resolution may leave untouched), and fail loudly with the link layer listed when a created link does not resolve to a package root. --- scripts/check-consumer-types.sh | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/check-consumer-types.sh b/scripts/check-consumer-types.sh index 7f0e822..51b9ccd 100755 --- a/scripts/check-consumer-types.sh +++ b/scripts/check-consumer-types.sh @@ -28,11 +28,27 @@ WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT mkdir -p "$WORK/node_modules" -ln -s "$(pwd)" "$WORK/node_modules/dsh-better-sidebar" +# Resolve link targets to their PHYSICAL path first: under pnpm, +# node_modules/@deepseek-ai/cordis is itself a symlink/junction into .pnpm, +# and on Windows a native symlink pointing AT a junction does not traverse +# for tsc (TS2307 on a link that visibly exists). Node's NATIVE realpath +# (GetFinalPathNameByHandle on Windows) expands junctions, which bash-level +# resolution may leave untouched; node is guaranteed here (tsc runs below). +resolve_dir() { node -e 'process.stdout.write(require("node:fs").realpathSync.native(process.argv[1]))' "$1"; } +ln -s "$(resolve_dir "$(pwd)")" "$WORK/node_modules/dsh-better-sidebar" # The vendored cordis base (and, through its realpath, cosmokit / standard-schema) # must resolve so the consumer can type `Context` against the DSH runtime scope. mkdir -p "$WORK/node_modules/@deepseek-ai" -ln -s "$(pwd)/node_modules/@deepseek-ai/cordis" "$WORK/node_modules/@deepseek-ai/cordis" +ln -s "$(resolve_dir "$(pwd)/node_modules/@deepseek-ai/cordis")" "$WORK/node_modules/@deepseek-ai/cordis" +# Loud guard: both links must resolve to a package root, or tsc below would +# report misleading module-resolution errors. +for pkg_json in "$WORK/node_modules/dsh-better-sidebar/package.json" "$WORK/node_modules/@deepseek-ai/cordis/package.json"; do + if [ ! -f "$pkg_json" ]; then + echo "[check-consumer-types] FAIL: $pkg_json does not resolve (broken link). Link layer:" >&2 + ls -la "$WORK/node_modules" "$WORK/node_modules/@deepseek-ai" >&2 || true + exit 1 + fi +done cat > "$WORK/check.ts" <<'EOF' import { SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from 'dsh-better-sidebar/client/service'