Skip to content

fix(windows): spawn .cmd shims through the interpreter and report owned exit codes - #5

Merged
Patodo merged 3 commits into
mainfrom
fix/windows-cmd-shims
Sep 18, 2026
Merged

Patodo merged 3 commits into
mainfrom
fix/windows-cmd-shims

Conversation

@Patodo

@Patodo Patodo commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner

让 Windows 上的本地开发流程真正可用。三条缺陷都是我在同一台机器上跑完整流程时定位并验证的。

① localapp init 装不上依赖

const child = spawn("npm", ["install"], { … });

Node 在 Windows 上不能 spawn .cmd 垫片(CVE-2024-27980 之后必须经 shell),PATH 上只有 npm.cmd/npm.ps1,于是 ENOENT → dependency_install_failed,并且失败后把刚建的项目回滚删除。

新增 src/process/command-invocation.ts:裸命令名交给命令解释器(解释器是绝对路径,并通过 PATHEXT 解析垫片);带路径的调用保持不变。

② localapp dev 起不来项目脚本

dev 的 runner 把裸 npm.cmd 交给受控包装层,而包装层只接受绝对可执行路径,脚本永远启动不了。

  • TS 侧:按 PATH + PATHEXT 把垫片解析成绝对路径。这里有个坑值得记下:npm 会在旁边装一个无扩展名的 POSIX 脚本 npm,先试裸名就会拿到它,解释器执行失败返回 1——所以必须先按 PATHEXT 解析(测试里加了无扩展名诱饵钉住)。
  • Rust 侧:job_owner 遇到 .cmd/.bat 目标时自己起命令解释器并构造命令行。这是唯一能控制 cmd 解析的那一行引号的层——从 TS 侧传 /d /s /c 给包装层不行,因为它会对每个 argv 元素重新加引号,解释器随后起的是交互式会话。

③ 装应用时服务端 EPERM fsync

app-installer.ts 保留包那一步用只读句柄打开副本后调 handle.sync();Windows 的 FlushFileBuffers 需要可写句柄,于是每次都 APP_INSTALL_FAILED: EPERM fsync。同文件里早已有 syncFile() 承担这条规则("r+" + 容忍纯访问类失败),改用它。

(定位过程:fs.fsyncSync 打点抓不到,换成连 FileHandle.prototype.sync 一起打点才确认——这也说明 61f5c8a 那批加固漏掉的正是这种 FileHandle 形态。)

④ 模板自带测试在 Windows 上必然失败

localapp check 在任何新脚手架工程上都是红的,因为模板测试有两条断言直接比较多行源码文本,而 Windows 检出与 Windows 侧的模板同步都是 CRLF,toContain("...\n...") 永远不匹配。这独立地挡住了 dev(dev 会跑同一套检查)。在测试的读取函数里统一换行。

端到端验证(Windows 11 26200,用打包产物,未加任何垫片)

localapp init app1        -> {"created":"app1"},装下 570 个包
localapp check            -> "success":true,7/7 阶段全过
localapp dev              -> App URL: http://127.0.0.1:61275/
                             Local Server: http://127.0.0.1:49860
$ curl <App URL>          -> 200,返回 Vite dev shell
$ curl <Server>/health    -> {"status":"ok"}

测试

  • tests/command-invocation.test.ts:6 例(含无扩展名诱饵、PATHEXT 顺序、路径不被改写)
  • native-adapter.node-test.mjs:源码断言钉住退出码传播与 .cmd/.bat 包装
  • tsc --noEmit 通过;test:public-source 通过

未包含

localapp server restart 会报 ipc_response_invalid 但实际重启成功——原因是控制处理器期限 5s 短于 daemon 自身的 readiness 预算 15s,连接在写响应前被销毁。与本次改动无关,需要单独修。

…ed exit codes

Two Windows defects blocked the local project workflow:

`localapp init` installed dependencies with spawn("npm", …). Node refuses to
spawn a .cmd shim without a shell, so it failed with ENOENT and then rolled the
project back, leaving the "run npm install yourself" hint impossible to follow.
Route the bare package-manager name through the command interpreter, which is
absolute and resolves the shim through PATHEXT.

`--job-owner` waited for its root and returned Ok(()) without reading its exit
code, so every owned command looked successful. Read it with GetExitCodeProcess
and make it the wrapper's own exit status; a failing project script is no longer
reported as a passing one.

Verified on Windows 11 26200: `localapp init` now installs 570 packages instead
of failing, and the wrapper reports 7, 0 and 3 for children that exit 7, 0 and 3.

`localapp dev` is still blocked and is not touched here: its script runner hands
a bare `npm.cmd` to the owned-process wrapper, which only accepts an absolute
executable. Routing it through cmd.exe from this layer does not work — the
wrapper re-quotes each argv element and the interpreter then starts an
interactive session instead of running the command — so that fix belongs in
`job_owner`, which should wrap a .cmd/.bat target itself.
Three defects, verified on one machine by running the whole flow:

`localapp init` wrote a project whose own test suite could never pass on
Windows. The template's failures were the two assertions that compare
multi-line source text: a Windows checkout and the Windows template sync carry
CRLF, so `toContain("...\n...")` never matched and `localapp check` failed at
the tests phase — which also blocked `localapp dev`, since it runs the same
check. Normalize line endings in the template test's reader.

`localapp dev` handed the owned-process wrapper a bare `npm.cmd`. The wrapper
only accepts an absolute executable path and cannot load a script image, so the
project scripts never started. Resolve the shim through PATH and PATHEXT on the
CLI side — an extensionless file of the same name must not win: npm ships one,
and returning it made the interpreter fail with exit 1 — and wrap a `.cmd`/
`.bat` target in the command interpreter inside `job_owner`, the only layer that
controls the quoting cmd.exe parses.

Installing an application into the local Server failed on Windows with
`APP_INSTALL_FAILED: EPERM fsync`. The retained-package step opened the copy
read-only and called `handle.sync()`; FlushFileBuffers needs a write-capable
handle there. Use the file's own `syncFile` helper, which already owns that
rule, instead of the inline open/sync/close.

Verified with the packaged build on Windows 11 26200:

  localapp init app1      -> 570 packages installed
  localapp check          -> success, all 7 phases passed
  localapp dev            -> App URL served (HTTP 200, Vite dev shell) and the
                             embedded Server answered /health, with no shim
The new cases built a fake PATH with the host path delimiter and a native temp
directory, so they passed on Windows and failed on the Linux runner: the
resolver splits with the win32 delimiter and the injected win32 paths do not
exist there.

Take the existence check as an injected seam instead, so the PATHEXT ordering
rule (npm's extensionless POSIX script must not win over its .cmd shim) is
asserted the same way on every platform.
@Patodo
Patodo merged commit dbbcfb9 into main Sep 18, 2026
4 checks passed
Patodo added a commit that referenced this pull request Sep 18, 2026
Ships the Windows local-flow fixes from #5: init installs dependencies,
`localapp dev` starts the project scripts through the interpreter, installing an
application into the local Server no longer fails on a read-only fsync, and the
template's own tests stop being line-ending sensitive.

Version-bound test assertions move to 0.2.7 and the reviewed public source
baseline digests are re-recorded for the two files this touches. The flagged
content in both files is unchanged: the same three canonical package markers
and the same apiKey fixture as before.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant