DBusMandatoryNameHandler.java

/*
 * SPDX-FileCopyrightText: 2023-2025 Lucimber UG
 * SPDX-License-Identifier: Apache-2.0
 */
package com.lucimber.dbus.netty;

import com.lucimber.dbus.message.InboundError;
import com.lucimber.dbus.message.InboundMethodReturn;
import com.lucimber.dbus.message.OutboundMethodCall;
import com.lucimber.dbus.type.DBusObjectPath;
import com.lucimber.dbus.type.DBusString;
import com.lucimber.dbus.type.DBusType;
import com.lucimber.dbus.type.DBusUInt32;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.util.ReferenceCountUtil;
import io.netty.util.concurrent.ScheduledFuture;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * After SASL authentication completes and the DBus message pipeline is configured, this handler
 * sends the mandatory org.freedesktop.DBus.Hello method call to request a unique bus name.
 *
 * <p>It listens for the reply to this specific Hello call. On success, it stores the assigned bus
 * name as a channel attribute and fires a {@link DBusChannelEvent#MANDATORY_NAME_ACQUIRED} event.
 * On failure, it fires {@link DBusChannelEvent#MANDATORY_NAME_ACQUISITION_FAILED}.
 *
 * <p>Regardless of success or failure of the Hello call, this handler removes itself from the
 * pipeline after processing the reply or an error related to it.
 */
public final class DBusMandatoryNameHandler extends ChannelInboundHandlerAdapter {

    private static final Logger LOGGER = LoggerFactory.getLogger(DBusMandatoryNameHandler.class);

    private static final DBusString DBUS_SERVICE_NAME = DBusString.valueOf("org.freedesktop.DBus");
    private static final DBusObjectPath DBUS_OBJECT_PATH =
            DBusObjectPath.valueOf("/org/freedesktop/DBus");
    private static final DBusString DBUS_INTERFACE_NAME =
            DBusString.valueOf("org.freedesktop.DBus");
    private static final DBusString HELLO_METHOD_NAME = DBusString.valueOf("Hello");
    private static final long HELLO_TIMEOUT_SECONDS = 30; // 30 second timeout for Hello call
    private State currentState = State.IDLE;
    private DBusUInt32 helloCallSerial;
    private ScheduledFuture<?> helloTimeoutFuture;

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
        // Handle reconnection events
        if (evt == DBusChannelEvent.RECONNECTION_STARTING) {
            reset();
            ctx.fireUserEventTriggered(evt);
            return;
        }

        if (evt == DBusChannelEvent.SASL_AUTH_COMPLETE && currentState == State.IDLE) {
            LOGGER.info(
                    "[DBusMandatoryNameHandler] SASL authentication complete, sending Hello method call");

            AtomicLong serialCounter =
                    ctx.channel().attr(DBusChannelAttribute.SERIAL_COUNTER).get();
            // D-Bus serial numbers are 32-bit unsigned and allowed to wrap around
            helloCallSerial = DBusUInt32.valueOf((int) serialCounter.getAndIncrement());

            OutboundMethodCall helloCall =
                    OutboundMethodCall.Builder.create()
                            .withSerial(helloCallSerial)
                            .withPath(DBUS_OBJECT_PATH)
                            .withMember(HELLO_METHOD_NAME)
                            .withReplyExpected(true)
                            .withDestination(DBUS_SERVICE_NAME)
                            .withInterface(DBUS_INTERFACE_NAME)
                            .build();

            ctx.writeAndFlush(helloCall)
                    .addListener(
                            new WriteOperationListener<>(
                                    LOGGER,
                                    future -> {
                                        if (future.isSuccess()) {
                                            LOGGER.info(
                                                    "[DBusMandatoryNameHandler] Hello call sent successfully (serial={})",
                                                    helloCallSerial.getDelegate());
                                            currentState = State.AWAITING_HELLO_REPLY;
                                            startHelloTimeout(ctx);
                                        } else {
                                            LOGGER.error(
                                                    "[DBusMandatoryNameHandler] Failed to send Hello call (serial={}): {}",
                                                    helloCallSerial.getDelegate(),
                                                    future.cause().getMessage());
                                            ctx.pipeline()
                                                    .fireUserEventTriggered(
                                                            DBusChannelEvent
                                                                    .MANDATORY_NAME_ACQUISITION_FAILED);
                                            ctx.pipeline()
                                                    .remove(this); // Remove self on send failure
                                            ctx.close(); // Critical failure
                                        }
                                    }));
        } else {
            // Pass on other user events if not handled here
            ctx.fireUserEventTriggered(evt);
        }
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        if (currentState != State.AWAITING_HELLO_REPLY) {
            ctx.fireChannelRead(msg);
            return;
        }

        boolean handled = false;
        if (msg instanceof InboundMethodReturn methodReturn) {
            if (methodReturn.getReplySerial().equals(helloCallSerial)) {
                handled = true;
                LOGGER.info(
                        "[DBusMandatoryNameHandler] Received Hello reply (serial={})",
                        methodReturn.getSerial().getDelegate());
                handleHelloReply(ctx, methodReturn);
                ReferenceCountUtil.release(msg); // Release message after handling
            }
        } else if (msg instanceof InboundError error) {
            if (error.getReplySerial().equals(helloCallSerial)) {
                handled = true;
                LOGGER.error(
                        "[DBusMandatoryNameHandler] Received Hello error (serial={}): {}",
                        error.getSerial().getDelegate(),
                        error.getErrorName());
                handleHelloError(ctx, error);
                ReferenceCountUtil.release(msg); // Release message after handling
            }
        }

        if (!handled) {
            // Not a reply to our Hello call, pass it on.
            ctx.fireChannelRead(msg);
        }
    }

    private void handleHelloReply(ChannelHandlerContext ctx, InboundMethodReturn reply) {
        cancelHelloTimeout();

        List<DBusType> payload = reply.getPayload();

        if (!payload.isEmpty() && payload.get(0) instanceof DBusString assignedName) {
            LOGGER.info(
                    "[DBusMandatoryNameHandler] Successfully acquired bus name: {}", assignedName);
            ctx.channel().attr(DBusChannelAttribute.ASSIGNED_BUS_NAME).set(assignedName);

            // Fire event before removing self
            ctx.pipeline().fireUserEventTriggered(DBusChannelEvent.MANDATORY_NAME_ACQUIRED);

            // Remove this handler from the pipeline as its job is done
            ctx.pipeline().remove(this);
            LOGGER.debug(
                    "Removed DBusMandatoryNameHandler from pipeline as name acquisition is complete.");
        } else {
            LOGGER.error("[DBusMandatoryNameHandler] Invalid Hello reply payload: {}", payload);

            // Fire event before removing self
            ctx.pipeline()
                    .fireUserEventTriggered(DBusChannelEvent.MANDATORY_NAME_ACQUISITION_FAILED);

            // Remove this handler from the pipeline after failure
            ctx.pipeline().remove(this);
            LOGGER.debug("Removed DBusMandatoryNameHandler from pipeline after failure.");
        }
    }

    private void handleHelloError(ChannelHandlerContext ctx, InboundError error) {
        cancelHelloTimeout();

        LOGGER.error(
                "Received error reply for Hello call (serial {}): Name: {}, Message: {}",
                helloCallSerial.getDelegate(),
                error.getErrorName(),
                error.getPayload());

        // Fire event before removing self
        ctx.fireUserEventTriggered(DBusChannelEvent.MANDATORY_NAME_ACQUISITION_FAILED);

        // Remove this handler from the pipeline after error
        ctx.pipeline().remove(this);
        LOGGER.debug("Removed DBusMandatoryNameHandler from pipeline after error.");
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        LOGGER.error("Exception caught. Current state: {}. Closing channel.", currentState, cause);
        // Ensure we signal failure if we were awaiting reply
        if (currentState == State.AWAITING_HELLO_REPLY) {
            cancelHelloTimeout();

            // Fire event before removing self
            ctx.pipeline()
                    .fireUserEventTriggered(DBusChannelEvent.MANDATORY_NAME_ACQUISITION_FAILED);

            // Remove this handler from the pipeline after exception
            ctx.pipeline().remove(this);
            LOGGER.debug("Removed DBusMandatoryNameHandler from pipeline after exception.");
        }
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        LOGGER.warn(
                "Channel became inactive while in MandatoryNameHandler. State: {}", currentState);
        if (currentState == State.AWAITING_HELLO_REPLY) {
            cancelHelloTimeout();
            ctx.pipeline()
                    .fireUserEventTriggered(DBusChannelEvent.MANDATORY_NAME_ACQUISITION_FAILED);
        }
        // No need to remove self, pipeline is being torn down
        super.channelInactive(ctx);
    }

    private void startHelloTimeout(ChannelHandlerContext ctx) {
        helloTimeoutFuture =
                ctx.executor()
                        .schedule(
                                () -> {
                                    if (currentState == State.AWAITING_HELLO_REPLY) {
                                        LOGGER.error(
                                                "[DBusMandatoryNameHandler] Hello call timed out after {} seconds",
                                                HELLO_TIMEOUT_SECONDS);

                                        // Fire event before removing self
                                        ctx.pipeline()
                                                .fireUserEventTriggered(
                                                        DBusChannelEvent
                                                                .MANDATORY_NAME_ACQUISITION_FAILED);

                                        // Remove this handler from the pipeline after timeout
                                        ctx.pipeline().remove(this);
                                        LOGGER.debug(
                                                "Removed DBusMandatoryNameHandler from pipeline after timeout.");
                                    }
                                },
                                HELLO_TIMEOUT_SECONDS,
                                TimeUnit.SECONDS);
    }

    private void cancelHelloTimeout() {
        if (helloTimeoutFuture != null && !helloTimeoutFuture.isDone()) {
            helloTimeoutFuture.cancel(false);
        }
    }

    /**
     * Resets the mandatory name handler to its initial state for reconnection. This method is
     * called when the connection needs to be re-established.
     */
    public void reset() {
        LOGGER.debug("Resetting DBusMandatoryNameHandler for reconnection");

        // Reset state
        currentState = State.IDLE;
        helloCallSerial = null;

        // Cancel any pending timeout
        cancelHelloTimeout();
        helloTimeoutFuture = null;
    }

    private enum State {
        IDLE,
        AWAITING_HELLO_REPLY
    }
}