Skip to content

wasip1: LSP over node:wasi hangs after initialize — signal watcher starves the scheduler (fix included) #4706

Description

@calvinrp

Split out from #4633. tsgo --lsp --stdio built for wasip1 and run under Node's node:wasi answers initialize, then ignores everything after it, spinning at 100% CPU. CLI invocations (tsgo -p) are unaffected — output is identical to the native binary on node >= 23.

Cause: cmd/tsgo calls signal.NotifyContext unconditionally. On wasip1 signals are never delivered, and Go's signal-watcher goroutine busy-spins (a sched_yield loop — we measured ~190k calls in 8s of idle). Wasm is single-threaded, so that always-runnable goroutine keeps the scheduler from ever reaching poll_oneoff — and the stdin reader, parked waiting for input, never wakes.

Fix: skip the signal watcher on wasip1 — since no signal can arrive, context.WithCancel is equivalent:

func notifyContext(parent context.Context, sigs ...os.Signal) (context.Context, context.CancelFunc) {
	if runtime.GOOS == "wasip1" {
		return context.WithCancel(parent)
	}
	return signal.NotifyContext(parent, sigs...)
}

Patch applies at typescript/v7.0.2. With it, a full LSP session under node:wasi works — initialize, publishDiagnostics, hover, shutdown — with exit behavior matching native. It also lets the runtime idle in poll_oneoff instead of burning a core.

One note: this makes LSP-over-wasm node-only for now — wasmtime 46 refuses the non-blocking stdio call (fd_fdstat_set_flags) the LSP path needs, so it still freezes there even with the fix.

Minimal repro driver:

import { readFile } from 'node:fs/promises';
import { WASI } from 'node:wasi';
const wasi = new WASI({ version: 'preview1', args: ['tsgo', '--lsp', '--stdio'],
  env: { PWD: '/' }, preopens: { '/': process.cwd() }, returnOnExit: true });
const wasm = await WebAssembly.compile(await readFile('tsgo.wasm'));
const inst = await WebAssembly.instantiate(wasm, wasi.getImportObject());
wasi.start(inst);

Spawn it with piped stdio, send a framed initialize (answered), then anything else: the stock binary never reads it; the patched one proceeds.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions