Skip to content

Fix Windows stdin-only pipe hang, launch attribute lifetime, and lost scaffolds - #188

Merged
fstubner merged 7 commits into
mainfrom
fix/windows-stdio-scaffold-and-launch-failure
Oct 7, 2026
Merged

fstubner merged 7 commits into
mainfrom
fix/windows-stdio-scaffold-and-launch-failure

Conversation

@fstubner

@fstubner fstubner commented Oct 7, 2026

Copy link
Copy Markdown
Owner

Three Windows sandbox defects.

A piped stdin alone hung the contained process. The stdio preload substituted a pipe in stdout or stderr, and stdin only alongside them. spawn or spawnSync with ['pipe', 'inherit', 'inherit'] reached the real call, which retries the pipe an AppContainer refuses forever. @prisma/client's postinstall uses that shape. A pipe in any slot is now substituted.

Launch attributes could be freed before CreateProcess read them. They were kept alive with blank assignments, which Go drops. With collections forced throughout, 1000 launches failed 9 times with "The parameter is incorrect" and started 7 children whose token was not an AppContainer token. With runtime.KeepAlive, 1000 launches under the same conditions all ran contained. A resolution step the sandbox never started is now retried once and then refused with exit 77, and NVX_YES/-y no longer approve it.

A scaffolder could lose its project and exit 0. A non-project folder whose permission walk overran 1.5 s was swapped for the sandbox's home. It now gets one entry, written without the walk, covering the folder and anything created in it. A command that may not run where it started (home, above it, inside NVX_HOME) still runs in the sandbox's home, in an empty folder of its own. If it leaves anything there, nvx names it, deletes it and exits 77.

Verification (Windows 11, scratch NVX_HOME)

  • gofmt -l internal cmd: empty. go vet ./... for windows, linux, darwin: clean.
  • go test ./internal/nvx -count=1: ok (126 s).
  • NVX_PROBE=1 for the new probes and the existing stdio probes: all pass.
Test origin/main this branch
TestEveryStdioShapeCompletesInsideTheSandbox timed out at 120 s on a-pipe-inherit-inherit pass, 8.5 s
TestALaunchSurvivesACollectionJustBeforeCreateProcess first launch ran outside the AppContainer, 3 of 3 runs (seam present, KeepAlive removed) pass
TestAScaffoldFromAFolderTooLargeToGrantStaysWhereItWasRun fail, project missing pass
TestAScaffoldThatCannotRunWhereItWasStartedDoesNotReportSuccess fail, exit 0 pass
TestAResolutionTheSandboxNeverStartedIsRefusedEvenWithYes fail, approved by NVX_YES pass

By hand: nvx npm install ./pkg whose postinstall spawns with ['pipe','inherit','inherit'] hung until killed at 90 s on main and finished in 3 s here. A scaffold run from a non-project folder of 20,200 files kept its folder (main lost it).

The Windows stdio preload replaced a pipe in stdout or stderr, and stdin
only alongside them. spawn and spawnSync with ['pipe', 'inherit',
'inherit'] reached the real call, which retries the pipe an AppContainer
refuses forever. The contained node process spun on one core and no
timer fired. @prisma/client's postinstall spawns with that shape, so a
contained npm install of a Prisma 6 project never returned.

A pipe in any of the three slots now goes through the substitution. A
probe runs all 27 shapes of pipe, inherit and ignore through spawn and
spawnSync and checks where each stream arrives.
The attribute list, the security capabilities, the capability SIDs and
the handle list were kept alive with blank assignments, which Go drops.
A collection between building them and CreateProcess could free them and
hand the memory out again. With collections forced throughout, 1000
launches failed 9 times with "The parameter is incorrect" and started 7
children whose token was not an AppContainer token. With runtime.KeepAlive,
1000 launches under the same conditions all ran contained. A probe runs a
collection just before CreateProcess and checks the child's token.

npm's resolution step that the sandbox never started was treated as npm
failing, so NVX_YES or -y approved the install with only the named
packages checked. It is now tried once more and then refused with exit
77, and no approval waives it.
A folder that is not a project and whose permission walk overran 1.5 s
was replaced by the sandbox's home as the working directory. npm create
vite@latest myvite printed Done, exited 0 and lost the project. A folder
of 20,200 files was enough. Such a folder now gets one entry, written
without the walk, that covers the folder and anything created in it.

A command that may not run where it started (the home folder, above it,
or inside NVX_HOME) still runs in the sandbox's home, now in an empty
folder of its own. When it leaves anything there, nvx names it, deletes
it and exits 77 where it exited 0.
writeProtectedDotenvACL attached its permission list to a security
descriptor, which holds the list's address as bytes the garbage collector
does not follow, and then wrote the descriptor. The list could be freed
first. It is now kept alive with runtime.KeepAlive, as in
writeEntryWithoutWalk and the launch.

A source test fails on a blank assignment in a Windows file that uses
unsafe code, and on any function that attaches a list to a descriptor
without runtime.KeepAlive. Against origin/main it reports all three
cases fixed on this branch.
@fstubner
fstubner merged commit 7b8f4df into main Oct 7, 2026
5 checks passed
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