ProcessUtilsTest.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.tika.utils;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeFalse;

import org.junit.jupiter.api.Test;

import org.apache.tika.config.ParseTimeout;
import org.apache.tika.config.TimeoutLimits;
import org.apache.tika.parser.ParseContext;

/**
 * These tests spawn the OS {@code sleep} command directly (unavailable on Windows) rather
 * than mocking {@link Process}, because the property under test -- that a checkpoint fires
 * while a real bounded wait is still in progress, not only after it returns -- is exactly
 * the thing a mock would have to fake.
 */
public class ProcessUtilsTest {

    @Test
    public void testHeartbeatFiresWhileProcessIsStillRunning() throws Exception {
        assumeFalse(SystemUtils.IS_OS_WINDOWS);

        ParseContext context = new ParseContext();
        context.set(TimeoutLimits.class, new TimeoutLimits(60_000, 60_000));
        ParseTimeout parseTimeout = ParseTimeout.getOrCreate(context);

        ProcessBuilder pb = new ProcessBuilder("sleep", "6");
        Thread runner = new Thread(() -> {
            try {
                ProcessUtils.execute(pb, context, 20_000, 1000, 1000);
            } catch (Exception e) {
                // surfaced via the join()+assert below if it prevents progress
            }
        });
        runner.start();

        try {
            // Wait a generous multiple of HEARTBEAT_INTERVAL_MILLIS (~1000ms), well short of
            // the process's 6s completion, for slack against jitter under a loaded test run.
            Thread.sleep(3000);

            // without a mid-wait checkpoint, millisSinceLastProgress() would be >= 3000
            assertTrue(parseTimeout.millisSinceLastProgress() < 3000,
                    "expected a checkpoint to have fired while the process was still running");
        } finally {
            runner.join(10_000);
        }
    }

    @Test
    public void testNullContextDoesNotThrow() throws Exception {
        assumeFalse(SystemUtils.IS_OS_WINDOWS);

        ProcessBuilder pb = new ProcessBuilder("sleep", "0");
        FileProcessResult result = ProcessUtils.execute(pb, null, 5_000, 1000, 1000);

        assertTrue(!result.isTimeout());
    }

    /**
     * TIKA-4813 follow-up: a null context has no task to clip against, but
     * {@code ParseTimeout.getOrCreate(null)} used to hand back a detached ParseTimeout built
     * from *default* TimeoutLimits (1 hour) -- silently capping any request above that and
     * contradicting the "granted unclipped" contract the javadoc on this overload promises
     * (e.g. LibPstParser's one-off version-check process, run during initialize() with no
     * ParseContext yet).
     */
    @Test
    public void testNullContextGrantsRequestUnclippedEvenAboveDefaultOneHour() throws Exception {
        assumeFalse(SystemUtils.IS_OS_WINDOWS);

        long requestedTimeoutMillis = TimeoutLimits.DEFAULT_TOTAL_TASK_TIMEOUT_MILLIS + 60_000;
        ProcessBuilder pb = new ProcessBuilder("sleep", "0");
        FileProcessResult result = ProcessUtils.execute(pb, null, requestedTimeoutMillis, 1000, 1000);

        assertFalse(result.isTimeout());
        assertFalse(result.isClippedByRemaining(),
                "a null context must not silently clip the request against a default-TimeoutLimits budget");
        assertTrue(result.getGrantedTimeoutMillis() == requestedTimeoutMillis,
                "granted (" + result.getGrantedTimeoutMillis() + ") must equal requested ("
                        + requestedTimeoutMillis + ") when there is no context to clip against");
    }

    @Test
    public void testTimeoutStillBoundsTheWait() throws Exception {
        assumeFalse(SystemUtils.IS_OS_WINDOWS);

        long start = System.currentTimeMillis();
        ProcessBuilder pb = new ProcessBuilder("sleep", "5");
        FileProcessResult result = ProcessUtils.execute(pb, null, 800, 1000, 1000);
        long elapsed = System.currentTimeMillis() - start;

        assertTrue(result.isTimeout(), "a 5s sleep with an 800ms budget must time out");
        assertTrue(elapsed < 4_000,
                "the polling rewrite must still honor the timeout, not wait for the full sleep; took " + elapsed + "ms");
    }

    @Test
    public void testCheckCommandDefaultTimeoutStillWorksForAFastCommand() {
        assumeFalse(SystemUtils.IS_OS_WINDOWS);

        assertTrue(ProcessUtils.checkCommand(new String[]{"true"}),
                "a fast, well-behaved command must still succeed under the new default");
    }

    @Test
    public void testCheckCommandCustomTimeoutBoundsASlowCommand() {
        assumeFalse(SystemUtils.IS_OS_WINDOWS);

        long start = System.currentTimeMillis();
        boolean result = ProcessUtils.checkCommandWithTimeout(new String[]{"sleep", "5"}, 500);
        long elapsed = System.currentTimeMillis() - start;

        assertFalse(result, "a command that outlives its timeout must report failure");
        assertTrue(elapsed < 4_000,
                "checkCommandWithTimeout must honor its own timeout, not the default; took " + elapsed + "ms");
    }
}