JansiConsoleAppenderIssue1063Test.java

/*
 * Logback: the reliable, generic, fast and flexible logging framework.
 * Copyright (C) 1999-2026, QOS.ch. All rights reserved.
 *
 * This program and the accompanying materials are dual-licensed under
 * either the terms of the Eclipse Public License v2.0 as published by
 * the Eclipse Foundation
 *
 *   or (per the licensee's choosing)
 *
 * under the terms of the GNU Lesser General Public License version 2.1
 * as published by the Free Software Foundation.
 */
package ch.qos.logback.core.blackbox.appender;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.FileDescriptor;
import java.io.PrintStream;

import org.jline.jansi.AnsiConsole;
import org.jline.jansi.AnsiPrintStream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import ch.qos.logback.core.ConsoleAppender;
import ch.qos.logback.core.Context;
import ch.qos.logback.core.ContextBase;
import ch.qos.logback.core.JansiConsoleAppender;
import ch.qos.logback.core.testUtil.DummyEncoder;

/**
 * Reproduces
 * <a href="https://github.com/qos-ch/logback/issues/1063">logback issue
 * #1063</a> (also
 * <a href="https://jira.qos.ch/browse/LOGBACK-1759">LOGBACK-1759</a>) for
 * {@link JansiConsoleAppender}.
 *
 * <p>
 * With Jansi, {@code start()} uses the process-wide {@link AnsiConsole#out()}
 * (also installed as {@link System#out} via {@link AnsiConsole#systemInstall()}).
 * {@link ch.qos.logback.core.OutputStreamAppender#stop()} then calls
 * {@code outputStream.close()} on that shared stream. A subsequent write marks
 * the stream's error flag; on some platforms (notably Windows) the underlying
 * {@link FileDescriptor#out} is closed at the OS level and cannot be recovered
 * within the same JVM.
 * </p>
 *
 * <p>
 * Expected correct behaviour: stopping the appender must not close the
 * process-wide stdout stream (flush only). {@link JansiConsoleAppender#stop()}
 * also balances {@link AnsiConsole#systemInstall()} with
 * {@link AnsiConsole#systemUninstall()} when this appender installed.
 * </p>
 */
public class JansiConsoleAppenderIssue1063Test {

    Context context = new ContextBase();
    PrintStream originalOut;
    PrintStream originalErr;

    @BeforeEach
    public void setUp() {
        originalOut = System.out;
        originalErr = System.err;
    }

    @AfterEach
    public void tearDown() {
        // Appenders under test should have stopped and uninstalled; drain any
        // leftover install count only if something failed mid-test.
        try {
            while (AnsiConsole.isInstalled()) {
                AnsiConsole.systemUninstall();
            }
        } catch (Throwable ignored) {
            // best-effort cleanup
        }
        System.setOut(originalOut);
        System.setErr(originalErr);
    }

    /**
     * Control: plain {@link ConsoleAppender} uses a non-closing
     * {@link ch.qos.logback.core.joran.spi.ConsoleTarget} stream, so stop must
     * leave {@link System#out} usable.
     */
    @Test
    public void plainConsoleAppenderStopDoesNotPoisonSystemOut() {
        ConsoleAppender<Object> ca = new ConsoleAppender<>();
        ca.setContext(context);
        ca.setEncoder(new DummyEncoder<>());
        ca.start();
        ca.doAppend(new Object());

        ca.stop();

        System.out.println("plain-after-stop");
        assertFalse(System.out.checkError(),
                "plain ConsoleAppender.stop() must not put System.out into error state");
    }

    /**
     * Issue #1063 adapted to {@link JansiConsoleAppender}: after stop(), a write
     * to the stream that was in use must not set the PrintStream error flag.
     */
    @Test
    public void jansiConsoleAppenderStopMustNotCloseSharedStdout() {
        JansiConsoleAppender<Object> ca = new JansiConsoleAppender<>();
        ca.setContext(context);
        ca.setEncoder(new DummyEncoder<>());
        ca.start();

        assertTrue(ca.getOutputStream() instanceof AnsiPrintStream);
        // systemInstall() makes AnsiConsole.out() the process-wide System.out
        assertSame(System.out, ca.getOutputStream(),
                "expected AnsiConsole.systemInstall() to install appender stream as System.out");

        PrintStream jansiOut = (PrintStream) ca.getOutputStream();
        ca.doAppend(new Object());
        assertFalse(jansiOut.checkError(), "stream should be healthy after append");

        ca.stop();
        assertFalse(AnsiConsole.isInstalled(),
                "stop() must systemUninstall when this appender called systemInstall");

        // After uninstall, System.out is restored; write to the former Jansi stream
        // must not have been closed by stop() (flush-only). PrintStream does not
        // throw on write-after-close; it sets the error flag.
        jansiOut.println("write-after-jansi-console-appender-stop");
        assertFalse(jansiOut.checkError(),
                "JansiConsoleAppender.stop() must not close the shared AnsiConsole stream "
                        + "(https://github.com/qos-ch/logback/issues/1063)");
    }

    /**
     * Reconfiguration path from the issue: stop (as during
     * {@code LoggerContext.reset()}) then start a new JansiConsoleAppender. The
     * second instance must get a usable stream.
     */
    @Test
    public void jansiConsoleAppenderUsableAfterStopAndRestart() {
        JansiConsoleAppender<Object> first = new JansiConsoleAppender<>();
        first.setContext(context);
        first.setEncoder(new DummyEncoder<>());
        first.start();
        first.doAppend(new Object());
        first.stop();

        JansiConsoleAppender<Object> second = new JansiConsoleAppender<>();
        second.setContext(context);
        second.setEncoder(new DummyEncoder<>());
        second.start();
        second.doAppend(new Object());

        PrintStream out = (PrintStream) second.getOutputStream();
        out.println("after-reconfigure");
        assertFalse(out.checkError(),
                "after stop+restart, Jansi-backed stream must still accept writes "
                        + "(https://github.com/qos-ch/logback/issues/1063)");
        second.stop();
    }

    /**
     * Documents the Windows-oriented observation from #1063 when it applies.
     * On Linux, {@link FileDescriptor#out} often remains {@code valid()} even
     * after the Jansi stream is closed; the PrintStream error flag is the
     * portable signal. This assertion only fires if the FD is actually closed.
     */
    @Test
    public void jansiConsoleAppenderStopShouldKeepFileDescriptorOutValid() {
        assertTrue(FileDescriptor.out.valid(), "precondition: stdout FD must be valid");

        JansiConsoleAppender<Object> ca = new JansiConsoleAppender<>();
        ca.setContext(context);
        ca.setEncoder(new DummyEncoder<>());
        ca.start();
        ca.doAppend(new Object());
        ca.stop();

        // Intentionally exercise the closed stream (sets checkError on some JDKs).
        System.out.println("fd-check-after-stop");

        assertTrue(FileDescriptor.out.valid(),
                "JansiConsoleAppender.stop() must not close FileDescriptor.out "
                        + "(https://github.com/qos-ch/logback/issues/1063)");
    }
}