Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions python/fasttext_module/fasttext/tests/test_autotune_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# SPDX-FileContributor: Arthit Suriyawongkul
# SPDX-FileCopyrightText: 2026-present, fasttext-community
# SPDX-FileType: SOURCE
# SPDX-License-Identifier: MIT

"""Autotune errors must raise, not terminate the process."""

import pytest

from .helpers import build_supervised_model, get_random_data


def test_autotune_error_raises(tmp_path):
data = get_random_data(3000, max_vocab_size=600)
valid = tmp_path / "valid.txt"
valid.write_text("".join(f"__label__{line}\n" for line in data))
kwargs = {
# thread=12: thread <= 10 leaves the input matrix partly uninitialized.
"thread": 12,
"verbose": 0,
"autotuneValidationFile": str(valid),
"autotuneMetric": "f1:__label__missing", # fails after the first trial
"autotuneDuration": 60, # long enough for slow runners to reach it
}
with pytest.raises(RuntimeError, match="Unknown autotune metric label"):
build_supervised_model(data, kwargs)
57 changes: 57 additions & 0 deletions python/fasttext_module/fasttext/tests/test_autotune_sigint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# SPDX-FileContributor: Arthit Suriyawongkul
# SPDX-FileCopyrightText: 2026-present, fasttext-community
# SPDX-FileType: SOURCE
# SPDX-License-Identifier: MIT

"""Autotune must restore the SIGINT handler it replaced."""

import signal
import subprocess
import sys

_SCRIPT = """
import os, signal, tempfile, threading, time, fasttext
from fasttext.tests.helpers import get_random_data
data = get_random_data(3000, max_vocab_size=600)
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
f.write("".join(f"__label__{line}\\n" for line in data))
# thread=12: thread <= 10 leaves the input matrix partly uninitialized.
fasttext.train_supervised(
f.name, autotuneValidationFile=f.name, autotuneDuration=2, thread=12, verbose=0
)

def later(delay, func, *args):
timer = threading.Timer(delay, func, args)
timer.daemon = True
timer.start()

try:
signal.raise_signal(signal.SIGINT)
time.sleep(1) # KeyboardInterrupt is raised between bytecodes
except KeyboardInterrupt:
print("KeyboardInterrupt")

if hasattr(signal, "pthread_kill"):
# Ctrl-C must also interrupt a blocking call (no SA_RESTART).
r, w = os.pipe()
later(0.5, signal.pthread_kill, threading.get_ident(), signal.SIGINT)
later(10, os.write, w, b"x") # unblocks the read if SIGINT did not
start = time.monotonic()
try:
os.read(r, 1)
except KeyboardInterrupt:
print("KeyboardInterrupt" if time.monotonic() - start < 5 else "late")
"""


def test_autotune_restores_sigint_handler():
"""Used to leave a handler pointing at the destroyed Autotune."""
result = subprocess.run(
[sys.executable, "-c", _SCRIPT],
capture_output=True,
text=True,
timeout=120,
check=False,
)
expected = ["KeyboardInterrupt"] * (2 if hasattr(signal, "pthread_kill") else 1)
assert result.stdout.split() == expected, result.stderr[-2000:]
55 changes: 52 additions & 3 deletions src/autotune.cc
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
#include <random>
#include <thread>

#ifndef _WIN32
#include <signal.h>
#endif

#define LOG_VAL(name, val) \
if (autotuneArgs.verbose > 2) { \
std::cout << #name " = " << val << std::endl; \
Expand All @@ -39,6 +43,39 @@ void signalHandler(int signal) {
}
}

// SIGINT disposition replaced by installSigint().
// POSIX keeps the full sigaction: restoring with std::signal() adds
// SA_RESTART, so Python's Ctrl-C would no longer interrupt a blocking read.
#ifdef _WIN32
void (*previousSigint)(int);
#else
struct sigaction previousSigint;
#endif
std::atomic<bool> sigintInstalled(false);

void installSigint() {
#ifdef _WIN32
previousSigint = std::signal(SIGINT, signalHandler);
#else
struct sigaction action = {};
action.sa_handler = signalHandler;
action.sa_flags = SA_RESTART; // as std::signal(), for autotune's own I/O
sigemptyset(&action.sa_mask);
sigaction(SIGINT, &action, &previousSigint);
#endif
sigintInstalled = true;
}

void restoreSigint() {
if (sigintInstalled.exchange(false)) {
#ifdef _WIN32
std::signal(SIGINT, previousSigint);
#else
sigaction(SIGINT, &previousSigint, nullptr);
#endif
}
}

class ElapsedTimeMarker {
std::chrono::steady_clock::time_point start_;

Expand Down Expand Up @@ -215,6 +252,18 @@ Autotune::Autotune(const std::shared_ptr<FastText>& fastText)
strategy_(),
timer_() {}

Autotune::~Autotune() noexcept {
// Restore SIGINT: the installed handler calls into this object.
restoreSigint();
interruptSignalHandler = nullptr;
// An exception leaving train() skips its timer join, and destroying a
// joinable std::thread calls std::terminate().
if (timer_.joinable()) {
continueTraining_ = false;
timer_.join();
}
}

void Autotune::printInfo(double maxDuration) {
double progress = elapsed_ * 100 / maxDuration;
progress = std::min(progress, 100.0);
Expand Down Expand Up @@ -266,12 +315,12 @@ void Autotune::startTimer(const Args& args) {
trials_ = 0;
continueTraining_ = true;

auto previousSignalHandler = std::signal(SIGINT, signalHandler);
interruptSignalHandler = [&]() {
std::signal(SIGINT, previousSignalHandler);
interruptSignalHandler = [this]() {
restoreSigint();
std::cerr << std::endl << "Aborting autotune..." << std::endl;
abort();
};
installSigint();
}

double Autotune::getMetricScore(
Expand Down
2 changes: 1 addition & 1 deletion src/autotune.h
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class Autotune {
Autotune(Autotune&&) = delete;
Autotune& operator=(const Autotune&) = delete;
Autotune& operator=(Autotune&&) = delete;
~Autotune() noexcept = default;
~Autotune() noexcept;

void train(const Args& args);
};
Expand Down
Loading