Module: Tina4::PortTakeover

Defined in:
lib/tina4/port_takeover.rb

Overview

Identity-checked port takeover, shared by the CLI and the runtime paths.

tina4 serve reclaims a busy port so the edit-restart loop does not fail with "address already in use". The convenience has a sharp edge: "whatever is listening" is not always the old Tina4 server, and before this module BOTH takeover paths (the CLI #kill_process_on_port and the runtime bind-failure WebServer#free_port) SIGTERM'd whatever held the port, with NO check that the victim was a Tina4 dev server -- a foreign holder (another dev server, a database, a stray listener) was killed.

This is the ONE takeover implementation both paths call (TAKEOVER-DEC-02), so the runtime path can never again be a weaker twin of the CLI path. It adds:

  • Identity (TAKEOVER-DEC-01): a Tina4 dev server writes a per-port PID file (data/.tina4-serve-<port>.pid) when it binds and removes it on clean exit. Takeover only signals a holder whose PID matches that file; a holder with no matching Tina4 PID file is REFUSED, never killed.
  • Dev gate + opt-out (TAKEOVER-DEC-03): takeover runs only in dev (TINA4_DEBUG truthy) and only when not opted out (TINA4_NO_TAKEOVER / tina4 serve --no-kill). A production bind never kills a port holder.
  • The existing PID safety filter and container guard, unchanged, on top.

Refusing is always safe (the developer frees the port by hand); over-killing was the bug this fixes.

Defined Under Namespace

Classes: Result

Constant Summary collapse

NOTHING =
"nothing"
KILLED =
"killed"
REFUSED_FOREIGN =
"refused_foreign"
REFUSED_OPTOUT =
"refused_optout"
REFUSED_PROD =
"refused_prod"
SKIPPED_CONTAINER =
"skipped_container"
REFUSALS =
[REFUSED_FOREIGN, REFUSED_OPTOUT, REFUSED_PROD].freeze

Class Method Summary collapse

Class Method Details

.dev?Boolean

Dev mode = TINA4_DEBUG truthy. Takeover runs only in dev.

Returns:

  • (Boolean)


55
56
57
# File 'lib/tina4/port_takeover.rb', line 55

def dev?
  truthy?(ENV["TINA4_DEBUG"])
end

.in_container?Boolean

True when this process is running inside a container. Reclaiming a port makes sense on a dev machine; inside a container the server IS the container, so there is no stale sibling to reclaim from.

Returns:

  • (Boolean)


67
68
69
70
71
72
73
74
# File 'lib/tina4/port_takeover.rb', line 67

def in_container?
  return true if File.exist?("/.dockerenv") || File.exist?("/run/.containerenv")

  blob = File.read("/proc/1/cgroup")
  blob.include?("docker") || blob.include?("containerd") || blob.include?("kubepods")
rescue SystemCallError
  false
end

.no_takeover_opted_out?Boolean

True when takeover is disabled via TINA4_NO_TAKEOVER.

Returns:

  • (Boolean)


60
61
62
# File 'lib/tina4/port_takeover.rb', line 60

def no_takeover_opted_out?
  truthy?(ENV["TINA4_NO_TAKEOVER"])
end

.pidfile_path(port, base_dir = nil) ⇒ Object



102
103
104
# File 'lib/tina4/port_takeover.rb', line 102

def pidfile_path(port, base_dir = nil)
  File.join(runtime_dir(base_dir), ".tina4-serve-#{port}.pid")
end

.port_holders(port) ⇒ Object

Raw lsof/netstat PID tokens for whatever holds port.



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/tina4/port_takeover.rb', line 132

def port_holders(port)
  if RUBY_PLATFORM =~ /mswin|mingw|cygwin/
    tokens = []
    `netstat -ano 2>&1`.each_line do |line|
      next unless line.include?(":#{port}") &&
                  (line.include?("LISTENING") || line.include?("ESTABLISHED"))

      candidate = line.strip.split(/\s+/).last
      tokens << candidate if candidate&.match?(/\A\d+\z/)
    end
    tokens
  else
    `lsof -ti :#{port} 2>/dev/null`.split
  end
rescue StandardError
  []
end

.read_pidfile(port, base_dir = nil) ⇒ Object

The PID a Tina4 dev server recorded for port, or nil if none/garbage.



117
118
119
120
121
122
# File 'lib/tina4/port_takeover.rb', line 117

def read_pidfile(port, base_dir = nil)
  token = File.read(pidfile_path(port, base_dir)).strip
  token.match?(/\A\d+\z/) ? token.to_i : nil
rescue SystemCallError
  nil
end

.remove_pidfile(port, base_dir = nil) ⇒ Object

Drop the PID file for port (clean shutdown, or after reclaiming it).



125
126
127
128
129
# File 'lib/tina4/port_takeover.rb', line 125

def remove_pidfile(port, base_dir = nil)
  File.delete(pidfile_path(port, base_dir))
rescue SystemCallError
  nil
end

.runtime_dir(base_dir = nil) ⇒ Object



98
99
100
# File 'lib/tina4/port_takeover.rb', line 98

def runtime_dir(base_dir = nil)
  base_dir || File.join(Dir.pwd, "data")
end

.selectable_pids(lsof_output, me, my_group = nil) ⇒ Object

The PIDs from lsof -ti output that are safe to signal.

Pure so the safety rule can be tested directly. A non-numeric field becomes 0 under to_i, and signalling PID 0 hits EVERY process in the caller's own process group -- the server kills itself. Accept only all-digit tokens; never PID 0 (our group), PID 1 (init), ourselves, or our own process group. This is the PID-SAFETY gate only; whether a survivor is a Tina4 server is the SEPARATE identity check in #take_over_port.



84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/tina4/port_takeover.rb', line 84

def selectable_pids(lsof_output, me, my_group = nil)
  pids = []
  lsof_output.split(/\s+/).each do |token|
    next unless token.match?(/\A\d+\z/) # never coerce junk into a PID

    pid = token.to_i
    next if pid <= 1 || pid == me # 0 = our group, 1 = init, me = suicide
    next if !my_group.nil? && pid == my_group

    pids << pid unless pids.include?(pid)
  end
  pids
end

.take_over_port(port, dev:, no_takeover:, base_dir: nil, grace: 0.5) ⇒ Object

Reclaim port ONLY from an identity-confirmed Tina4 dev server. The single guarded path for both the CLI (tina4 serve) and the runtime bind-failure fallback. dev/no_takeover are passed in so this stays pure and directly testable; callers resolve them from #dev? / #no_takeover_opted_out?.



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/tina4/port_takeover.rb', line 154

def take_over_port(port, dev:, no_takeover:, base_dir: nil, grace: 0.5)
  if no_takeover
    return Result.new(REFUSED_OPTOUT, port, [],
                      "Port #{port} is in use and takeover is disabled " \
                      "(TINA4_NO_TAKEOVER/--no-kill) -- free it or choose another port.")
  end
  unless dev
    return Result.new(REFUSED_PROD, port, [],
                      "Port #{port} is in use; takeover is disabled outside dev mode " \
                      "-- free it or choose another port.")
  end
  return Result.new(SKIPPED_CONTAINER, port, [], "") if in_container?

  tokens = port_holders(port)
  return Result.new(NOTHING, port, [], "") if tokens.empty?

  me = Process.pid
  my_group = begin
    Process.getpgrp
  rescue StandardError
    nil
  end
  holders = selectable_pids(tokens.join(" "), me, my_group)
  return Result.new(NOTHING, port, [], "") if holders.empty?

  recorded = read_pidfile(port, base_dir)
  tina4_holders = recorded.nil? ? [] : holders.select { |pid| pid == recorded }
  if tina4_holders.empty?
    return Result.new(REFUSED_FOREIGN, port, [],
                      "Port #{port} is held by a non-Tina4 process " \
                      "-- free it or choose another port.")
  end

  killed = []
  tina4_holders.each do |pid|
    Process.kill("TERM", pid)
    killed << pid
  rescue Errno::ESRCH, Errno::EPERM
    # already gone or no permission
  end
  return Result.new(NOTHING, port, [], "") if killed.empty?

  remove_pidfile(port, base_dir)
  sleep(grace) if grace.positive?
  Result.new(KILLED, port, killed,
             "Reclaimed port #{port} from Tina4 dev server (PID: #{killed.join(', ')}).")
end

.truthy?(value) ⇒ Boolean

Returns:

  • (Boolean)


50
51
52
# File 'lib/tina4/port_takeover.rb', line 50

def truthy?(value)
  %w[true 1 yes on].include?(value.to_s.strip.downcase)
end

.write_pidfile(port, base_dir = nil, pid = nil) ⇒ Object

Record THIS process as the Tina4 dev server on port (best-effort).



107
108
109
110
111
112
113
114
# File 'lib/tina4/port_takeover.rb', line 107

def write_pidfile(port, base_dir = nil, pid = nil)
  dir = runtime_dir(base_dir)
  require "fileutils"
  FileUtils.mkdir_p(dir)
  File.write(pidfile_path(port, base_dir), (pid || Process.pid).to_s)
rescue SystemCallError
  nil # identity is a convenience; never let it break the server
end