Saturday, March 20. 2010
WshShell.Exec Considered Harmful Due To Blocking
For the unfamiliar, the Exec method of WshShell (used in Windows scripting) runs an application and provides access to that application's standard streams as TextStream objects. The problem with this method is that the blocking behavior of these streams is not defined (as noted in the comments on the StdOut property) and, more importantly, is impossible to use safely. The problem, familiar to anyone who has dealt with reading and writing to child programs, is that it is very easy to block while attempting to read from (or write to) the child process. What makes this problem worse is that TextStream provides no method for dealing with blocking; there is no way to set non-blocking mode or to check if input is ready to be read or to check if it would be safe to write (at least, none that I am aware of).
As a demonstration of the problem, consider the following applications. First, the child program (written in C):
#include <stdio.h>
int main(void)
{
for (int i=0; i<10; ++i) {
for (int j=0; j<8192; ++j)
fputc('x', stdout);
for (int j=0; j<8192; ++j)
fputc('x', stderr);
}
return 0;
}
var WShell = new ActiveXObject("WScript.Shell");
var wsexec = WShell.Exec(cmd);
var output = "";
var error = "";
// Keep looping until the program exits
while (wsexec.Status == 0) {
while (!wsexec.StdOut.AtEndOfStream) {
output += wsexec.StdOut.Read(1);
}
while (!wsexec.StdErr.AtEndOfStream) {
error += wsexec.StdErr.Read(1);
}
WScript.Sleep(100);
}
WScript.Echo("Output: " + output);
WScript.Echo("Error Output: " + error);
The solution that I came up with (which has its own drawbacks in terms of performance), is to write the program output to files, then read it back in the script once the program has finished. This solves the deadlock problem at the expense of decreasing the performance by requiring the output to be written to disk as an intermediate step (although the OS may not flush it to the physical disk). The code for this solution is presented below:
/** Run a command, in a separate process and retrieve its output.
*
* This is a safer, slower, alternative to WshShell.Exec that supports
* retrieving the output (to stdout and stderr) only after the command
* has completed execution. It does not support writing to the standard
* input of the command. It's only redeeming quality is that it will
* not cause deadlocks due to the blocking behavior of attempting to read
* from StdOut/StdErr.
*
* @param cmd The name/path of the command to run
* @param winstyle The window style (see WshShell.Run) of the command, or null
* @return An object with an exitcode property set to the exit code of the
* command, an output property set to the string of text written by the
* command to stdout, and an errors property with the string of text written
* by the command to stderr.
*/
function run(cmd) {
var tmpdir = FSO.GetSpecialFolder(2 /* TemporaryFolder */);
if (!/(\\|\/)$/.test(tmpdir))
tmpdir += "\\";
var outfile = tmpdir + FSO.GetTempName();
var errfile = tmpdir + FSO.GetTempName();
// Note: See KB278411 for this recipe
// Note2: See cmd.exe /? for interesting quoting behavior...
var runcmd = '%comspec% /c "' + cmd + ' > "' + outfile + '" 2> "' + errfile + '""';
var wshexec = WShell.Exec(runcmd);
// Write stuff to the standard input of the command (through cmd.exe)
// Note: This will block until the program exits if significant amounts
// of information are written and not read. But no deadlock will occur.
// Note2: This will error if the program has exited
try {
wshexec.StdIn.Write("stuff\n");
} catch (ex) {
WScript.Echo("Unable to write to program.");
}
// Do stuff, or write more stuff while cmd executes, or wait...
while (wshexec.Status == 0)
WScript.Sleep(100);
exitcode = wshexec.ExitCode;
var output = "";
try {
var outfs = FSO.OpenTextFile(outfile, 1 /* ForReading */);
output = outfs.ReadAll();
outfs.Close();
FSO.DeleteFile(outfile);
} catch (ex) { }
var errors = "";
try {
var errfs = FSO.OpenTextFile(errfile, 1 /* ForReading */);
errors = errfs.ReadAll();
errfs.Close();
FSO.DeleteFile(errfile);
} catch (ex) { }
return { exitcode: exitcode, output: output, errors: errors };
}
result = run("dir");
WScript.Echo("Exit Code: " + result.exitcode);
WScript.Echo("Output:\n" + result.output);
WScript.Echo("Error Output:\n" + result.errors);
Remember, Don't ever WshShell.Exec a command directly if you are not sure of its inputs and outputs and your script deadlocking would be a problem.