Module: ConvertSdk::ForkGuard

Defined in:
lib/convert_sdk/fork_guard.rb

Overview

The SDK's single fork-detection authority and the ONLY Process._fork prepend in the gem — the SDK's only global mutation (NFR15, architecture Decision 6). It follows the Rails ForkTracker pattern: a module prepended onto Process.singleton_class whose _fork wraps super and, when it returns 0 (the child), runs the re-arm path. The prepend is installed once at SDK load (it must exist before any fork; installing it is cheap and thread-free, so it does not violate the NFR4 zero-threads-until-use rule — that rule concerns THREADS, not this hook).

Fork detection elsewhere uses ForkGuard.forked? — a free integer comparison (+Process.pid != owner_pid+, the Datadog idiom) safe to call on every boundary, including JRuby (where it is always false).

On JRuby (no fork, no Process._fork) the prepend is a no-op by construction: ForkGuard.install! skips it, so ForkGuard.forked? stays false forever.

Consumers register their thread-owning timers via ForkGuard.register_timer and any child-side cleanup (e.g. ApiManager's queue-ownership clear in Story 4.2) via ForkGuard.register_child_callback, keeping ForkGuard decoupled from its callers. ForkGuard.rearm! is the shared re-arm path (also invoked by Client#postfork in Epic 4): it marks every registered timer dead, then fires every registered child-callback in registration order, then resets owner_pid.

The child hook path is LOCK-MINIMAL — mutexes held by other threads at the moment of fork are a classic deadlock source. It resets owner_pid first, then iterates a SNAPSHOT of the timer registry and a SNAPSHOT of the child-callback registry taken under the registry mutex.

Defined Under Namespace

Modules: ForkHook

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.loggerConvertSdk::LogManager?

Module-level logger, settable at wiring time (Client wires it in 2.7). nil-safe before wiring — the hook never assumes a logger is present.

Returns:



51
52
53
# File 'lib/convert_sdk/fork_guard.rb', line 51

def logger
  @logger
end

.owner_pidInteger (readonly)

Returns the pid that currently owns the SDK's threads.

Returns:

  • (Integer)

    the pid that currently owns the SDK's threads.



46
47
48
# File 'lib/convert_sdk/fork_guard.rb', line 46

def owner_pid
  @owner_pid
end

Class Method Details

.forked?Boolean

Returns true iff the current process differs from the owner (i.e. we are in a forked child). A free comparison; false on JRuby.

Returns:

  • (Boolean)

    true iff the current process differs from the owner (i.e. we are in a forked child). A free comparison; false on JRuby.



68
69
70
# File 'lib/convert_sdk/fork_guard.rb', line 68

def forked?
  Process.pid != @owner_pid
end

.install!void

This method returns an undefined value.

Install the Process._fork prepend. Idempotent (double-install guarded) and a no-op when fork is unsupported (JRuby) — the prepend never lands, so the hook is a no-op by construction. Safe to call repeatedly.



57
58
59
60
61
62
63
64
# File 'lib/convert_sdk/fork_guard.rb', line 57

def install!
  return unless Process.respond_to?(:_fork) && Process.respond_to?(:fork)
  return if @installed

  Process.singleton_class.prepend(ForkHook)
  @installed = true
  @owner_pid = Process.pid
end

.rearm!void

This method returns an undefined value.

The shared re-arm path: reset owner_pid, mark every registered timer dead, then fire every child-callback in registration order. Lock-minimal: owner_pid is reset first, then SNAPSHOTS of the registries are iterated outside the registry mutex (deadlock-safe in the fork hook).



92
93
94
95
96
97
98
# File 'lib/convert_sdk/fork_guard.rb', line 92

def rearm!
  @owner_pid = Process.pid
  timers, callbacks = @registry_mutex.synchronize { [@timers.dup, @child_callbacks.dup] }
  @logger&.debug("ForkGuard#rearm!: fork detected, re-arming #{timers.size} timer(s) in pid #{Process.pid}")
  timers.each(&:mark_dead)
  callbacks.each(&:call)
end

.register_child_callback(callable) ⇒ void

This method returns an undefined value.

Register a child-side callback fired after timers are marked dead (e.g. queue-ownership clear). Keeps ForkGuard decoupled from its callers.

Parameters:

  • callable (#call)


83
84
85
# File 'lib/convert_sdk/fork_guard.rb', line 83

def register_child_callback(callable)
  @registry_mutex.synchronize { @child_callbacks << callable }
end

.register_timer(timer) ⇒ void

This method returns an undefined value.

Register a thread-owning timer to be marked dead in a forked child.

Parameters:

  • timer (#mark_dead)


75
76
77
# File 'lib/convert_sdk/fork_guard.rb', line 75

def register_timer(timer)
  @registry_mutex.synchronize { @timers << timer }
end

.reset_for_tests!void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Test-only reset so the singleton-state module is order-independent under RSpec. Clears registries, resets owner_pid, drops the logger. Does NOT uninstall the prepend (it is harmless and global).



121
122
123
124
125
126
127
128
# File 'lib/convert_sdk/fork_guard.rb', line 121

def reset_for_tests!
  @registry_mutex.synchronize do
    @timers = []
    @child_callbacks = []
  end
  @owner_pid = Process.pid
  @logger = nil
end

.stop_all_timers!void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Test-only reap that STOPS (signals exit + joins) every registered timer so NO BackgroundTimer thread can survive into the next example. Distinct from reset_for_tests!, which only clears the registry (it leaves any live thread running). A leaked flush/refresh timer thread firing a real POST/GET after its example ends pollutes a later example's zero-HTTP assertion under WebMock (intermittent on JRuby's thread scheduling) — a global after(:each) reap closes that window deterministically. Iterates a SNAPSHOT taken under the registry mutex; #stop is idempotent so this is a cheap no-op for already-stopped timers.



111
112
113
114
# File 'lib/convert_sdk/fork_guard.rb', line 111

def stop_all_timers!
  timers = @registry_mutex.synchronize { @timers.dup }
  timers.each(&:stop)
end