Skip to content
Merged
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
"version": "2.64.0",
"version": "2.65.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
111 changes: 109 additions & 2 deletions apps/desktop/src/main/login-shell-path.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { execFileSync } from 'node:child_process'
import { accessSync, constants as fsConstants } from 'node:fs'
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
probeNvmInstall,
resetLoginShellResolutionForTests,
resolveCommandViaLoginShell
resolveCommandViaLoginShell,
resolveLoginShellPathDirs,
setLoginShellTimeoutForTests
} from './login-shell-path'

const onPosix = process.platform !== 'win32'
Expand Down Expand Up @@ -98,6 +101,110 @@ describe('rc-file-only PATH entries (#634)', () => {
)
})

// Julie (Discord, 2026-10-09) launched ZenNotes from a terminal; opening
// Settings > CLI spawned interactive shells that took her terminal and never
// exited, and the page sat on "Checking install status..." for good.
describe('shells started from a terminal', () => {
const savedEnv: Record<string, string | undefined> = {}
let tempDir: string | null = null
const marker = (dirs: string): string => `printf '\\n__ZENNOTES_LOGIN_SHELL__%s' "${dirs}"`

afterEach(async () => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
resetLoginShellResolutionForTests()
if (tempDir) await rm(tempDir, { recursive: true, force: true })
tempDir = null
})

function overrideEnv(overrides: Record<string, string>): void {
for (const [key, value] of Object.entries(overrides)) {
if (!(key in savedEnv)) savedEnv[key] = process.env[key]
process.env[key] = value
}
}

// A killed process lingers as a zombie until its parent reaps it; that
// counts as gone.
function running(pid: number): boolean {
try {
return !execFileSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8' }).trim().startsWith('Z')
} catch {
return false
}
}

async function gone(pid: number): Promise<boolean> {
for (let i = 0; i < 40; i++) {
if (!running(pid)) return true
await new Promise((resolve) => setTimeout(resolve, 50))
}
return false
}

// Stands in for an interactive shell stuck on an rc prompt: it ignores
// SIGTERM, as interactive shells do, and never prints the marker.
it.skipIf(!onPosix)('gives up on a shell that ignores SIGTERM and leaves none of it running', async () => {
tempDir = await mkdtemp(path.join(os.tmpdir(), 'zen-stuck-shell-'))
const pids = path.join(tempDir, 'pids')
const stuck = path.join(tempDir, 'stuck-shell')
await makeExecutable(stuck, `#!/bin/sh\ntrap '' TERM\necho $$ > "${pids}"\nsleep 30 &\necho $! >> "${pids}"\nwait\n`)
overrideEnv({ SHELL: stuck, HOME: tempDir })
resetLoginShellResolutionForTests()
setLoginShellTimeoutForTests(1000)

const started = Date.now()
const dirs = await resolveLoginShellPathDirs()
// The standard shells tried after it still answer.
expect(dirs.length).toBeGreaterThan(0)
expect(Date.now() - started).toBeLessThan(4000)
const [shellPid, sleepPid] = (await readFile(pids, 'utf8')).trim().split('\n').map(Number)
expect(await gone(shellPid)).toBe(true)
expect(await gone(sleepPid)).toBe(true)
})

it.skipIf(!onPosix)('runs the shell in a session of its own, without a terminal', async () => {
tempDir = await mkdtemp(path.join(os.tmpdir(), 'zen-tty-shell-'))
const report = path.join(tempDir, 'report')
const shell = path.join(tempDir, 'tty-shell')
await makeExecutable(
shell,
`#!/bin/sh\nif (exec 3</dev/tty) 2>/dev/null; then tty=open; else tty=none; fi\necho "$tty $$ $(ps -o pgid= -p $$)" > "${report}"\n${marker('/usr/bin:/bin')}\n`
)
overrideEnv({ SHELL: shell, HOME: tempDir })
resetLoginShellResolutionForTests()

expect(await resolveLoginShellPathDirs()).toEqual(['/usr/bin', '/bin'])
const [tty, pid, group] = (await readFile(report, 'utf8')).trim().split(/\s+/)
// /dev/tty only tells the probes apart when the tests run in a terminal.
// The new session also makes the shell lead its own process group, which
// shows anywhere and is what lets a timeout kill the whole group.
expect(group).toBe(pid)
expect(tty).toBe('none')
})

it.skipIf(!onPosix)('shares one shell between callers that ask at the same time', async () => {
tempDir = await mkdtemp(path.join(os.tmpdir(), 'zen-shared-shell-'))
const runs = path.join(tempDir, 'runs')
const shell = path.join(tempDir, 'counting-shell')
await makeExecutable(shell, `#!/bin/sh\necho run >> "${runs}"\nsleep 0.3\n${marker('/usr/bin:/bin')}\n`)
overrideEnv({ SHELL: shell, HOME: tempDir })
resetLoginShellResolutionForTests()

const [dirs, sh, ls] = await Promise.all([
resolveLoginShellPathDirs(),
resolveCommandViaLoginShell('sh'),
resolveCommandViaLoginShell('ls')
])
expect(dirs).toEqual(['/usr/bin', '/bin'])
expect(sh).toBeTruthy()
expect(ls).toBeTruthy()
expect((await readFile(runs, 'utf8')).trim().split('\n')).toHaveLength(1)
})
})

describe('probeNvmInstall', () => {
const savedNvmDir = process.env.NVM_DIR
let nvmDir: string | null = null
Expand Down
97 changes: 79 additions & 18 deletions apps/desktop/src/main/login-shell-path.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { execFile } from 'node:child_process'
import { spawn } from 'node:child_process'
import { promises as fsp, constants as fsConstants } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'

const execFileAsync = promisify(execFile)

// A login shell can be slow if the user's profile is heavy; cap it so a stuck
// shell never blocks search. Resolution is memoized, so this runs rarely.
const LOGIN_SHELL_TIMEOUT_MS = 5_000
let loginShellTimeoutMs = LOGIN_SHELL_TIMEOUT_MS
const LOGIN_SHELL_MAX_OUTPUT = 1024 * 1024
// Memoize resolved locations so we don't spawn a shell on every capability
// check / search. Short enough to self-heal after a tool is installed mid-run.
const RESOLUTION_TTL_MS = 60_000
Expand All @@ -32,6 +31,9 @@ const SHELL_MODES = ['-lic', '-lc'] as const

const cache = new Map<string, { at: number; value: string | null }>()
let pathDirsCache: { at: number; value: string[] } | null = null
// One probe at a time: the CLI page asks for the PATH, node and npm at once,
// and each caller used to start its own shell while the first was running.
let pathDirsInFlight: Promise<string[]> | null = null

/**
* Resolve a command to an absolute path using the user's login shell.
Expand Down Expand Up @@ -88,15 +90,30 @@ export async function resolveLoginShellPathDirs(): Promise<string[]> {
const cached = pathDirsCache
if (cached && Date.now() - cached.at < RESOLUTION_TTL_MS) return cached.value

const value = await queryLoginShellPathDirs()
pathDirsCache = { at: Date.now(), value }
return value
if (!pathDirsInFlight) {
pathDirsInFlight = queryLoginShellPathDirs()
.then((value) => {
pathDirsCache = { at: Date.now(), value }
return value
})
.finally(() => {
pathDirsInFlight = null
})
}
return pathDirsInFlight
}

/** Resolution is memoized; tests that vary HOME/SHELL/NVM_DIR reset it between cases. */
export function resetLoginShellResolutionForTests(): void {
cache.clear()
pathDirsCache = null
pathDirsInFlight = null
loginShellTimeoutMs = LOGIN_SHELL_TIMEOUT_MS
}

/** Shortens the per-shell timeout so a test of a stuck shell does not take five seconds. */
export function setLoginShellTimeoutForTests(ms: number): void {
loginShellTimeoutMs = ms
}

async function scanLoginShellPath(command: string): Promise<string | null> {
Expand All @@ -120,17 +137,8 @@ async function queryLoginShellPathDirs(): Promise<string[]> {
await fsp.access(shellPath, fsConstants.X_OK)
// printf interprets the leading \n itself, forcing our marker onto its
// own line no matter what the rc files printed before it.
const { stdout } = await execFileAsync(
shellPath,
[mode, `printf '\\n${OUTPUT_MARKER}%s' "$PATH"`],
{
encoding: 'utf8',
timeout: LOGIN_SHELL_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
windowsHide: true
}
)
const dirs = (extractMarkedLine(String(stdout)) ?? '')
const stdout = await runLoginShell(shellPath, mode, `printf '\\n${OUTPUT_MARKER}%s' "$PATH"`)
const dirs = (extractMarkedLine(stdout) ?? '')
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
Expand All @@ -147,6 +155,59 @@ async function queryLoginShellPathDirs(): Promise<string[]> {
return []
}

/**
* Run one probe in the user's shell, cut off from any terminal.
*
* Launched from a terminal, ZenNotes owns that terminal, and an interactive
* shell started beneath it took it over: job control moved the terminal's
* foreground to the shell, and an rc file that waits on the terminal (an
* update prompt, a setup wizard, `exec tmux`) blocked there for good.
* Interactive shells ignore SIGTERM, so the timeout never ended them, the CLI
* settings page waited on them forever, and the user's terminal was left to
* the stuck shells. In a new session (`detached`) the shell has no
* controlling terminal, so `/dev/tty` fails at once instead of blocking, its
* stdin is empty, and a timeout kills its whole process group with SIGKILL.
*/
function runLoginShell(shellPath: string, mode: string, command: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(shellPath, [mode, command], {
detached: true,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true
})
let stdout = ''
let settled = false
const killGroup = (): void => {
try {
if (child.pid) process.kill(-child.pid, 'SIGKILL')
} catch {
/* the group is already gone */
}
}
const finish = (error: Error | null): void => {
if (settled) return
settled = true
clearTimeout(timer)
if (error) reject(error)
else resolve(stdout)
}
const timer = setTimeout(() => {
killGroup()
finish(new Error(`${shellPath} ${mode} did not answer within ${loginShellTimeoutMs} ms`))
}, loginShellTimeoutMs)
child.stdout?.setEncoding('utf8')
child.stdout?.on('data', (chunk: string) => {
stdout += chunk
if (stdout.length > LOGIN_SHELL_MAX_OUTPUT) {
killGroup()
finish(new Error(`${shellPath} ${mode} printed more than ${LOGIN_SHELL_MAX_OUTPUT} bytes`))
}
})
child.on('error', (error) => finish(error))
child.on('close', () => finish(null))
})
}

async function anyExistingDirectory(dirs: string[]): Promise<boolean> {
for (const dir of dirs) {
if (!path.isAbsolute(dir)) continue
Expand Down
2 changes: 1 addition & 1 deletion apps/quicklook/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/quicklook",
"private": true,
"version": "2.64.0",
"version": "2.65.0",
"type": "module",
"description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it",
"homepage": "https://zennotes.org",
Expand Down
2 changes: 1 addition & 1 deletion apps/share-viewer/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/share-viewer",
"private": true,
"version": "2.64.0",
"version": "2.65.0",
"type": "module",
"description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website",
"homepage": "https://zennotes.org",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/web",
"private": true,
"version": "2.64.0",
"version": "2.65.0",
"type": "module",
"description": "ZenNotes web client for self-hosted and hosted deployments",
"homepage": "https://zennotes.org",
Expand Down
20 changes: 10 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading