バッチファイルでタイムアウトを指定してコマンドを実行する

ほぼ10年ぶりの投稿となってしまいました🤣
でも趣味ですし気が向いた時で良いですよね 😅

今回は、バッチファイルでタイムアウトを指定してコマンドを実行する方法を考えてみたいと思います。

課題

バッチファイル中の処理で、特定のコマンドの実行に時間が掛かる場合があります。
その際、そのコマンドの実行をある時間までは待てるけど、それ以上に時間が掛かるような場合は
コマンドの実行を打ち切って次の処理に移りたいといったようなケースがあります。
タイムアウト付きでコマンド実行したいケースですね)


簡単に調べてみたところ、直接そのようなことができる Windows 標準のコマンドは無さそうでした。
そのため、標準コマンドを組み合わせて実現する方法を考えてみました。

スクリプト

試行錯誤した結果、以下のように記載することでタイムアウト付きでコマンドを実行することができました。

@echo off
setlocal

:: waitfor コマンドで使うシグナル名
set SIGNAL_NAME=TestSignal

:: サブコンソールのタイトル
set SUB_CONSOLE_TITLE=%~n0_Sub_Console

:: タイムアウト秒数
set TIMEOUT=5

:: 実行したいコマンド (時間のかかるコマンド)
set CMDLINE=timeout /t 20

:: 処理開始時刻を表示
echo %date% %time% - Start: %CMDLINE% [Timeout: %TIMEOUT% sec]

:: サブコンソールでコマンドの実行
start "%SUB_CONSOLE_TITLE%" cmd /c "%CMDLINE% & waitfor /s %COMPUTERNAME% /si %SIGNAL_NAME%"

:: コマンドの終了を待ち合わせ
waitfor /t %TIMEOUT% %SIGNAL_NAME%
if errorlevel 1 (
  :: 待ち合わせがタイムアウトしたので、サブコンソールごと対象コマンドを強制終了
  taskkill /fi "WINDOWTITLE eq %SUB_CONSOLE_TITLE%" /f /t
)

:: 処理終了時刻を表示
echo %date% %time% - End

exit /b 0

※ファイル名は "timeout-cmd-exec.bat"とします。

実行例

※ 実行環境は Windows 10 Pro のコマンドプロンプトです。

タイムアウトする場合

以下は、タイムアウトを 5 秒として、20 秒時間がかかるコマンドを実行してみた例です。

C:\tmp>timeout-cmd-exec.bat
2025/04/30 12:37:55.20 - Start: timeout /t 20 [Timeout: 5 sec]
エラー: 'TestSignal' 待ちがタイムアウトしました。
成功: PID 10700 のプロセス (PID 11220 の子プロセス) を終了しました。
成功: PID 752 のプロセス (PID 11220 の子プロセス) を終了しました。
成功: PID 11220 のプロセス (PID 9356 の子プロセス) を終了しました。
2025/04/30 12:38:00.49 - End

起動から約 5 秒でコマンドの待ち合わせがタイムアウトして、時間がかかっているコマンドを強制終了させています。

待ち合わせ時間内にコマンドが完了する場合(タイムアウトしない場合)

以下は、同様にタイムアウトを 5 秒として、 2 秒で完了するコマンドを実行させてみた例です。
(スクリプト例の set CMDLINE=timeout /t 20 の "20" を "2" に書き換えれば簡単に試せます)

C:\tmp>timeout-cmd-exec.bat
2025/04/30 14:26:27.11 - Start: timeout /t 2 [Timeout: 5 sec]

成功: シグナルを受信しました。
2025/04/30 14:26:29.18 - End

今回は待ち合わせ時間内にコマンドが完了しているため、コマンドの強制終了は行われていません。

ポイント

スクリプトのポイントは以下です。

  • 時間のかかるコマンドはメインコンソールから切り離してサブコンソールで実行する
  • サブコンソールでコマンドの実行が完了したら、waitfor コマンドで実行完了をメインコンソールへ連絡する
  • メインコンソールでは、サブコンソールからの連絡を待ち合わせる
  • 連絡の待ち合わせがタイムアウトした場合、taskkill コマンドでサブコンソールを強制終了させる


start コマンドを用いることで、サブコンソールでコマンドを実行することができます。
その際、サブコンソールのウィンドウタイトルとして、%SUB_CONSOLE_TITLE% 変数の文字列を設定しており、後述の taskkill コマンドで終了する際の目印としています。ちなみに、今回の例ではサブコンソールが表示されますが、start コマンドに /min オプションを付けておくとサブコンソールを最小化状態で起動することができます。
learn.microsoft.com


また、waitfor コマンドで、コンソール間で連携を行うことができます。
learn.microsoft.com


taskkill コマンドで、対象コマンドを強制終了しています。
その際、終了対象のコマンドを特定するために、先述のウィンドウタイトルを対象に終了させています。また、/t オプションを付けることで、対象のサブコンソール配下のプロセスをすべて終了させています。/t オプションを付けない場合、終了させたいコマンドは残ったまま、サブコンソール (cmd.exe) のみ終了してしまうため、あえて /t オプションを付けています。
learn.microsoft.com

注意点

waitfor コマンドのシグナル名 (SIGNAL_NAME 変数)

上述の例では分かりやすさ優先のために固定値を設定していますが、実際はバッチファイルの実行毎に一意な文字列に設定します。
というのも、同一のシグナル名を使用する複数のバッチファイルが同時刻に動作する場合、別のバッチファイル用のシグナルを受け取ってしまったり、あるいは、意図しない相手にシグナルを送ってしまったりと誤動作の原因となる可能性があるためです。

サブコンソールのタイトル (SUB_CONSOLE_TITLE 変数)

こちらも同様に固定値を例にしていますが、実際はバッチファイルの実行毎に一意な文字列に設定します。
タイムアウトした際に強制終了させる目印として「サブコンソールのタイトル」を使用しているため、他のアプリケーションでもよく使われるような値を設定してしまうと誤って意図しない相手を終了させてしまう可能性があるためです。ですので、実行毎に自分だけしか使わない一意な文字列にする必要があります。

後書き

できるだけバッチファイル標準機能と Windows の標準コマンドで済むようにしてみました。
ただ、バッチファイルですので、PC が高負荷な場合は待ち合わせ時間が延びてしまったりなど、かっちり厳密に動く訳ではありません。
(厳密に動かしたい人はバッチファイルなんか使わず、他の手段を使ってるよね😂)


また、タイムアウト付きで実行するために、普通に実行する場合と比べて記載量が増えてしまっています。
上記の例ではたった 1 つのコマンドだけを対象にしていて、もったいない感を感じなくもないですが、タイムアウト付きで実行する機構の部分をサブルーチン化して、1 つのコマンドだけでなく色々なコマンドを簡単にタイムアウト付きで実行できるようにするのも面白いかもしれません。


ちなみに、Linuxシェルスクリプトで同様のことを行いたい場合は、そのものズバリな timeout というコマンドを利用できるようです。便利ですね🤩
linuxjm.sourceforge.io

バッチファイルの for /f で実行したコマンドのエラーを検出する

課題

バッチファイルで、コマンドの実行結果を変数に保存(キャプチャ) したいときに for /f コマンドがしばしば用いられます。


次のような使い方ですね。

for /f "usebackq delims=" %%i in (`dir /b`) do echo Value: %%~i


ただ、コマンド(ここでは dir コマンド) が正常に終了する場合は、このような記載で問題ないのですが、多くのコマンドはたまに失敗することがあります。


例えば、下記のようなバッチファイルを実行してみます。
HOGE は存在しないファイルとします。dir コマンドは存在しないファイルを表示する場合に失敗します。

@echo off
for /f "usebackq delims=" %%i in (`dir /b HOGE`) do echo Value: %%~i

実行結果です。

ファイルが見つかりません


標準エラー出力にエラーが出力されるため、人が見ればエラーがあったことが分かりますが、バッチスクリプト的(for コマンドの do 以降で実施される処理的) にはエラーがあったかどうか分かりません。

解決

このような場合、次のように記述すれば for /f で実行したコマンドのエラーを検出することができます。

@echo off
for /f "usebackq delims=" %%i in (`dir /b HOGE ^|^| echo *ERROR*`) do if "%%i" == "*ERROR*" (
	echo dir コマンドに失敗しました。
) else (
	echo dir コマンドに成功しました。
	echo Value: %%~i
)

実行結果です。

ファイルが見つかりません
dir コマンドに失敗しました。

コマンドのエラーを検出できていることが分かります。

ポイント

下記のようなバッチの記述方法を利用しています。

コマンド1 || コマンド2

"コマンド2" は "コマンド1" が失敗した場合にのみ実行されます。


上述の例で言えば dir コマンドが失敗した場合にのみ、echo *ERROR* が実行され、文字列 *ERROR* が %%i 変数にキャプチャされます。
このため、%%i 変数の内容が *ERROR* か否かによってエラーの有無を判別することができます。


本例ではエラーの有無を判別する文字列を *ERROR* としていますが、全くの任意です。ただ、"コマンド1" の出力として現れないような文字列にしておくことがベターかと思われます。

なお、| (縦線) を for /f コマンドで記述する際は、上述の例のとおり ^ (ハット) でエスケープして ^| と記述する必要があります。

注意点

下記の記述方法で失敗と判断("コマンド2" が実行) される条件ですが、"コマンド1" が 0 以外を返却した場合のもようです。

コマンド1 || コマンド2


次の実験で確認できます。

C:\>cmd /c "exit /b 1" || echo ERROR
ERROR

C:\>cmd /c "exit /b 0" || echo ERROR

C:\>cmd /c "exit /b -1" || echo ERROR
ERROR


このため、正常に終了する場合でも 0 以外を返却するコマンドに対しては、今回の手法は利用できません。

例えば robocopy コマンドは、正常に終了する場合でも 0 以外を返却する場合があります。

バッチファイルで MD5 チェックサムを生成する

今回はバッチファイルで MD5チェックサム(メッセージダイジェスト) を生成してみます。

MD5 チェックサムを生成する方法として、通常 Linux では md5sum コマンドを利用します。下記は md5sum の実行例ですが、各ファイルとそのチェックサムが出力されます。

584da0a485f209242059e6de66aac904 test1.txt
ff0eb2864feb22354747f8c85d42ccb5 test2.txt
00539e9205b2ba8cf6dabd9f123399e2 test3.txt

一方、Windows では md5sum のような標準的なコマンドがないようです。ただ、調べてみると Windows の標準コマンド certutil を利用してチェックサムの算出を行えるもようです。


下記は certutil の実行例です。

C:\tmp> certutil -hashfile test1.txt MD5
MD5 ハッシュ (ファイル test1.txt):
58 4d a0 a4 85 f2 09 24 20 59 e6 de 66 aa c9 04
CertUtil: -hashfile コマンドは正常に完了しました。

md5sum と出力形式は異なりますが、チェックサムが算出されていることが分かります。
今回は、この certutil コマンドを利用して、md5sum コマンドの出力形式でMD5チェックサムを生成するようなスクリプトを作成してみます。


早速ですが、下記のようなコードになりました。

rem FileName: md5sum.bat

@echo off

if "%~1" == "" (
  echo Usage: %~n0 FILE...
  exit /b 0
)

setlocal
set CmdName=CertUtil
set HashMethod=MD5

for %%a in (%*) do for %%f in (%%a) do call :PrintFileHash "%%~f"
exit /b 0

:PrintFileHash
set Hash=
for /f "usebackq delims=" %%h in (`%CmdName% -hashfile "%~1" %HashMethod% ^| find /v "%HashMethod%" ^| find /v "%CmdName%:"`) do set Hash=%%h
if "%Hash%" == "" if "%~z1" == "0" (
	set Hash=d41d8cd98f00b204e9800998ecf8427e
) else (
	exit /b 0
)
set Hash=%Hash: =%
echo %Hash% %~1
exit /b 0


実行してみます。

C:\tmp> md5sum.bat *
584da0a485f209242059e6de66aac904 test1.txt
ff0eb2864feb22354747f8c85d42ccb5 test2.txt
00539e9205b2ba8cf6dabd9f123399e2 test3.txt

md5sum の出力形式にならって出力されていることが分かります。


※なお、スクリプト内に1つ MD5 チェックサム(d41d8cd98f00b204e9800998ecf8427e) が記されていますが、これはファイルサイズが 0 のファイルの MD5 チェックサムです。ファイルサイズが 0 のファイルを certutil に入力すると、エラーとなるため、この場合だけ特別扱いしています。

JScript でテキストファイルの文字列を置換する

今回は、JScript を用いてテキストファイルの文字列を置換するスクリプトを作りました。

スクリプト

// FileName: replaceText.js

var args = WScript.Arguments;
if (args.Length < 3) {
	WScript.Echo('Usage: cscript replaceText.js 入力ファイル名 置換前テキスト 置換後テキスト');
	WScript.Quit(1);
}

var srcfile = args(0);
var srckwd = args(1);
var dstkwd = args(2);
var retcode = 0;
var strm = null;

var fso = new ActiveXObject('Scripting.FileSystemObject');

try {
	strm = fso.OpenTextFile(srcfile);
} catch (e) {
	WScript.StdErr.WriteLine('Error: ' + e.description);
	WScript.Quit(e.number & 0xFFFF);
}

try {
	while (!strm.AtEndOfStream) {
		WScript.Echo(strm.ReadLine().replace(srckwd, dstkwd));
	}
} catch (e) {
	WScript.StdErr.WriteLine('Error: ' + e.description);
	retcode = e.number & 0xFFFF;
} finally {
	strm.Close();
}

strm = null;
fso = null;

WScript.Quit(retcode);

使い方

コマンドプロンプトで下記のように指定してください。

cscript replaceText.js 入力ファイル名 置換前テキスト 置換後テキスト

WSH のロゴを表示したくない場合は、下記のように指定してください。

cscript //NoLogo replaceText.js 入力ファイル名 置換前テキスト 置換後テキスト

実行例

下記のテキストファイルを入力とします。

C:\Windows\System32\drivers\1394bus.sys
C:\Windows\System32\drivers\1394ohci.sys
C:\Windows\System32\drivers\acpi.sys
C:\Windows\System32\drivers\acpipmi.sys
C:\Windows\System32\drivers\adp94xx.sys
C:\Windows\System32\drivers\adpahci.sys
C:\Windows\System32\drivers\adpu320.sys
C:\Windows\System32\drivers\afd.sys
C:\Windows\System32\drivers\agilevpn.sys
C:\Windows\System32\drivers\AGP440.sys

「C:\Windows\System32」を「D:」に置き換えてみます。
実行すると置換済みのテキストが出力されます。

C:\>cscript //NoLogo replaceText.js input.txt C:\Windows\System32 D:
D:\drivers\1394bus.sys
D:\drivers\1394ohci.sys
D:\drivers\acpi.sys
D:\drivers\acpipmi.sys
D:\drivers\adp94xx.sys
D:\drivers\adpahci.sys
D:\drivers\adpu320.sys
D:\drivers\afd.sys
D:\drivers\agilevpn.sys
D:\drivers\AGP440.sys

補足

スクリプトで扱えるテキストの文字コードは「Shift-JIS」です。
Unicode を扱いたい場合は、OpenTextFile のパラメータを変更するなどの修正が必要です。

バッチファイルで入力文字列の形式をチェックする

今回はバッチファイルネタです。


バッチファイルを書いていると、ユーザに文字列を入力してもらいたい時があります。
更に、入力された文字列の形式をチェックしたい時があります。
よくあるケースは、入力された文字列が日付の形式になっているか?等ですかね。


下記のように、findstr コマンドを用いることでチェックを行うことができます。

rem FileName: validate.bat

:loop
set str=
set /p str="YYYYMMDD 形式で日付を入力してください(Enter:終了): "
if "%str%" == "" goto end

echo %str% | findstr /r "\<[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]\>" >nul 2>&1
if errorlevel 1 (
	echo %str% : NG
) else (
	echo %str% : OK
)

echo.
goto loop

:end

findstr コマンドに /r オプションを与えることにより、正規表現による検索処理を行わせています。
findstr コマンドについては、下記を参照してください。
Findstr | Microsoft Learn


実行例です。

C:\bat>validate.bat
YYYYMMDD 形式で日付を入力してください(Enter:終了): abcd
abcd : NG

YYYYMMDD 形式で日付を入力してください(Enter:終了): 123456
123456 : NG

YYYYMMDD 形式で日付を入力してください(Enter:終了): 2015/04/26
2015/04/26 : NG

YYYYMMDD 形式で日付を入力してください(Enter:終了): 2015-04-26
2015-04-26 : NG

YYYYMMDD 形式で日付を入力してください(Enter:終了): 20150426
20150426 : OK

YYYYMMDD 形式で日付を入力してください(Enter:終了):

C:\bat>

「20150426」のみ OK 判定になっており、形式をチェックできていることが分かります。

JScript を用いてインターネットのファイルをダウンロードする

またまた JScript ネタです。
今回は、JScript を用いてインターネット上のファイルをダウンロードするスクリプトを作ってみます。

いきなりコードですが、下のようになりました。

// FileName: download.js

var args = WScript.Arguments;

if (args.Length <= 1) {
	var emsg = "Usage: cscript download.js ダウンロード対象のURL ダウンロード先フォルダ";
	WScript.Echo(emsg);
	WScript.Quit(1);
}

var fso = new ActiveXObject("Scripting.FileSystemObject");
var url = args(0);
var fname = fso.GetAbsolutePathName(args(1)) + "\\" + fso.GetFileName(url);

try {
	download(url, fname);
} catch(e) {
	var en = e.number & 0xFFFF;
	WScript.Echo("Error: " + e.description + "(" + en + ") ");
	WScript.Quit(en);
}
WScript.Quit(0);

function download(url, fname) {
	var http = newHttpObject();
	http.open("GET", url, false);
	http.send();
	if (http.status != 200) {
		var err = (http.status == 0)? 1 : http.status;
		throw new Error(err, http.statusText);
	}
	
	var file = new ActiveXObject("ADODB.Stream");
	file.Type = 1;
	file.Open();
	try {
		file.Write(http.responseBody);
		file.SaveToFile(fname, 2);
	} catch(e) {
		throw e;
	} finally {
		file.Close();
	}
	
	file = null;
	http = null;
	
	return;
}

function newHttpObject() {
	var http;
	try {
		http = new ActiveXObject("Msxml2.ServerXMLHTTP.6.0");
	} catch(e) {
		try {
			http = new ActiveXObject("Msxml2.ServerXMLHTTP");
		} catch(e) {
			try {
				http = new ActiveXObject("Msxml2.XMLHTTP");
			} catch(e) {
				throw e;
			}
		}
	}
	return http;
}


使い方ですが、コマンドプロンプトで下記のように指定して実行してください。

cscript download.js ダウンロード対象のURL ダウンロード先フォルダ


例です。下記を実行すると logo-ns-131205.png が C:\Temp 配下に保存されます。

cscript download.js http://k.yimg.jp/images/top/sp2/cmn/logo-ns-131205.png C:\Temp

JScript を用いて Lhaplus で自己解凍書庫を作る

久々の投稿となってしまいました。
今回は JScript を用いて Lhaplus を制御し、自己解凍書庫を作成してみます。


Lhaplus をスクリプトで制御するためには、Lhaplus のコマンドラインスイッチを利用しますが、ヘルプを参照したところ、現状コマンドラインスイッチは公開されていないようでした。他サイト様を参照すると、公開されてないものの Lhaplus 自体はコマンドラインで操作が可能とのことです。

開発リソース/Windows/Lhaplusのコマンドライン引数 - isla-plata.org Wiki


実際に試してみたところ、たしかにコマンドラインから操作することが可能でした。ただ、動かしてみた範囲では、書庫の作成が正常に完了する場合も、エラーで終了する場合も、実行ファイルの戻り値として同じ 0 が返却されるため、スクリプト側から Lhaplus の処理の成否を判断するのが難しそうです。

今回はこの部分を何とか外部から判断できないか工夫してみました。できあがったコードが以下です。(zlib/libpngライセンス)

// FileName: createSFX.js

var lpath = "C:\\Program Files\\Lhaplus\\Lhaplus.exe";
var args = WScript.Arguments;

if (args.Length <= 0) {
	var emsg = "Error: 圧縮対象が指定されていません。\n" + 
				"Usage: cscript createSFX.js 圧縮対象の絶対パス";
	WScript.Echo(emsg);
	WScript.Quit(1);
}

try {
	createSFX(lpath, args(0));
} catch(e) {
	var en = e.number & 0xFFFF;
	WScript.Echo("Error: " + e.description + "(" + en + ") ");
	WScript.Quit(en);
}
WScript.Quit(0);

function createSFX(lpath, srcpath) {
	var fso = new ActiveXObject("Scripting.FileSystemObject");
	var sh = new ActiveXObject("WScript.Shell");
	var exec = null;
	var target = "\"" + fso.GetAbsolutePathName(srcpath) + "\"";
	var outdir = "\"" + fso.GetParentFolderName(fso.GetAbsolutePathName(srcpath)) + "\"";
	var logdir = WScript.ScriptFullName.replace(WScript.ScriptName, "");
	var logpath = logdir + "lhaplus.log";
	var cmdline = lpath + " /x /c:zip /log:" + logpath + " /o:" + "" + outdir + " " + target;
	var logfile = null;
	var err = false;
	
	// 前回のログファイルの削除
	try { fso.DeleteFile(logpath, true); } catch (e) {}
	
	try {
		// 自己解凍書庫の作成
		exec = sh.Exec(cmdline);
		
		// Lhaplus の終了待ち
		for (; exec.Status == 0; WScript.Sleep(300)) {
			try {
				// ログ監視
				logfile = fso.OpenTextFile(logpath, 1, false, -2);
				if (logfile.ReadAll().indexOf("エラーまたは警告が発生しています。") > -1) {
					exec.Terminate();
					err = true;
				}
				logfile.Close;
			} catch(e) {
			} finally {
				logfile = null;
			}
		}
		
		if (err || exec.ExitCode != 0) {
			throw new Error(1, "自己解凍書庫の作成でエラーが発生しました。");
		}
	} catch(e) {
		throw e;
	} finally {
		exec = null;
		sh = null;
		fso = null;
	}
	
	return;
}

使い方は、このスクリプトにファイルをドラッグ&ドロップするだけです。ドロップしたファイルと同じフォルダに、入力ファイル名の自己解凍書庫が作成されます。


上記コードの中で、「ログ監視」コメント部分がポイントです。Lhpalus にはログファイルを出力するオプションがあるようで、エラー時はこのログファイルの中に "エラーまたは警告が発生しています。" という文字列が出力されます。これを利用して外部からエラーと判断することができます。


また、Lhaplus の既定の設定では、圧縮処理でエラーが発生した場合に下のようなログウィンドウを表示するようになっています。
f:id:norastep:20141015000830p:plain
このログウィンドウを閉じないと Lhaplus.exe が永遠に終了しないため、エラー時に限って Lhaplus に終了要求を送ります。

ログウィンドウの表示設定は、Lhaplus の詳細設定より下記赤枠の箇所が該当します。
f:id:norastep:20150422205742p:plain

なお、本スクリプトはこの設定以外では動作しません。エラー時のみログウィンドウが表示されることを期待しているためです。