Skip to content

win: support running inside an AppContainer (lowbox token) - #5

Closed
dylan-conway wants to merge 30 commits into
bunfrom
dylan/win-appcontainer
Closed

dylan-conway wants to merge 30 commits into
bunfrom
dylan/win-appcontainer

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Jun 18, 2026 •

Copy link
Copy Markdown
Member

win: support running inside an AppContainer (lowbox token)

Small commits that let libuv-based programs work inside a Windows AppContainer (lowbox token). Supersedes #4, which carried a uv_fs_realpath device-mapping fallback (~400 lines) that the consumer can implement on its side instead; this PR is just the parts that have to live in libuv.

What breaks today (verified on Windows 11 26200)

  • An AppContainer process may only create named pipes under \\?\pipe\LOCAL\; anywhere else CreateNamedPipe returns ERROR_ACCESS_DENIED. uv__unique_pipe_name generates \\?\pipe\uv\<ptr>-<pid> and uv__pipe_server treats ERROR_ACCESS_DENIED as a name collision and retries with a new name forever, so any uv_spawn with piped stdio (and uv_pipe()) busy-loops at 100% CPU.
  • Console read cancellation relies on injecting a synthesized input record with WriteConsoleInputW, which a sandboxed process may be denied on an inherited console; the failure paths deadlocked the loop or hung close.
  • GetFinalPathNameByHandleW(VOLUME_NAME_DOS) is denied on every handle (the DOS-name translation opens the mount-manager device, which the sandbox blocks), so uv_fs_realpath always fails with EPERM.
  • The default \Device\Null ACL denies AppContainer tokens (all SKUs; some hosts re-grant it per boot), failing every spawn with a UV_IGNORE stdio slot. Deliberately not worked around here — see the Process section.

Changes

Pipes

  • uv__unique_pipe_name generates \\?\pipe\LOCAL\uv\<ptr>-<pid>. The names are internal (never surfaced; embed a pointer and the pid) and outside an AppContainer the prefix is just six literal bytes in the name, so this is not observable.
  • uv__pipe_server bounds ERROR_ACCESS_DENIED retries: one retry with a freshly drawn random name (the previous candidates derive from a fixed seed + pid, so a squatter could deny pipe creation by occupying the guessable names), then the error is returned instead of looping. ERROR_PIPE_BUSY re-arms the limit — a busy pipe proves the namespace is accessible — and also draws a fresh random name, since a squatter can force that error too.
  • uv_pipe() translates failures to negative UV_E* codes at the API boundary (it previously returned raw positive Win32 codes), and CRT fd exhaustion surfaces as UV_EMFILE instead of UV_UNKNOWN (a dead errno == UV_EMFILE comparison).
  • uv_pipe_bind distinguishes EACCES (namespace denied — renaming will never help) from EADDRINUSE (name genuinely taken — renaming can help). The ambiguity is resolved by probing whether the name exists (WaitNamedPipe with no timeout, which doesn't consume a connection; probed through the \\.\ spelling since WaitNamedPipe rejects \\?\), not by token inspection — so a genuine collision inside a sandbox still reports EADDRINUSE, and non-AppContainer sandboxes get the right answer too. When the probe finds the name free, the bind is retried once: a collision whose server just vanished binds successfully instead of misreporting. Deliberate limits: a probe that itself fails (e.g. denied by a stricter sandbox) conservatively keeps EADDRINUSE, and a server vanishing twice in a row can still be misread as a denial — one bounded retry is the trade against probing forever.

TTY

  • Console read cancellation works without input injection: line mode falls back to CancelSynchronousIo on the reader thread, raw mode claims the registered wait and posts the completion directly, and the failure paths are sound either way (previously: leaked lock + armed trap, or a hung close). The reader publishes its thread handle before flipping the read status, so the canceller never waits on publication; the retry yields (SwitchToThread, the same shape as uv__pipe_interrupt_read) rather than sleeping, and a failed thread duplication withdraws immediately instead of burning the retry budget.
  • Backports upstream ec0ab5d7 (GHSA-4prr-4742-3ccf, off-by-one heap overflow in the utf-16→wtf-8 conversion), and fixes the sizing interaction the backport leaves behind: the read is sized to the conversion capacity ((bytes - 1) / 3 code units) so an exact-fill line is never delivered torn with an embedded NUL or an nread larger than the buffer. Allocations too small to hold even one converted character report UV_ENOBUFS instead of silently consuming input. (Upstream has the same latent exact-fill issue.)
  • ReadConsoleW's error is captured before the reader thread is unpublished (GetLastError-clobber class).

Filesystem

  • fs__realpath_handle preserves the real error when the second GetFinalPathNameByHandleW call fails (it was overwritten with ERROR_INVALID_HANDLE, surfacing as EBADF), and fs__realpath captures GetLastError() before CloseHandle() can clobber it.
  • The ACCESS_DENIED stat fallback (which sandboxed processes take for every denied path) no longer reads path[-1] for all-dot relative paths or writes an uninitialized WCHAR over the path's last character, and it searches the right directory for root-adjacent spellings: splitting X:\name, \\?\X:\name, \\.\X:\name, or rooted \name previously left a drive-relative (X:), volume-device (\\?\X:), or empty directory to search, which could silently stat the wrong file.

Process

  • Opening NUL for UV_IGNORE stdio slots is unchanged: a denied open fails the spawn with the real error, exactly as upstream. The default \Device\Null ACL denies AppContainer tokens on every SKU (the kernel resets it to that default each boot); hosts that need ignored stdio inside a container grant the device to the container SIDs from an elevated context, or launch the sandboxed process with its std handles already wired to NUL so children inherit or duplicate them (handle duplication performs no new access check). An earlier revision of this PR substituted a closed-end anonymous pipe on denial; it was dropped because failing writes turn every write-checking child (python, git, gnulib-based tools) into a confusing nonzero exit, and no mainstream runtime falls back when the null device is unavailable.

API

  • uv_os_is_app_container() — returns 1 when the process token is an AppContainer token (GetTokenInformation(TokenIsAppContainer), cached with uv_once); 0 otherwise and on non-Windows. Declared in uv.h, documented in misc.rst. (No internal caller needs it — the pipe and fs fixes are deliberately unconditional — so it stands as embedder API.)

Tests

pipe_emfile (fd exhaustion → UV_EMFILE), tty_line_read_cancel_no_inject (cancellation through a read-only console handle, exercising the no-injection fallback in plain CI), tty_line_read_exact_fill (exact-fill line reads deliver complete sequences, with the buffer sized so ASAN catches a return of the overflow), and a bounds regression test for the wtf-8 conversion. The versionadded:: 1.52.0 annotation on uv_os_is_app_container was always false (upstream 1.52.x predates it and never shipped this API); it is marked as a fork extension.

Notes

  • A capped access-denied failure from uv_pipe() surfaces as UV_EPERM (the standard ERROR_ACCESS_DENIED mapping).
  • Sandbox-only branches are verified with a manual AppContainer harness (CreateAppContainerProfile, no admin required); libuv's own CI does not run sandboxed, but the downstream bun PR runs this commit inside a real AppContainer in its regular Windows CI (test/js/bun/windows/appcontainer.test.ts).
  • Accepted residuals: the exact-fill read sizing can split a surrogate pair at multiple-of-3 allocation sizes (lone surrogates are valid WTF-8, the documented contract of uv_utf16_to_wtf8); the bind disambiguation's vanished-server retry narrows but cannot eliminate misclassification under adversarial churn (bounded by design); and when injection is denied and the cancel misses (reader preempted in the short pre-wait window, or its thread handle failed to duplicate), the trap is withdrawn and the next typed line is consumed and suppressed — bounded degradation where the old code hung the loop.

Not in this PR

uv_fs_realpath still always fails with EPERM inside an AppContainer (as it did before this PR). Junctions created by a sandboxed process are quarantined by the kernel (their reads fail); making them legible is deliberately out of scope. A working fallback requires building a device→drive-letter map, which is large and has no single-call solution; consumers that need it can implement it on top of VOLUME_NAME_NT and FileIdInformation.

robobun and others added 11 commits June 6, 2026 00:08
Returns 1 when the process runs with an AppContainer (lowbox) token,
0 otherwise and on non-Windows platforms. Several Windows facilities
(the named pipe namespace, the mount manager) are restricted inside an
AppContainer; embedders and upcoming libuv code need a cheap way to
detect that situation.
An AppContainer (lowbox token) process may only create named pipes
under \\?\pipe\LOCAL\. uv__unique_pipe_name generated names outside
that prefix, so every CreateNamedPipe call failed with
ERROR_ACCESS_DENIED, which uv__pipe_server treated as a name collision
and retried with a new name forever, spinning at 100% CPU. Any
uv_spawn with piped stdio or uv_pipe() hit this.

- Prefix the generated names with LOCAL\. The names are internal and
  never surfaced (they embed a pointer value and the pid), and outside
  an AppContainer the prefix is just part of the name, so this is not
  observable.
- Cap ERROR_ACCESS_DENIED retries in uv__pipe_server at one so a
  process that is denied access to the pipe namespace for other
  reasons fails instead of looping forever.
When the second GetFinalPathNameByHandleW call failed, the error was
overwritten with ERROR_INVALID_HANDLE before being read by the caller,
so the caller reported EBADF regardless of the real failure. Preserve
the error across the buffer free instead.
uv_pipe returned the Windows error from uv__create_pipe_pair as a
positive value instead of a negative UV_E* code. ERROR_ACCESS_DENIED
could not previously escape uv__pipe_server's retry loop, but now that
the retries are capped it reaches callers in exactly the sandboxed
setups the cap is for, so translate it at the API boundary. The EMFILE
branch below compared the CRT errno against UV_EMFILE and could never
match, returning UV_UNKNOWN instead; compare against EMFILE.

Also reset the ERROR_ACCESS_DENIED retry limit when an attempt fails
with ERROR_PIPE_BUSY. A busy pipe proves the namespace is accessible,
so a later ERROR_ACCESS_DENIED is another name collision rather than a
sandbox denial. The stdio pipe names start from a constant seed and
routinely walk several ERROR_PIPE_BUSY collisions, so two stale
cross-user names anywhere in that walk could otherwise fail a spawn.
fs__realpath read GetLastError() after CloseHandle(handle). The
last-error value is only defined after a failed call; a successful
CloseHandle may overwrite it, losing the error fs__realpath_handle
preserved. Capture it first, the way fs__readlink does.
Cancelling a pending console read relies on writing a synthesized input
record (VK_RETURN for line reads, FOCUS_EVENT for raw reads) to wake the
blocked reader. A sandboxed process (for example a Windows AppContainer)
may read from a console it inherited while being denied
WriteConsoleInputW on it, and the failure paths did not cope:

- uv__cancel_read_console returned with uv_tty_output_lock still held
  and uv__read_console_status left at TRAP_REQUESTED, so the next tty
  operation deadlocked the loop thread.
- The raw path left the registered console wait armed, so the read
  request never completed and closing the handle hung the loop.

Make cancellation work and the failure paths sound:

- Track the thread blocked in ReadConsoleW and fall back to
  CancelSynchronousIo when input injection fails; the reader then
  completes and releases the output lock as usual. If the reader cannot
  be cancelled, withdraw the trap, release the lock, and report the
  cancellation as pending so the eventual completion is suppressed.
- For raw reads, claim the registered wait and post the completion
  directly; the wait callback and uv__tty_read_stop arbitrate via an
  interlocked exchange of read_raw_wait. A pending sentinel covers the
  window where the wait fires before RegisterWaitForSingleObject's
  out-parameter store is visible.
- Suppress cancelled-read error completions in
  uv_process_tty_read_line_req the same way the success path suppresses
  the injected VK_RETURN, so a cancel-and-restart (uv_tty_set_mode)
  requeues the read instead of delivering a stray error.
The names uv__pipe_server tries are guessable: they derive from a fixed
seed (the stdio path uses a compile-time constant) plus the pid, and
retries increment by one. Since the retry limit was added, another
process could deny pipe creation outright by squatting on the first two
candidate names with spare instance capacity, which turns the victim's
FILE_FLAG_FIRST_PIPE_INSTANCE create into ERROR_ACCESS_DENIED; before
the limit, the loop walked past any finite squat.

Re-randomize the name on an access-denied retry so the next candidate
cannot be predicted. A second denial then really does mean the pipe
namespace is inaccessible, which is what the retry limit is for.
The console read worker now unpublishes its thread handle (a critical
section plus CloseHandle) between ReadConsoleW and the GetLastError
read, and either call may clobber the thread's last-error value -- the
same class of bug recently fixed twice in fs.c. In the worst case a
genuine ReadConsoleW failure would surface as the wrong error, or as a
zero-byte read if the value was overwritten with zero. Capture the
error immediately after ReadConsoleW.
Reserve one byte for the NUL terminator when passing the buffer size to
uv_utf16_to_wtf8() in the TTY line-read path. Without this, when all
input characters encode to exactly 3 UTF-8 bytes (e.g. CJK) and the
buffer size is divisible by 3, the NUL terminator is written one byte
past the allocated buffer.

Backport of libuv/libuv commit ec0ab5d (GHSA-4prr-4742-3ccf), which
this branch predates; the regression test covers the conversion's
bounds directly.
- pipe_emfile: exhaust the CRT fd table and assert uv_pipe() reports
  UV_EMFILE; it surfaced UV_UNKNOWN before the errno comparison fix.
- tty_line_read_cancel_no_inject: cancel a pending console line read
  through a read-only console handle, on which the usual
  WriteConsoleInputW wake-up is denied; exercises the cancellation
  fallback that previously deadlocked the loop.
Upstream has since released 1.52.x without this API, so the
versionadded annotation pointed at a release that does not contain
it.
@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown

Ran a bun build pinned to this branch (through b919670) inside a real AppContainer on Windows Server 2019 (17763), with an API sweep covering process spawning, pipes, IPC, tty, fs, and sockets. Results for the commits here:

  • uv_spawn with piped stdio, uv_pipe() pairs, and node-style IPC over the libuv channel all work inside the container. The same test against the previous libuv spins in uv__pipe_server until killed, so the LOCAL\ prefix plus the retry cap are load-bearing and verified end to end.
  • With the embedder's ConPTY pipes also created under \\.\pipe\LOCAL\, CreatePseudoConsole plus a child doing raw-mode console reads, uv_tty_set_mode toggles, and exit with a pending read all work inside the container, which exercises the new cancellation path. Nothing hangs.

Two more AppContainer denials surfaced that libuv may want to own. Both verified on 17763.

1. The NUL device is ERROR_ACCESS_DENIED inside an AppContainer. uv__stdio_create opens NUL (uv__create_nul_handle) for every UV_IGNORE stdio slot, so any uv_spawn with an ignored stdio entry still fails on this branch. stdin defaults to ignore in a lot of real code, so after the pipe namespace this is the biggest spawn breakage:

stdio result in the container
all pipe, all inherit, mixed pipe+inherit works
any slot UV_IGNORE uv_spawn fails (access denied opening NUL)

When CreateFileW(L"NUL") fails and uv__is_app_container(), falling back to an anonymous pipe with the parent's end closed would make ignore behave like NUL for both directions with no API change.

2. Mount points created from inside an AppContainer are rewritten by the kernel. A junction created with uv_fs_symlink(UV_FS_SYMLINK_JUNCTION) from inside the container ends up with SubstituteName = \??\Global\C:\path instead of \??\C:\path. The path libuv receives is identical in and out of the container and fs__create_junction builds the buffer from it deterministically, and cmd /c mklink /J from inside the container produces the same form, so this is the kernel canonicalizing the name against the sandbox's device map:

created outside the container:  Substitute Name: \??\C:\ac\proj\t2\jt
created inside the container:   Substitute Name: \??\Global\C:\ac\proj\t2\jt

Traversal through such a junction works everywhere. But fs__readlink_handle only recognizes \??\<drive>: mount points, so uv_fs_readlink returns EINVAL and uv_fs_lstat does not report a link for junctions the sandboxed process itself created. Accepting and stripping a Global\ segment after \??\ for IO_REPARSE_TAG_MOUNT_POINT would cover it.

For reference, the rest of the sweep's libuv-relevant results inside the container match this PR's description: GetFinalPathNameByHandleW works with VOLUME_NAME_NT and VOLUME_NAME_NONE and is denied with VOLUME_NAME_DOS and VOLUME_NAME_GUID; QueryDosDevice and GetVolumeInformationW on a drive root are denied (so a consumer-side NT-to-DOS mapping cannot be seeded from those, only from paths whose DOS spelling the process already knows, like the cwd and the executable); CreateSymbolicLinkW fails with ERROR_PRIVILEGE_NOT_HELD regardless of flags; anonymous pipes, CONOUT$, and in-process loopback all work. The window station detail worth documenting somewhere: outside an interactive session the container SID needs to be granted on the window station and desktop or any exe importing user32 dies at load with STATUS_DLL_INIT_FAILED.

@dylan-conway

Copy link
Copy Markdown
Member Author

Both landed, plus one more since your sweep ran (head was b919670 then):

Junctions — 1ad4de83. fs__readlink_handle now recognizes a \??\Global\<drive>: substitute name and splices the Global\ component out before the existing drive-letter validation, so uv_fs_readlink/uv_fs_lstat work on junctions the sandboxed process itself created. ERROR_UNTRUSTED_MOUNT_POINT (448) is also mapped to UV_EACCES so traversal through such a junction reports something legible instead of UV_UNKNOWN. Verified in-container on 26200: readlink of a just-created junction returns the target, stat through it reports EACCES. (Worth noting for the sweep matrix: on 26200 traversal through a sandbox-created junction is denied with 448 — the untrusted-mount-point mitigation appears to be enforced here but not on 17763, where your sweep saw traversal succeed.)

NUL — 2d640bb3. When CreateFileW(L"NUL") fails, uv__create_nul_handle substitutes an anonymous pipe with the other end closed: reads see EOF, writes fail fast instead of blocking. Deliberately not gated on uv__is_app_container() — NUL never fails outside sandboxes, and gating would leave non-AppContainer sandboxes with the old failure. One caveat: 26200 allows NUL inside an AppContainer (the substitute path never triggers here), so the fallback is exercised only by review and the pipe-shape semantics on this machine — a re-run of your UV_IGNORE matrix on 17763 would be a welcome confirmation.

Also relevant to the sweep: a237cadd makes uv_pipe_bind report UV_EACCES instead of UV_EADDRINUSE when binding a free name is denied (disambiguated by checking name existence via WaitNamedPipe, which doesn't consume a connection), while genuine collisions — including on LOCAL\ names inside a container — still report EADDRINUSE.

The window-station/desktop grant requirement and the rest of the host-responsibility list are documented on the bun side (docs/runtime/windows-appcontainer) rather than in libuv.

The path-splitting in the ACCESS_DENIED stat fallback read path[-1]
for all-dot relative paths (the scan leaves split == 0 with no name
seen, and the separator check then indexes split - 1), and the cleanup
wrote an uninitialized splitchar over the path's last character for
every branch that does not actually split (trailing-slash paths, '..',
'c:'). Sandboxed processes take this fallback for any denied path, so
the undefined behavior went from theoretical to routine.

Initialize splitchar, guard the separator check, and restore the path
only when it was split.
uv_pipe_bind mapped CreateNamedPipe's ERROR_ACCESS_DENIED to
UV_EADDRINUSE unconditionally: with FILE_FLAG_FIRST_PIPE_INSTANCE that
error normally means the name is taken. A process that is denied the
pipe namespace (e.g. a sandboxed process binding outside
\.\pipe\LOCAL\) gets the same error for a completely free name and
was told the address is in use, inviting futile rename-and-retry loops.

Disambiguate by checking whether the name exists: WaitNamedPipe does
not consume a pipe connection and reports ERROR_FILE_NOT_FOUND when no
live instance exists. Name present stays UV_EADDRINUSE, including for
genuine collisions inside a sandbox; name absent becomes UV_EACCES.
Some sandboxed processes (a Windows Server AppContainer, for example)
are denied the NUL device, and uv__stdio_create opens NUL for every
UV_IGNORE stdio slot, so any spawn with an ignored slot failed even
though pipe and inherit slots work. When the open fails, substitute an
anonymous pipe with the other end closed: reads see EOF and writes
fail fast instead of blocking, the closest available approximation of
the null device. The NUL handle is unchanged where it can be opened.
The off-by-one fix reserved one byte for the NUL terminator in the
conversion buffer but kept sizing the ReadConsoleW request at bytes/3
code units, whose worst-case conversion needs the full allocation. On
an exact fill the converter truncated mid-character and reported the
required (larger) length, so read_cb received a torn character with an
embedded NUL counted inside nread - and for allocations smaller than
one code point, an nread larger than the buffer itself.

Size the read to the conversion capacity, (bytes - 1) / 3, so the
worst case always fits, and deliver nothing rather than torn bytes in
the only remaining failure case (an allocation too small for a single
character). Add a regression test that injects console input against a
6-byte allocation and checks the payload is complete sequences.
Sleep(1) rounds up to the system timer resolution, so 100 retries
meant the loop thread could block for over a second at the default
15.6ms resolution. 32 tries keeps the publish-window coverage while
bounding the worst case to a few hundred milliseconds.
Two gaps in the existence probe that decides between EADDRINUSE and
EACCES:

- WaitNamedPipe rejects the \?\ pipe-name spelling with
  ERROR_BAD_PATHNAME, so binds using that form never took the EACCES
  arm. Probe through the \.\ spelling.
- A colliding server can disappear between the failed create and the
  probe, which used to misreport the (now free) name as EACCES. When
  the probe finds no live instance, retry the create once: a vanished
  collision now binds successfully, and a second denial really is the
  namespace.
Splitting "X:\name" wrote a NUL over the backslash, leaving "X:" as
the directory to search - which Win32 resolves as the drive's current
directory, not its root. If the current directory on that drive
contained an entry with the same name, the fallback silently returned
the wrong file's stats.
The NUL-device fallback created both pipe ends inheritable and closed
the parent-side end immediately after. CreateProcess inherits every
inheritable handle in the process, so a spawn racing on another thread
could inherit the doomed end, keeping the pipe alive: reads in the
child with the ignored slot would block instead of seeing EOF, and
writes would fill the pipe buffer instead of failing fast. Create both
ends non-inheritable and mark only the kept end.
ERROR_PIPE_BUSY can also be forced by a squatter (a pipe created with
spare instance capacity), so walking predictably off the current name
let an adversary steer the retry sequence; draw a fresh random name
instead, as the access-denied path already does.

In the bind probe, read each character of the name only after the
previous one proved non-NUL, so single-character pipe names cannot
read past the terminator, and note the deliberate limits of the
disambiguation (a probe denied by a stricter sandbox, or a server
vanishing twice, keeps the conservative verdict).
The drive-root fix only covered the bare "X:\name" spelling. Extend
it to "\?\X:\name" and "\.\X:\name" (truncating those left
"\?\X:", which opens the volume device) and to rooted "\name"
(truncating left an empty string).
Delivering zero bytes for an allocation that cannot hold one converted
character made such reads silently consume console input forever.
Surface ENOBUFS to the read callback instead. Also size the test
buffer exactly to the allocation so a regression to the old one-byte
overflow is out-of-bounds under ASAN.
The mechanism was stated backwards: a squatter with spare instance
capacity forces ERROR_ACCESS_DENIED on a FILE_FLAG_FIRST_PIPE_INSTANCE
create; ERROR_PIPE_BUSY is what a pipe at full instance capacity
produces.
@dylan-conway
dylan-conway force-pushed the dylan/win-appcontainer branch from 6d1dc15 to a8a4150 Compare June 30, 2026 20:23
Scope the console-read-slot comment to the supported single-reader
case and move pipe_emfile out of the tty block in the test list.
@dylan-conway
dylan-conway force-pushed the dylan/win-appcontainer branch from a8a4150 to 88e612c Compare June 30, 2026 21:58
…s a pipe

The UV_IGNORE fallback substituted an anonymous pipe for any
CreateFileW("NUL") failure and stamped the slot FOPEN|FDEV either way.
A CRT child then treated the dead pipe as a character device:
_isatty() is FDEV-driven, so python enabled line buffering and aborted
at its first print with EINVAL (ERROR_NO_DATA is absent from the CRT
errno table) instead of running to completion.

Only ERROR_ACCESS_DENIED takes the fallback now - any other NUL failure
stays a loud spawn error - and uv__create_nul_handle reports the
substitution so the caller stamps FOPEN|FPIPE for the pipe.
Publish the reader's duplicated thread handle (or a failed-duplication
sentinel) before flipping the read status to IN_PROGRESS. Once the
canceller observes IN_PROGRESS the handle is already visible, so the
retry loop no longer waits on publication; the remaining
ERROR_NOT_FOUND window is the short stretch around the wait itself,
and SwitchToThread covers it the same way uv__pipe_interrupt_read
does. A failed duplication now withdraws immediately instead of
burning the whole retry budget, and the bound spins yields, not
timers.
The bound is also reachable when the reader is preempted past the whole
spin, not only on hosts whose console reads cannot be cancelled; the
withdraw handles both.
fs_stat_locked_root_file stats C:\pagefile.sys (held exclusively by the
memory manager, so the directory-listing fallback runs) through the
drive-absolute, \?\-prefixed, and rooted spellings, with a same-named
decoy in the cwd that the old drive-relative split used to pick up.
tty_line_read_enobufs pins UV_ENOBUFS for a line-read allocation too
small to hold one converted character; the old sizing wrote the NUL one
byte past the allocation and reported a successful 3-byte read.
Substituting a closed-end anonymous pipe when opening NUL is denied
made every ignored slot's writes fail, which well-behaved children
treat as fatal (python aborts at its shutdown flush, git takes the
simulated SIGPIPE path, gnulib-based tools exit nonzero) - trading one
loud spawn error for many confusing child failures. Restore the
upstream contract: the spawn fails with the real error. Environments
that need ignored stdio inside an AppContainer grant the device to the
container SIDs from an elevated context (the kernel resets the ACL
each boot) or pass an open NUL handle in; the embedder can also
supply its own sink.
The kernel canonicalizes the substitute name of junctions created
under a sandbox device map (e.g. inside an AppContainer) to
\??\Global\<drive>:\path, which fs__readlink_handle rejected, so
uv_fs_readlink returned EINVAL and uv_fs_lstat reported such
junctions as plain directories - dangerous for tree walkers, which
would recurse through the junction instead of unlinking it.

Accept the Global form and return its exact win32 spelling,
\?\Global\<drive>:\path, by flipping the single character that
distinguishes the NT prefix from the win32 one. No part of the
target is dropped: the Global component is semantically meaningful
(it forces the global device map, bypassing session-local drive
mappings), and the returned string opens the same object and
round-trips to an identical junction. Plain \??\<drive>: targets
keep the existing drive-lettered rendering, and everything else
still fails with ERROR_SYMLINK_NOT_SUPPORTED.

The test plants the exact reparse data the kernel produces, so it
runs without a container, and also pins the negative case: volume
GUID mount points still refuse to render.
@dylan-conway

Copy link
Copy Markdown
Member Author

Updating the record since my last comment — two of the commits described above were subsequently reworked:

NUL — the anonymous-pipe fallback was removed from this branch entirely (67bba539). Substituting a closed pipe for NUL changes what every child process observes on its std handles (writes fail instead of succeeding), which is too large a behavioral swing for the library to make silently. libuv now fails uv_spawn loudly when NUL can't be opened, matching upstream, and the embedder owns the policy: bun substitutes parent-drained pipes for ignored stdio, gated on the container plus a per-spawn NUL probe.

Junctions — restored in 9420b271 with different semantics than the earlier commit. Stripping the Global\ component was lossy: \??\Global\<drive>: forces resolution through the global device map, so under a session-local drive mapping the stripped spelling names a different object. fs__readlink_handle now accepts the form and returns its exact Win32 spelling, \\?\Global\<drive>:\path — one character changed (\??\ → \\?\), nothing dropped, the returned string opens the same object and round-trips to an identical junction. lstat classification follows from readlink, so tree-walkers stop descending through such junctions. The test plants the kernel-produced reparse data directly, so it runs without a container.

The ERROR_UNTRUSTED_MOUNT_POINT → EACCES mapping is deliberately not included for now.

@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown

Superseded: upstream's AppContainer pipe-namespace fix (libuv#5181) has been cherry-picked into the bun branch via #7. The remaining pieces (tty console read cancellation, fs error fidelity, uv_pipe_bind disambiguation) will be tracked separately; the app-container token probe is moving into bun's own Rust.

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.

2 participants