Repository navigation
TypeScript: single-instance lock for the listening socket #46
Description
Is your feature request related to a problem?
#45 adds a single-instance lock to Go and Rust: flock(LOCK_EX|LOCK_NB) on <listen-socket>.lock, held for as long as the process runs. Node has no flock. On Node 22, fs.flock is undefined, there is no fcntl locking, and O_EXLOCK doesn't exist on Linux. So TypeScript relies only on the "in use" check (it connect(2)s to the existing socket before deleting it). That leaves a race between checking and deleting:
- A connects: refused, so the socket is stale.
- B connects: refused, so the socket is stale.
- A deletes the socket and binds. A is now live.
- B deletes A's live socket and binds. A keeps running, but nothing can reach it.
This only happens when two TypeScript instances start within a few milliseconds of each other on the same path. A second instance that starts after the first is listening is still refused. The race is recorded formally as raceWithoutLockTest in spec/listener.qnt, using the listener_unlocked module. It is a documented exception to the equal-peers rule; see spec/listener-design.md, section "TypeScript exception".
Describe the solution
A small in-repo N-API addon, about 30 lines of C, exposing flock(fd, LOCK_EX|LOCK_NB), with no npm dependency. TypeScript then uses the same lock as Go and Rust: the same file path, mode, O_NOFOLLOW and error messages.
This needs a change to ts/Dockerfile, which currently runs npm ci --ignore-scripts on stagex/pallet-nodejs. Either allow install scripts for that one package, or build the addon in an explicit step on stagex/pallet-nodejs or a StageX C-toolchain stage. make verify-reproducible-ts must stay bit-for-bit reproducible.
Done when:
- the
listener_lockedQuint configuration covers TypeScript as well; - the skipped test
openListener concurrentints/src/listen.test.tsis un-skipped and passes (8 instances started together, repeated 50 times, and exactly one ends up serving); - TypeScript passes the same lock unit tests as Go and Rust (symlink refused, unreadable lock file, released on
SIGKILL).
Describe alternatives
- An
fs-ext-style npm addon: rejected. It's an external dependency built by install scripts. - Calling
flock(1)from a shell: rejected. util-linux isn't in the runtime image. - An abstract-namespace lock socket: rejected. It's Linux-only and scoped to a network namespace, so two containers sharing a socket volume wouldn't see each other's lock.
- A lock-free
link(2)/rename(2)replace-and-check-inode protocol: rejected. It can still orphan a socket when three instances start together, and a live socket briefly disappears while it's being put back.
Which implementation(s) would this affect?
- Go
- Rust
- TypeScript
- Quint specification
- All
Additional context
Follow-up to #45.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
abienkowski commentedon Sep 30, 2026
Notes from the #45 review that affect this issue's scope:
openListenerreturns the lock handle, and the caller keeps it for as long as the process runs. TypeScript'sopenListener(server, path, gid)returnsvoidtoday. So the lock must either be tied toserver's lifetime (released onclose) or be returned fromopenListener. The skipped testopenListener concurrent (#46)ints/src/listen.test.tswill need its body updated to match; removing theskipalone won't be enough.listenOnSocketsaves and restores the process-wide umask around bind. If two calls overlap in one process, as they do in the concurrency test, they can restore in the wrong order and leave the umask at0177. Production calls it only once, so only the test is affected. Acquiring the lock before taking the umask would keep bind attempts from overlapping.skipremoved today, the losers fail withEADDRINUSErather than the "in use" message. That confirms the race the test is meant to catch.feat!: dockerd-parity listening socket, Unix path only (#47)