fix(serve): forward the full environment to the streaming daemon

When streaming is enabled, the daemon only saw environment variable
changes for a hardcoded whitelist (PATH, POSH_* variables, VIRTUAL_ENV,
CONDA_PROMPT_MODIFIER), forwarded per-prompt by the shell integration
scripts. Anything outside that whitelist - like a variable direnv
exports - stayed pinned to whatever value existed when the daemon
started, so `{{ .Env.XXX }}` templates never picked up live changes.

The daemon now reads the shell's complete environment on every prompt
instead: each request's JSON header is unconditionally followed by a
raw "KEY=VALUE\0" record stream terminated by an empty record, which
the daemon parses with readEnvBlob before applying it via the existing
overlay/unset machinery. This wire format needs no escaping (env
values can never contain a NUL byte on any OS), fixing latent
correctness bugs in the shell-side JSON escapers it replaces (fish
silently dropped embedded newlines; zsh's control-character stripper
only handled the first stray character due to a single-substitution
bug) and is cheaper to produce than the JSON it replaces, particularly
in fish.

All four shell integrations (fish, zsh, pwsh, cmd/Clink) were updated
to send the full env this way instead of a whitelist, including at
their abort/quit call sites, which now also send the (empty) blob
every request line requires to keep the stream in sync.

Fixes #7792.
This commit is contained in:
Claude
2026-08-12 22:58:14 +02:00
committed by Jan De Dobbeleer
parent e458c6ed14
commit 4709f3e237
7 changed files with 333 additions and 132 deletions
+103 -41
View File
@@ -26,10 +26,15 @@ func init() {
RootCmd.AddCommand(serveCmd)
}
// One JSON object per line on stdin. Unknown fields are ignored by
// encoding/json by default, giving forward compatibility for free.
// One JSON object per line on stdin, immediately followed - for every
// command, not just render - by a raw "KEY=VALUE\x00" record stream
// terminated by an empty record (a bare NUL); see readEnvBlob. Unknown JSON
// fields are ignored by encoding/json by default, giving forward
// compatibility for free.
type serveRequest struct {
Env map[string]string `json:"env"`
// Env is never part of the JSON header - it comes from the raw record
// stream that follows every request line, parsed by readEnvBlob.
Env map[string]string `json:"-"`
Command string `json:"command"`
Shell string `json:"shell"`
ShellVersion string `json:"shell-version"`
@@ -112,9 +117,11 @@ func createServeCmd() *cmdtree.Command {
// primitive fish has) never EOFs the read side. Unix only - the shell owns
// the fifo's lifecycle.
//
// Clients must write each request in a single write(2) call; requests from a
// single sequential writer (one shell session) never interleave regardless
// of size.
// A request (header line plus its env blob, see readEnvBlob) may span more
// than one write(2) call - the reader is not line/buffer-size bound - but
// those calls must be consecutive with no other writer's bytes landing
// between them. A single sequential writer (one shell session, one request
// at a time) guarantees that regardless of size.
func openServeInput(pipePath string) (*os.File, error) {
if pipePath == "" {
return os.Stdin, nil
@@ -139,7 +146,8 @@ type serveActiveCycle struct {
copierDone chan struct{}
}
// runServeLoop reads newline-delimited JSON requests from in and writes
// runServeLoop reads newline-delimited JSON requests (each immediately
// followed by a raw env record blob, see readEnvBlob) from in and writes
// NUL-delimited, cycle-id-prefixed prompt records to out. It returns when it
// reads a quit command or hits EOF on stdin. The returned bool reports
// whether at least one render request was handled, so the caller knows
@@ -152,20 +160,18 @@ type serveActiveCycle struct {
// shell additionally redirects this process's stderr so anything unrecovered
// can never reach the user's terminal.
func runServeLoop(in, out *os.File) bool {
scanner := bufio.NewScanner(in)
// Env payloads (a POSH_* overlay plus PATH) can exceed the default 64 KB
// scanner buffer, so grow it up front.
scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024)
reader := bufio.NewReader(in)
var active *serveActiveCycle
renderedAtLeastOnce := false
// envKeys tracks which variables the previous request's overlay set, so
// envKeys tracks which variables the previous request's env blob set, so
// a variable that disappears from a later request (e.g. VIRTUAL_ENV after
// `deactivate`) gets unset instead of pinning its stale value for the rest
// of the daemon's life. Scoped to the loop so repeated invocations in the
// same process (tests) never inherit a previous loop's keys. The serve
// loop is single-threaded, so no locking.
// `deactivate`, or anything a client stops forwarding) gets unset instead
// of pinning its stale value for the rest of the daemon's life. Scoped to
// the loop so repeated invocations in the same process (tests) never
// inherit a previous loop's keys. The serve loop is single-threaded, so
// no locking.
envKeys := map[string]struct{}{}
stopActiveCycle := func() {
@@ -189,52 +195,108 @@ func runServeLoop(in, out *os.File) bool {
active = nil
}
for scanner.Scan() {
line := scanner.Bytes()
for {
line, err := reader.ReadBytes('\n')
eof := err != nil
line = bytes.TrimSuffix(line, []byte{'\n'})
line = bytes.TrimSuffix(line, []byte{'\r'})
// Strip a UTF-8 BOM: .NET's default UTF8 encoding writes one on the
// StreamWriter's first write, which would otherwise make the first
// request line of a session unparseable.
line = bytes.TrimPrefix(line, []byte{0xEF, 0xBB, 0xBF})
if len(line) == 0 {
if eof {
break
}
continue
}
// A well-formed client always sends the env blob right after the
// header line, for every command - even abort/quit send a bare NUL
// terminator. Reading it here, unconditionally, is what keeps the
// stream in sync regardless of the header's command or JSON validity;
// a client that skipped it on some commands would desync every
// request after the first one that did.
env, envErr := readEnvBlob(reader)
if envErr != nil {
// Truncated/closed mid-blob: nothing more can be recovered.
break
}
var req serveRequest
if err := json.Unmarshal(line, &req); err != nil {
// Malformed line: ignore for forward/backward compatibility.
continue
}
if err := json.Unmarshal(line, &req); err == nil {
req.Env = env
switch req.Command {
case serveCommandRender:
// A new render request implicitly aborts whatever is running.
stopActiveCycle()
// A nil cycle means setup panicked before prompt.New completed -
// template.Init may never have run, in which case the shutdown
// path must not call template.SaveCache (it dereferences state
// only Init sets). A started cycle implies Init completed.
if active = startRenderCycle(&req, out, envKeys); active != nil {
renderedAtLeastOnce = true
switch req.Command {
case serveCommandRender:
// A new render request implicitly aborts whatever is running.
stopActiveCycle()
// A nil cycle means setup panicked before prompt.New completed -
// template.Init may never have run, in which case the shutdown
// path must not call template.SaveCache (it dereferences state
// only Init sets). A started cycle implies Init completed.
if active = startRenderCycle(&req, out, envKeys); active != nil {
renderedAtLeastOnce = true
}
case serveCommandAbort:
stopActiveCycle()
case serveCommandQuit:
stopActiveCycle()
return renderedAtLeastOnce
default:
// Unknown command: ignore for forward compatibility.
}
case serveCommandAbort:
stopActiveCycle()
case serveCommandQuit:
stopActiveCycle()
return renderedAtLeastOnce
default:
// Unknown command: ignore for forward compatibility.
}
// Malformed JSON header: ignored for forward/backward compatibility
// (its env blob was already consumed above, keeping the stream in sync).
if eof {
break
}
}
// EOF (or a scanner error) on stdin: behave like an explicit quit so
// caches are still flushed by the caller's deferred cleanup.
// EOF (or a read error) on stdin: behave like an explicit quit so caches
// are still flushed by the caller's deferred cleanup.
stopActiveCycle()
return renderedAtLeastOnce
}
// readEnvBlob reads a "KEY=VALUE\x00" record stream from r, terminated by an
// empty record (a bare NUL byte). Every request line is unconditionally
// followed by this blob - even for commands that ignore its contents - so
// the reader never needs to know in advance whether one is coming.
//
// Environment variable values cannot contain a NUL byte on any OS this
// project targets (POSIX environ entries and the Windows environment block
// are themselves NUL-terminated/-delimited C strings), so this framing needs
// no escaping: a key/value pair is malformed only if it has no '=', in which
// case it is skipped.
func readEnvBlob(r *bufio.Reader) (map[string]string, error) {
env := map[string]string{}
for {
record, err := r.ReadBytes(0)
if err != nil {
return nil, err
}
record = record[:len(record)-1] // drop the trailing NUL delimiter
if len(record) == 0 {
return env, nil
}
key, value, found := bytes.Cut(record, []byte{'='})
if !found {
continue
}
env[string(key)] = string(value)
}
}
func applyEnvOverlay(env map[string]string, keys map[string]struct{}) {
for key := range keys {
if _, ok := env[key]; ok {
+3 -1
View File
@@ -53,7 +53,9 @@ func TestServeLoop_RequestPipe(t *testing.T) {
data, err := json.Marshal(v)
require.NoError(t, err)
_, err = f.Write(append(data, '\n'))
data = append(data, '\n', 0) // trailing 0: empty env blob, just the terminator
_, err = f.Write(data)
require.NoError(t, err)
require.NoError(t, f.Close())
}
+114 -3
View File
@@ -59,14 +59,40 @@ func startServeHarness(t *testing.T) *serveHarness {
return h
}
// send writes a single newline-terminated JSON request to the loop's stdin.
// send writes a newline-terminated JSON header to the loop's stdin, followed
// by the NUL-delimited env blob every request must carry (see readEnvBlob).
// v may carry an "env" key (map[string]string) - if present, it is pulled
// out of the JSON header and sent as the raw blob instead; a request with no
// "env" key sends an empty blob (just the terminator).
func (h *serveHarness) send(v any) {
h.t.Helper()
env := map[string]string{}
if m, ok := v.(map[string]any); ok {
if raw, ok := m["env"]; ok {
delete(m, "env")
typed, ok := raw.(map[string]string)
require.True(h.t, ok, "send: \"env\" must be a map[string]string, got %T", raw)
env = typed
}
}
data, err := json.Marshal(v)
require.NoError(h.t, err)
_, err = h.stdin.Write(append(data, '\n'))
var buf bytes.Buffer
buf.Write(data)
buf.WriteByte('\n')
for key, value := range env {
buf.WriteString(key)
buf.WriteByte('=')
buf.WriteString(value)
buf.WriteByte(0)
}
buf.WriteByte(0) // empty record: terminates the blob
_, err = h.stdin.Write(buf.Bytes())
require.NoError(h.t, err)
}
@@ -430,6 +456,91 @@ func TestServeLoop_EnvOverlayUnsetsVanishedVariables(t *testing.T) {
h.quitAndWait()
}
// TestServeLoop_EnvBlobHandlesArbitraryValues guards the reason env forwarding
// moved off JSON: a value with a literal newline, tab, quote, backslash, or
// non-ASCII byte must reach the daemon byte-exact, with no escaping logic to
// get wrong. This would corrupt or silently drop such values under JSON
// string escaping (which is exactly what the whitelist-based overlay hid,
// since it only ever carried PATH/POSH_*-shaped values).
func TestServeLoop_EnvBlobHandlesArbitraryValues(t *testing.T) {
h := startServeHarness(t)
pwd := t.TempDir()
chdirBackToWD(t)
const name = "POSH_SERVE_ENV_ARBITRARY_TEST"
t.Cleanup(func() { _ = os.Unsetenv(name) })
value := "line1\nline2\ttab\"quote\\backénd"
h.send(map[string]any{
"command": "render", "id": 1, "shell": "pwsh", "pwd": pwd,
"env": map[string]string{name: value},
})
records := h.records(500 * time.Millisecond)
require.NotEmpty(t, records)
assert.Equal(t, value, os.Getenv(name), "env value must survive the blob byte-exact, unescaped")
h.quitAndWait()
}
// TestReadEnvBlob_MalformedRecordsSkipped guards readEnvBlob's parsing rules
// directly: a record with no '=' is dropped rather than corrupting the map
// or aborting the parse, and an empty blob (just the terminator) parses to
// an empty, non-nil map.
func TestReadEnvBlob_MalformedRecordsSkipped(t *testing.T) {
blob := "NOEQUALSSIGN\x00KEY=value\x00ANOTHER=a=b=c\x00\x00"
reader := bufio.NewReader(bytes.NewBufferString(blob))
env, err := readEnvBlob(reader)
require.NoError(t, err)
assert.NotContains(t, env, "NOEQUALSSIGN", "a record with no '=' must be skipped, not stored under an empty value")
assert.Equal(t, "value", env["KEY"])
assert.Equal(t, "a=b=c", env["ANOTHER"], "only the first '=' splits key from value")
empty, err := readEnvBlob(bufio.NewReader(bytes.NewBufferString("\x00")))
require.NoError(t, err)
assert.Empty(t, empty)
}
// TestReadEnvBlob_TruncatedBlobReturnsError guards the case where the
// connection closes mid-record, before the terminating empty record ever
// arrives: readEnvBlob must report an error (so runServeLoop treats it like
// EOF and shuts down) rather than block forever or return a partial map.
func TestReadEnvBlob_TruncatedBlobReturnsError(t *testing.T) {
reader := bufio.NewReader(bytes.NewBufferString("KEY=value\x00TRUNC=no-terminator-ever"))
env, err := readEnvBlob(reader)
require.Error(t, err)
assert.Nil(t, env)
}
// TestServeLoop_MalformedHeaderStillConsumesBlobThenNextRequestRenders is
// the central desync-resilience property runServeLoop's comments claim: a
// non-empty header line that fails JSON parsing must still have its env blob
// consumed (every header is unconditionally followed by one), or the next
// request's header would be misread as more of the previous blob and the
// loop would never render again.
func TestServeLoop_MalformedHeaderStillConsumesBlobThenNextRequestRenders(t *testing.T) {
h := startServeHarness(t)
pwd := t.TempDir()
chdirBackToWD(t)
// A malformed (non-JSON) header line, followed by its own non-empty env
// blob - exactly what a well-formed client always sends, just with a
// garbled header. The header still needs its own newline terminator;
// omitting it would just make this one long header line, defeating the
// point of the test.
_, err := h.stdin.Write([]byte("not valid json\nSOME=value\x00\x00"))
require.NoError(t, err)
h.render(1, pwd)
records := h.records(500 * time.Millisecond)
require.NotEmpty(t, records, "a render after a malformed header must still produce records - the stream must not have desynced")
assert.Equal(t, "1", records[0].id)
h.quitAndWait()
}
func TestServeLoop_QuitExitsCleanly(t *testing.T) {
h := startServeHarness(t)
@@ -496,7 +607,7 @@ func TestServeLoop_UTF8BOMOnFirstLine(t *testing.T) {
require.NoError(t, err)
payload := append([]byte{0xEF, 0xBB, 0xBF}, data...)
payload = append(payload, '\n')
payload = append(payload, '\n', 0) // trailing 0: empty env blob, just the terminator
_, err = h.stdin.Write(payload)
require.NoError(t, err)
+36 -26
View File
@@ -290,33 +290,35 @@ function _omp_serve_start
return 0
end
# Joining the result with '' drops any embedded newlines - the values we send
# (paths, POSH_* variables) never legitimately contain them, and a raw newline
# would break the line-delimited request protocol.
# Escapes a JSON header string field (PWD). Joining the result with '' drops
# any embedded newlines - PWD never legitimately contains one, and a raw
# newline would break the line-delimited request protocol.
function _omp_serve_escape
string join '' -- (string replace -a -- '\\' '\\\\' "$argv" | string replace -a -- '"' '\\"' | string replace -a -- \t '\\t')
end
function _omp_serve_env_json
set --local parts
set --append parts '"PATH":"'(_omp_serve_escape (string join ':' -- $PATH))'"'
# forward every exported POSH_* variable plus the virtual-env markers;
# the daemon's environment is otherwise frozen at its start
for name in (set --names)
if string match -q 'POSH_*' -- $name; and set -qx $name
set --append parts '"'$name'":"'(_omp_serve_escape "$$name")'"'
end
# Writes the full exported environment as "KEY=VALUE\0" records, terminated
# by one extra bare NUL (an empty record). No escaping is needed - env values
# can never contain a NUL byte on any OS. Quoting the indirect expansion
# ("$$name") is load-bearing: fish variables are lists, and a bare $$name
# would re-cycle printf's format string across a multi-element value (e.g.
# PATH); quoted, fish joins it exactly as it would for export - colon-joined
# for path variables (anything ending in "PATH", plus anything set --path),
# space-joined otherwise - matching what a real child process sees.
#
# A name outside fish's identifier syntax (e.g. a bash-exported function
# leaking in as "BASH_FUNC_foo%%", or any inherited name with a hyphen)
# survives to `set --export --names` but breaks indirect expansion: $$name
# only dereferences the longest leading identifier run and pastes the rest
# back as literal text, corrupting the value. Skip those - zsh's writer
# already omits them for the same underlying reason (its parameter table
# can't hold such a name either), so this just matches that behavior.
function _omp_serve_env_raw
for name in (set --export --names)
string match -qr -- '^\w+$' $name; or continue
printf '%s=%s\0' $name "$$name"
end
for name in VIRTUAL_ENV CONDA_PROMPT_MODIFIER
if set -q $name
set --append parts '"'$name'":"'(_omp_serve_escape "$$name")'"'
end
end
echo -n '{'(string join ',' -- $parts)'}'
printf '\0'
end
function _omp_serve_request
@@ -338,7 +340,7 @@ function _omp_serve_request
set cleared false
end
set --local json '{"command":"render","id":'$_omp_serve_cycle',"shell":"fish","shell-version":"'$FISH_VERSION'","status":'$_omp_status',"pipestatus":"'"$_omp_pipestatus"'","no-status":'$_omp_no_status',"execution-time":'$exec_time',"stack-count":'(count $dirstack)',"terminal-width":'$width',"cleared":'$cleared',"pwd":"'(_omp_serve_escape $PWD)'","env":'(_omp_serve_env_json)'}'
set --local json '{"command":"render","id":'$_omp_serve_cycle',"shell":"fish","shell-version":"'$FISH_VERSION'","status":'$_omp_status',"pipestatus":"'"$_omp_pipestatus"'","no-status":'$_omp_no_status',"execution-time":'$exec_time',"stack-count":'(count $dirstack)',"terminal-width":'$width',"cleared":'$cleared',"pwd":"'(_omp_serve_escape $PWD)'"}'
# a fifo write with no reader blocks forever - only write while the
# daemon pipeline is alive (the daemon holds the fifo open read-write,
@@ -347,7 +349,13 @@ function _omp_serve_request
return 1
end
echo $json >"$_omp_serve_fifo" 2>/dev/null
# The full environment follows the header, unconditionally - see
# _omp_serve_env_raw. One fifo open for both writes keeps them from
# interleaving with another writer.
begin
echo $json
_omp_serve_env_raw
end >"$_omp_serve_fifo" 2>/dev/null
end
# poll the tempfile until it holds this cycle's primary record
@@ -405,14 +413,16 @@ function _omp_serve_render
end
function _omp_serve_abort
# Every request line - even one with no env of its own - must be followed
# by a blob; a bare NUL is an empty one (see readEnvBlob/_omp_serve_env_raw).
if test -n "$_omp_serve_fifo"; and _omp_serve_alive
echo '{"command":"abort"}' >"$_omp_serve_fifo" 2>/dev/null
printf '{"command":"abort"}\n\0' >"$_omp_serve_fifo" 2>/dev/null
end
end
function _omp_serve_quit
if test -n "$_omp_serve_fifo"; and _omp_serve_alive
echo '{"command":"quit"}' >"$_omp_serve_fifo" 2>/dev/null
printf '{"command":"quit"}\n\0' >"$_omp_serve_fifo" 2>/dev/null
end
_omp_serve_stop
if test -n "$_omp_serve_tempfile"
+25 -14
View File
@@ -184,35 +184,43 @@ local function json_escape(str)
return (str:gsub('%c', ''))
end
local function serve_env_json()
-- Returns the full exported environment as "KEY=VALUE\0" records, terminated
-- by one extra bare NUL (an empty record) - see readEnvBlob on the daemon
-- side. No escaping is needed: env values can never contain a NUL byte on
-- any OS, and os.getenv already returns each variable's real, single-string
-- value.
local function serve_env_raw()
local parts = {}
local function add(name, value)
if value then
parts[#parts + 1] = string.format('"%s":"%s"', name, json_escape(value))
parts[#parts + 1] = name .. '=' .. value .. '\0'
end
end
add('PATH', os.getenv('PATH'))
add('VIRTUAL_ENV', os.getenv('VIRTUAL_ENV'))
add('CONDA_PROMPT_MODIFIER', os.getenv('CONDA_PROMPT_MODIFIER'))
if os.getenvnames then
for _, name in ipairs(os.getenvnames()) do
if name:sub(1, 5) == 'POSH_' then
add(name, os.getenv(name))
end
add(name, os.getenv(name))
end
else
-- Older Clink without os.getenvnames can't enumerate the
-- environment; fall back to the variables oh-my-posh itself depends
-- on rather than sending nothing.
add('PATH', os.getenv('PATH'))
add('VIRTUAL_ENV', os.getenv('VIRTUAL_ENV'))
add('CONDA_PROMPT_MODIFIER', os.getenv('CONDA_PROMPT_MODIFIER'))
end
return '{' .. table.concat(parts, ',') .. '}'
parts[#parts + 1] = '\0' -- empty record: terminates the blob
return table.concat(parts)
end
local function serve_write_request()
serve.cycle = serve.cycle + 1
serve.transient = nil
-- Forwarded to the daemon through the POSH_* env overlay below.
-- Forwarded to the daemon through the full env blob below.
os.setenv('POSH_CURSOR_LINE', console.getnumlines())
local status = 0
@@ -221,18 +229,21 @@ local function serve_write_request()
end
local request = string.format(
'{"command":"render","id":%d,"shell":"cmd","status":%d,"no-status":%s,"execution-time":%d,"pwd":"%s","terminal-width":%d,"wait":true,"env":%s}\n',
'{"command":"render","id":%d,"shell":"cmd","status":%d,"no-status":%s,"execution-time":%d,"pwd":"%s","terminal-width":%d,"wait":true}\n',
serve.cycle,
status,
no_exit_code and 'true' or 'false',
last_duration or 0,
json_escape(os.getcwd() or ''),
console.getwidth() or 0,
serve_env_json()
console.getwidth() or 0
)
-- The full environment follows the header, unconditionally - see
-- serve_env_raw. Both writes go through the same pipe from the same
-- sequential writer, so they can never interleave with another request.
return (pcall(function()
assert(serve.w:write(request))
assert(serve.w:write(serve_env_raw()))
serve.w:flush()
end))
end
+26 -25
View File
@@ -429,7 +429,11 @@ New-Module -Name "oh-my-posh-core" -ScriptBlock {
# render needs to be interrupted - write abort instead of killing anything.
if ($null -ne $script:Streaming.ServeProcess -and -not $script:Streaming.ServeProcess.HasExited) {
try {
# Every request line - even one with no env of its own - must
# be followed by a blob; a bare NUL is an empty one (see
# readEnvBlob/Get-PoshServeEnvRaw).
$script:Streaming.StdIn.WriteLine('{"command":"abort"}')
$script:Streaming.StdIn.Write([char]0)
$script:Streaming.StdIn.Flush()
}
catch {
@@ -613,26 +617,19 @@ New-Module -Name "oh-my-posh-core" -ScriptBlock {
return ''
}
function Get-PoshServeEnvOverlay {
# v1 env overlay: PATH, every POSH_* variable, VIRTUAL_ENV and
# CONDA_PROMPT_MODIFIER. Deliberately not derived from config
# templates yet (see implementation plan TODO).
$overlay = [ordered]@{}
$overlay['PATH'] = $env:PATH
Get-ChildItem env:POSH_* -ErrorAction Ignore | ForEach-Object {
$overlay[$_.Name] = $_.Value
function Get-PoshServeEnvRaw {
# The full exported environment as "KEY=VALUE\0" records, terminated
# by one extra bare NUL (an empty record) - see readEnvBlob on the
# daemon side. No escaping is needed: env values can never contain a
# NUL byte on any OS, and GetEnvironmentVariables() already returns
# each variable's real, single-string value - no array-join
# subtlety like fish's list variables to worry about here.
$sb = [System.Text.StringBuilder]::new()
foreach ($entry in [Environment]::GetEnvironmentVariables().GetEnumerator()) {
[void]$sb.Append($entry.Key).Append('=').Append($entry.Value).Append([char]0)
}
if (Test-Path env:VIRTUAL_ENV) {
$overlay['VIRTUAL_ENV'] = $env:VIRTUAL_ENV
}
if (Test-Path env:CONDA_PROMPT_MODIFIER) {
$overlay['CONDA_PROMPT_MODIFIER'] = $env:CONDA_PROMPT_MODIFIER
}
return $overlay
[void]$sb.Append([char]0)
return $sb.ToString()
}
function Suspend-PoshServeOnFailure {
@@ -659,6 +656,7 @@ New-Module -Name "oh-my-posh-core" -ScriptBlock {
if ($null -ne $script:Streaming.Output -and $script:Streaming.Output.Count -ge 4096) {
try {
$script:Streaming.StdIn.WriteLine('{"command":"quit"}')
$script:Streaming.StdIn.Write([char]0)
$script:Streaming.StdIn.Flush()
}
catch {
@@ -677,11 +675,6 @@ New-Module -Name "oh-my-posh-core" -ScriptBlock {
$script:Streaming.Transient = ''
$script:Streaming.CycleStarted = $true
$envOverlay = Get-PoshServeEnvOverlay
$envJson = ($envOverlay.Keys | ForEach-Object {
'"' + $_ + '":' + (ConvertTo-PoshServeJsonString $envOverlay[$_])
}) -join ','
$json = '{' +
'"command":"render"' +
',"id":' + $script:Streaming.CycleId +
@@ -696,11 +689,16 @@ New-Module -Name "oh-my-posh-core" -ScriptBlock {
',"terminal-width":' + (Get-TerminalWidth) +
',"job-count":' + $script:JobCount +
',"cleared":false' +
',"env":{' + $envJson + '}' +
'}'
# The full environment follows the header, unconditionally - see
# Get-PoshServeEnvRaw. Both writes go through the same StdIn, so they
# can never interleave with another request.
$envRaw = Get-PoshServeEnvRaw
try {
$script:Streaming.StdIn.WriteLine($json)
$script:Streaming.StdIn.Write($envRaw)
$script:Streaming.StdIn.Flush()
}
catch {
@@ -720,6 +718,7 @@ New-Module -Name "oh-my-posh-core" -ScriptBlock {
try {
$script:Streaming.StdIn.WriteLine($json)
$script:Streaming.StdIn.Write($envRaw)
$script:Streaming.StdIn.Flush()
}
catch {
@@ -1024,6 +1023,7 @@ New-Module -Name "oh-my-posh-core" -ScriptBlock {
if ($null -ne $s.ServeProcess -and -not $s.ServeProcess.HasExited) {
try {
$s.StdIn.WriteLine('{"command":"quit"}')
$s.StdIn.Write([char]0)
$s.StdIn.Flush()
$s.StdIn.Close()
}
@@ -1299,6 +1299,7 @@ New-Module -Name "oh-my-posh-core" -ScriptBlock {
if ($null -ne $script:Streaming.ServeProcess -and -not $script:Streaming.ServeProcess.HasExited) {
try {
$script:Streaming.StdIn.WriteLine('{"command":"quit"}')
$script:Streaming.StdIn.Write([char]0)
$script:Streaming.StdIn.Flush()
$script:Streaming.StdIn.Close()
}
+26 -22
View File
@@ -208,6 +208,23 @@ function _omp_serve_escape() {
REPLY=${s//[[:cntrl:]]/}
}
# Returns via REPLY: the full exported environment as "KEY=VALUE\0" records,
# terminated by one extra bare NUL (an empty record). No escaping is needed -
# env values can never contain a NUL byte on any OS, and ${(P)name} already
# returns an exported scalar's real value (zsh ties PATH-like parameters to
# their colon-joined scalar form, unlike fish's list variables, so there is
# no array-join subtlety to handle here).
function _omp_serve_env_raw() {
local name value
REPLY=''
for name in ${(k)parameters[(I)*]}; do
[[ ${parameters[$name]} == *export* ]] || continue
value=${(P)name}
REPLY+="$name=$value"$'\0'
done
REPLY+=$'\0'
}
function _omp_serve_request() {
# A write to a dead daemon's pipe raises SIGPIPE, which kills a
# non-interactive shell outright - ignore it for the duration of this
@@ -225,24 +242,6 @@ function _omp_serve_request() {
(( _omp_serve_cycle++ ))
_omp_transient_prompt=""
local name env_json
_omp_serve_escape "$PATH"
env_json="\"PATH\":\"$REPLY\""
# Forward every exported POSH_* variable plus the virtual-env markers; the
# daemon's environment is otherwise frozen at its start.
for name in ${(k)parameters[(I)POSH_*]}; do
[[ ${parameters[$name]} == *export* ]] || continue
_omp_serve_escape "${(P)name}"
env_json+=",\"$name\":\"$REPLY\""
done
for name in VIRTUAL_ENV CONDA_PROMPT_MODIFIER; do
[[ -v $name ]] || continue
_omp_serve_escape "${(P)name}"
env_json+=",\"$name\":\"$REPLY\""
done
_omp_serve_escape "$PWD"
local json='{"command":"render"'
@@ -257,10 +256,13 @@ function _omp_serve_request() {
json+=",\"terminal-width\":${COLUMNS:-0}"
json+=",\"job-count\":$_omp_job_count"
json+=",\"pwd\":\"$REPLY\""
json+=",\"env\":{$env_json}"
json+='}'
print -r -u $_omp_serve_fd_in -- "$json" 2>/dev/null
# The full environment follows the header, unconditionally - see
# _omp_serve_env_raw. Both writes go through the same fd from the same
# sequential writer, so they can never interleave with another request.
_omp_serve_env_raw
print -rn -u $_omp_serve_fd_in -- "$json"$'\n'"$REPLY" 2>/dev/null
}
# Renders the primary prompt through the daemon. Returns nonzero on failure,
@@ -347,13 +349,15 @@ function _omp_serve_async_handler() {
function _omp_serve_abort() {
setopt localoptions localtraps
trap '' PIPE
[[ $_omp_serve_fd_in -ge 0 ]] && print -r -u $_omp_serve_fd_in -- '{"command":"abort"}' 2>/dev/null
# Every request line - even one with no env of its own - must be followed
# by a blob; a bare NUL is an empty one (see readEnvBlob/_omp_serve_env_raw).
[[ $_omp_serve_fd_in -ge 0 ]] && print -rn -u $_omp_serve_fd_in -- $'{"command":"abort"}\n\0' 2>/dev/null
}
function _omp_serve_quit() {
setopt localoptions localtraps
trap '' PIPE
[[ $_omp_serve_fd_in -ge 0 ]] && print -r -u $_omp_serve_fd_in -- '{"command":"quit"}' 2>/dev/null
[[ $_omp_serve_fd_in -ge 0 ]] && print -rn -u $_omp_serve_fd_in -- $'{"command":"quit"}\n\0' 2>/dev/null
_omp_serve_stop
}