You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
dsreplication enable hangs initializing registration information when adding a third replica; CI Test replication step has no timeout #861
The CI step Test replication can hang indefinitely while adding a third replica to the topology: dsreplication enable stalls on "Initializing registration information on server localhost:34444 with the contents of server localhost:4444" and never completes. Since neither the job nor the step has a timeout configured, the job sits idle until GitHub Actions kills it at the default 6-hour limit (##[error]The operation was canceled.).
The step started at 17:31:38, produced its last log line at 17:33:22, and was killed at 21:26:52 (job start + 6h).
The same step passed in ~3.5–4 minutes in all 8 sibling matrix jobs of the same run (ubuntu 11/17/21/26, macOS, Windows), so this is a flaky race, not a Java 25 regression, and it is unrelated to the PR being built (Windows MSI changes).
Log tail
17:33:22 Updating replication configuration for baseDN cn=schema on server
17:33:22 localhost:34444 .....Done.
17:33:22 Initializing registration information on server localhost:34444 with the
21:26:52 ##[error]The operation was canceled.
21:26:52 Post job cleanup.
...
21:26:52 Terminate orphan process: pid (116117) (java)
21:26:52 Terminate orphan process: pid (116766) (java)
21:26:52 Terminate orphan process: pid (117971) (java)
21:26:52 Terminate orphan process: pid (118114) (dsreplication)
21:26:52 Terminate orphan process: pid (118116) (_server-script.sh)
21:26:52 Terminate orphan process: pid (118182) (java)
All three servers plus the dsreplication CLI were still alive as orphan processes, i.e. the CLI was genuinely blocked waiting for the ADS registration data (cn=admin data) total update from server 1 (port 4444) to server 3 (port 34444) to signal completion — which never happened.
Analysis
The hang occurs inside dsreplication enable for the third server (host1 = 24444, host2 = 34444): after updating the replication configuration for dc=example,dc=com and cn=schema on all three servers, the tool initializes the registration information on the new server with the contents of an existing topology member. This total-update session appears to have a race where the initialization-completed signal can be missed, leaving the CLI waiting forever.
Suggested actions
Add timeout-minutes to the Test replication step so a hang fails fast instead of consuming a runner for 6 hours (the step normally completes in under 5 minutes).
Investigate the race in the ADS/registration-data total update during dsreplication enable when joining an additional replica to an existing topology.
CLI (ReplicationCliMain.initializeSuffix) creates a ds-task-initialize-from-remote-replica task on the destination server and polls its ds-task-state every 500 ms with no timeout (Installer.initializeSuffix, the while (!isOver) loop).
The task on the destination (InitializeTask.runTask) calls initializeFromRemote and then waits in while (initState == RUNNING) wait(1000) — also without a timeout. The final state is only set once InitializeTargetMsg + entries + DoneMsg have been processed, or once an ErrorMsg is accepted.
ReplicationDomain.initializeFromRemote publishes an InitializeRequestMsg to the broker and returns immediately. From then on the fate of the task depends entirely on a response arriving.
The window "request published → InitializeTargetMsg not yet received" has no protection at all, and there are two real holes in it. (The import phase itself is protected: broker disconnection and exporter disappearance are detected in receiveEntryBytes — which is why the hang always happens at the start, never mid-transfer.)
Hole 1: the request can be lost silently
ReplicationBroker.publish() (the void variant) silently returns false when connectionError is set or when connectRequiresRecovery is true after a reconnect, and initializeFromRemoteignores the return value. The "resend procedure of the connect phase" that the comment relies upon only replays UpdateMsgs — an InitializeRequestMsg is simply gone. The re-send in ReplicationDomain.initialize() (attemptCnt < 2) only fires after a failed import, i.e. after the request has already reached the source.
The CI timing sits exactly in this window: at 17:33:22 dsreplication had just re-configured the cn=admin data domain on all three servers ("Updating registration configuration … Done") and created the task in the same instant — the domain brokers are restarting their RS sessions at that very moment, and connectRequiresRecovery silently swallows non-recovery messages after a reconnect.
Hole 2: a legitimate ErrorMsg is discarded by a millisecond-granularity filter
Even when the topology does answer with an error (RS finds no route → replyWithUnreachablePeerMsg; the source refuses the export → the ErrorMsg added by #841 — that fix was in the failing build, verified by commit ancestry), the requester only accepts it when errorMsg.getCreationTime() > ieCtx.startTime (two call sites: the listener loop and receiveEntryBytes). ErrorMsg carries no correlation id, so this timestamp comparison is the only staleness filter. In CI all three servers run on the same host: the full round-trip "request → rejection → ErrorMsg" easily fits inside one millisecond, the strict > fails, the error is logged as "relates to a previous attempt" — and the task waits forever.
Observed consequence
The task stays RUNNING forever, the CLI polls it every 500 ms forever, and the job dies only at the GitHub Actions 6-hour default limit (mitigated by #862). The orphan-process list of the cancelled job (3 × java + dsreplication + _server-script.sh) matches this state exactly.
Planned fixes
Do not lose the request: in initializeFromRemote, use the result-returning publish(msg, retryOnFailure) variant and fail the task immediately when the request could not be sent — the CLI already has a 5-attempt retry loop for exactly this kind of failure.
Start watchdog: if neither InitializeTargetMsg nor ErrorMsg arrives within a bounded time after the request was published, fail the task instead of waiting forever, turning the infinite hang into a retryable error.
ErrorMsg gate: accept errors created in the same millisecond as the context (>= instead of >).
Addendum: a fourth bug uncovered while writing regression tests
The regression tests for the two holes above kept failing in an unexpected way — the initialize task completed successfully instead of failing — which exposed one more, and arguably the worst, bug in this code path:
// ReplicationDomain.ImportExportContextpublicvoidsetExceptionIfNoneSet(DirectoryExceptionexception)
{
if (exception == null) // checks the ARGUMENT instead of the field
{
this.exception = exception; // i.e. only ever assigns null
}
}
setExceptionIfNoneSet never records anything: the null check was written against the argument instead of the field. The bug is inherited from upstream — the same code is present in the "Recreate OpenDJ 3 development branch" commit (2014).
Consequences, since every error path of the total update funnels through this method:
processErrorMsg — an ErrorMsg that does pass the timestamp gate sets no exception, so updateTaskCompletionState(ieCtx.getException()) is called with null and the task ends COMPLETED_SUCCESSFULLY even though nothing was initialized.
LDAPReplicationDomain.importBackend — a failed LDIF import records no error: the generation id is not recomputed despite partial data in the backend, importBackend does not throw, and the initialize task again reports success.
receiveEntryBytes — losing the RS connection or receiving out-of-order entry messages mid-import is likewise swallowed.
In other words: whenever a total update failed with a properly delivered error, dsreplication initialize (and the ADS initialization inside dsreplication enable) reported success over partially imported data.
The fix is a one-liner (if (this.exception == null)), included in the fix PR together with the request-loss, timestamp-gate and watchdog changes; the new ReplicationDomainTest regression tests cover the error-propagation end to end.
Activity
vharseko commentedon Aug 12, 2026
Root-cause analysis
How this phase works
ReplicationCliMain.initializeSuffix) creates ads-task-initialize-from-remote-replicatask on the destination server and polls itsds-task-stateevery 500 ms with no timeout (Installer.initializeSuffix, thewhile (!isOver)loop).InitializeTask.runTask) callsinitializeFromRemoteand then waits inwhile (initState == RUNNING) wait(1000)— also without a timeout. The final state is only set onceInitializeTargetMsg+ entries +DoneMsghave been processed, or once anErrorMsgis accepted.ReplicationDomain.initializeFromRemotepublishes anInitializeRequestMsgto the broker and returns immediately. From then on the fate of the task depends entirely on a response arriving.The window "request published →
InitializeTargetMsgnot yet received" has no protection at all, and there are two real holes in it. (The import phase itself is protected: broker disconnection and exporter disappearance are detected inreceiveEntryBytes— which is why the hang always happens at the start, never mid-transfer.)Hole 1: the request can be lost silently
ReplicationBroker.publish()(thevoidvariant) silently returnsfalsewhenconnectionErroris set or whenconnectRequiresRecoveryis true after a reconnect, andinitializeFromRemoteignores the return value. The "resend procedure of the connect phase" that the comment relies upon only replaysUpdateMsgs — anInitializeRequestMsgis simply gone. The re-send inReplicationDomain.initialize()(attemptCnt < 2) only fires after a failed import, i.e. after the request has already reached the source.The CI timing sits exactly in this window: at 17:33:22
dsreplicationhad just re-configured thecn=admin datadomain on all three servers ("Updating registration configuration … Done") and created the task in the same instant — the domain brokers are restarting their RS sessions at that very moment, andconnectRequiresRecoverysilently swallows non-recovery messages after a reconnect.Hole 2: a legitimate ErrorMsg is discarded by a millisecond-granularity filter
Even when the topology does answer with an error (RS finds no route →
replyWithUnreachablePeerMsg; the source refuses the export → theErrorMsgadded by #841 — that fix was in the failing build, verified by commit ancestry), the requester only accepts it whenerrorMsg.getCreationTime() > ieCtx.startTime(two call sites: the listener loop andreceiveEntryBytes).ErrorMsgcarries no correlation id, so this timestamp comparison is the only staleness filter. In CI all three servers run on the same host: the full round-trip "request → rejection → ErrorMsg" easily fits inside one millisecond, the strict>fails, the error is logged as "relates to a previous attempt" — and the task waits forever.Observed consequence
The task stays RUNNING forever, the CLI polls it every 500 ms forever, and the job dies only at the GitHub Actions 6-hour default limit (mitigated by #862). The orphan-process list of the cancelled job (3 ×
java+dsreplication+_server-script.sh) matches this state exactly.Planned fixes
initializeFromRemote, use the result-returningpublish(msg, retryOnFailure)variant and fail the task immediately when the request could not be sent — the CLI already has a 5-attempt retry loop for exactly this kind of failure.InitializeTargetMsgnorErrorMsgarrives within a bounded time after the request was published, fail the task instead of waiting forever, turning the infinite hang into a retryable error.>=instead of>).vharseko commentedon Aug 12, 2026
Addendum: a fourth bug uncovered while writing regression tests
The regression tests for the two holes above kept failing in an unexpected way — the initialize task completed successfully instead of failing — which exposed one more, and arguably the worst, bug in this code path:
setExceptionIfNoneSetnever records anything: the null check was written against the argument instead of the field. The bug is inherited from upstream — the same code is present in the "Recreate OpenDJ 3 development branch" commit (2014).Consequences, since every error path of the total update funnels through this method:
processErrorMsg— an ErrorMsg that does pass the timestamp gate sets no exception, soupdateTaskCompletionState(ieCtx.getException())is called withnulland the task endsCOMPLETED_SUCCESSFULLYeven though nothing was initialized.LDAPReplicationDomain.importBackend— a failed LDIF import records no error: the generation id is not recomputed despite partial data in the backend,importBackenddoes not throw, and the initialize task again reports success.receiveEntryBytes— losing the RS connection or receiving out-of-order entry messages mid-import is likewise swallowed.In other words: whenever a total update failed with a properly delivered error,
dsreplication initialize(and the ADS initialization insidedsreplication enable) reported success over partially imported data.The fix is a one-liner (
if (this.exception == null)), included in the fix PR together with the request-loss, timestamp-gate and watchdog changes; the newReplicationDomainTestregression tests cover the error-propagation end to end.[#861] Fail fast when the CI Test replication step hangs (#862)
[OpenIdentityPlatform#861] Keep the import/export context non-null th…
[#861] Fail fast when a total update request gets no answer (#864)