Skip to content

TypeScript: single-instance lock for the listening socket #46

Open

Description

@abienkowski

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:

  1. A connects: refused, so the socket is stale.
  2. B connects: refused, so the socket is stale.
  3. A deletes the socket and binds. A is now live.
  4. 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_locked Quint configuration covers TypeScript as well;
  • the skipped test openListener concurrent in ts/src/listen.test.ts is 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.

Activity

  1. added
    Type: EnhancementAdded to issues and PRs when a change includes improvements or optimizations.
    on Sep 29, 2026
  2. abienkowski commented on Sep 30, 2026

    @abienkowski
    CollaboratorAuthor

    Notes from the #45 review that affect this issue's scope:

    • Lock lifetime: Go's openListener returns the lock handle, and the caller keeps it for as long as the process runs. TypeScript's openListener(server, path, gid) returns void today. So the lock must either be tied to server's lifetime (released on close) or be returned from openListener. The skipped test openListener concurrent (#46) in ts/src/listen.test.ts will need its body updated to match; removing the skip alone won't be enough.
    • umask with overlapping calls: listenOnSocket saves 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 at 0177. Production calls it only once, so only the test is affected. Acquiring the lock before taking the umask would keep bind attempts from overlapping.
    • Current failure mode: with the skip removed today, the losers fail with EADDRINUSE rather than the "in use" message. That confirms the race the test is meant to catch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type: EnhancementAdded to issues and PRs when a change includes improvements or optimizations.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions