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.
Split out from #4633.
tsgo --lsp --stdiobuilt for wasip1 and run under Node'snode:wasianswersinitialize, 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/tsgocallssignal.NotifyContextunconditionally. On wasip1 signals are never delivered, and Go's signal-watcher goroutine busy-spins (asched_yieldloop — we measured ~190k calls in 8s of idle). Wasm is single-threaded, so that always-runnable goroutine keeps the scheduler from ever reachingpoll_oneoff— and the stdin reader, parked waiting for input, never wakes.Fix: skip the signal watcher on wasip1 — since no signal can arrive,
context.WithCancelis equivalent: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 inpoll_oneoffinstead 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:
Spawn it with piped stdio, send a framed
initialize(answered), then anything else: the stock binary never reads it; the patched one proceeds.