Dúvida sobre exemplo no livro Java Concurrency in Practice

Estou relendo o livro Java Concurrency in Practice e implementando os exemplos de código enquanto estudo. Estou no capítulo sobre cancelamento de tarefas, e encontrei um problema no código de exemplo.

Esse é o código que eu implementei e aonde estou fazendo algumas modificações para experimentar e entender melhor o funcionamento das ideias.

private static final ScheduledExecutorService cancelExec = Executors.newScheduledThreadPool(1);

public static void timedRun(Runnable r, long timeout, TimeUnit unit) throws InterruptedException {
    class RethrowableTask implements Runnable {
        private volatile Throwable t;

        @Override
        public void run() {
            try {
                r.run();
            } catch (Throwable t) {
                this.t = t;
            }
        }

        void rethrow() {
            if (t != null)
                throw new RuntimeException(t);
        }

    }

    RethrowableTask task = new RethrowableTask();
    final Thread taskThread = new Thread(task);
    taskThread.start();
    cancelExec.schedule(taskThread::interrupt, timeout, unit);
    taskThread.join(unit.toMillis(timeout));
    task.rethrow();
}

public static void main(String[] args) throws InterruptedException {
    Runnable task = () -> {
        try {
            TimeUnit.SECONDS.sleep(10);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            throw new RuntimeException("Oi");
        }
    };
    try {
        timedRun(task, 5, TimeUnit.SECONDS);
    } catch (Throwable t) {
        t.printStackTrace();
    }
    TimeUnit.SECONDS.sleep(15);
}

Esse código faz o seguinte:

  1. A thread principal cria um runnable (que tenta dormir 10 segundos e no final causa uma exceção proposital);
  2. A thread principal cria uma segunda thread (com o runnable criado no item 1) e dá start nela. Existe uma espécie de embrulho ao redor do runnable original, que tem como único intuito capturar exceptions eventuais e salvar para um throw futuro;
  3. A thread principal agenda um novo runnable em um serviço de agendamento (ScheduledExecutorService) para dar um Thread.interrupt() na thread criada no item 2, que deve ser executado após o timeout estabelecido pelo usuário;
  4. A thread principal dá um join() na thread criada no item 2 para aguardar seu término até no máximo o timeout estabelecido pelo usuário;
  5. A thread principal verifica se alguma exception aconteceu no runnable do usuário e a causa novamente, para que o usuário possa saber de sua existência;

Os timers estão definidos como:

  • Timeout do usuário para a execução cronometrada: 5 segundos
  • Tempo de sleep do runnable para a execução cronometrada: 10 segundos

Logicamente, como o sleep da tarefa a ser executada é maior do que o timeout (10 > 5), o timeout vai acontecer primeiro e a tarefa vai ser interrompida. O método sleep vai causar uma InterruptedException, que vai ser capturada pela task. Em seguida (no finally) uma RuntimeException é causada propositalmente. Essa exception é capturada pelo catch da RethrowableTask, e armazenada em uma variável volatile para garantir a visibilidade por todas as outras threads.

Acontece que, na hora que a thread principal chama o método task.rethrow(), a variável t ainda é null e a exception não é propagada para o método main. Eu fiz um teste colocando um sleep entre o join e o rethrow, e funcionou.

Parece que essa pequena janela de tempo entre o taskThread.interrupt() (executado pela thread do pool de agendamento) e o task.rethrow() é suficiente para causar um mal comportamento do programa. Existe portanto, um erro nessa solução do autor?

Listing original do livro: http://jcip.net/listings/TimedRun2.java