小程序长连接被中断时导致php进程被强制退出

前言

微信小程序请求长连接时,请求还在接受数据的时候关闭小程序,php后端会出现很奇怪的数据不全问题。

问题

通常,我们请求后端时,某个客户端关闭后php进程开启后不会退出(我们习惯上的认知是这样的)。

但当我们假设服务端有一个接口stream.php写作:

<?php
set_time_limit(0);
ini_set('output_buffering', 'off');
ini_set('zlib.output_compression', false);
header('Content-Type: text/event-stream');

header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');

if (ob_get_level() > 0) {
    @ob_end_flush();
}
foreach (array_fill(1, 20, 1) as $item) {
    echo 'data: {"id":"6wpvHt69PzgJr83YYa8GwZzoxl3Lp","object":"chat.completion.chunk","created":1679480635,"model":"3.5-turbo-0301","choices":[{"delta":{"content":"础"},"index":0,"finish_reason":null}]}' . PHP_EOL . PHP_EOL;
    flush();
    sleep(1);
}
echo "[DONE]" . PHP_EOL . PHP_EOL;

此时我们再实现一个接口answer.php去请求上述接口:

<?php
set_time_limit(0);
ini_set('output_buffering', 'off');
ini_set('zlib.output_compression', false);
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');

if (ob_get_level() > 0) {
    @ob_end_flush();
}

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://a.com/stream.php');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
curl_setopt($ch, CURLOPT_POSTFIELDS, 'aaa=1');
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/json',]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $data) {
    $len = strlen($data);
    echo $data;
    return $len;
});
$response = curl_exec($ch);
curl_close($ch);
file_put_contents('finish.txt', 'finish');

然后写一个小程序请求案例:

<template>
	<view class="content">
		<view class="text-area">
			<view @click="handleTest">提交</view>
		</view>
		<view>{{result}}</view>
	</view>
</template>

<script setup>
	import {
		ref
	} from "vue";
	let result = ref([]);
	let handleTest = () => {
		result.value = [];
		let url = 'https://a.com/answer.php';
		let requestTask = wx.request({
			url: url,
			enableChunked: true,
			responseType: 'text',
			method: 'POST',
			data: {
				'id': 2,
			},
		});
		requestTask.onChunkReceived((data) => {
			let res = String.fromCharCode(...(new Int8Array(data.data)));
			result.value.push(res);
			console.log(res);
		});
	}
</script>

<style>
</style>

运行小程序,点击“提交”,等有数据显示时,马上退出小程序。

正常来说,即时小程序关闭了,20秒后服务器依然会生成一个finish.txt

但是持续操作几遍后都没有发现finish.txt文件

过程

如果没生成finish.txt文件,那一定是程序运行到写文件前就退出了:

  1. 如果是要退出文件要么是die函数或者抛出异常,代码里我没有写die这类函数,那么就有可能是抛出异常了,改动代码:

    <?php
    try {
        set_time_limit(0);
        ini_set('output_buffering', 'off');
        ini_set('zlib.output_compression', false);
        header('Content-Type: text/event-stream');
        header('Cache-Control: no-cache');
        header('Connection: keep-alive');
        header('X-Accel-Buffering: no');
        if (ob_get_level() > 0) {
            @ob_end_flush();
        }
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, 'https://a.com/stream.php');
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HEADER, false);
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
        curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
        curl_setopt($ch, CURLOPT_POSTFIELDS, 'aaa=1');
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/json',]);
        curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $data) {
            $len = strlen($data);
            echo $data;
            return $len;
        });
        $response = curl_exec($ch);
        curl_close($ch);
    } catch (Throwable $e) {
        var_dump($e->getMessage());
    }
    file_put_contents('finish.txt', 'finish');

    尝试再操作一遍,发现依然没有文件产生。

  2. 还是围绕进程退出分析,如果不是代码问题,那就只有是有某个服务将这个进程kill掉了,我选择把锅甩给nginx。按照猜想,退出进程那就一定会有一个信号传进来,所以使用pcntl_signal捕获这个信号,顺便使用register_shutdown_function来辅助分析,继续改写代码:

    <?php
    pcntl_async_signals(true);
    $finish = false;
    register_shutdown_function(function () use (&$finish) {
        file_put_contents(__DIR__ . DIRECTORY_SEPARATOR . 'shutdown.txt', 'shutdown');
        if (!$finish) file_put_contents(__DIR__ . DIRECTORY_SEPARATOR . 'interupt.txt', 'interupt');
    });
    foreach (array(
                SIGTERM,
                SIGHUP,
                SIGUSR1,
                SIGUSR2,
                SIGINT,
                SIGQUIT,
                SIGILL,
                SIGTRAP,
                SIGABRT,
                SIGIOT,
                SIGBUS,
                SIGFPE,
    //             SIGKILL,
                SIGSEGV,
                SIGPIPE,
                SIGALRM,
                SIGCHLD,
                SIGCONT,
    //             SIGSTOP,
                SIGTSTP,
                SIGTTIN,
                SIGTTOU,
                SIGURG,
                SIGXCPU,
                SIGXFSZ,
                SIGVTALRM,
                SIGPROF,
                SIGWINCH,
                SIGIO,
                SIGPWR,
            ) as $signal) {
        pcntl_signal($signal, function ($signal) {
            file_put_contents(__DIR__ . DIRECTORY_SEPARATOR . 'signal.txt', $signal);
        });
    }
    set_time_limit(0);
    ini_set('output_buffering', 'off');
    ini_set('zlib.output_compression', false);
    header('Content-Type: text/event-stream');
    header('Cache-Control: no-cache');
    header('Connection: keep-alive');
    header('X-Accel-Buffering: no');
    if (ob_get_level() > 0) {
        @ob_end_flush();
    }
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, 'https://a.com/stream.php');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HEADER, false);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
    curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
    curl_setopt($ch, CURLOPT_POSTFIELDS, 'aaa=1');
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/json',]);
    curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $data) {
        $len = strlen($data);
        echo $data;
        return $len;
    });
    $response = curl_exec($ch);
    curl_close($ch);
    $finish   = true;
    file_put_contents('finish.txt', 'finish');

    操作一遍小程序,这次服务器产生了2个文件shutdown.txtinterupt.txt,没有signal.txt文件,但说明程序确实是被意外终止了,且代码没有执行完。

    进一步分析程序退出时产生了什么signal,如果小程序能够输出内容,那么代码一定是执行到了CURLOPT_WRITEFUNCTION回调,那么就有可能是这个回调机制导致程序信号被拦截(猜想),现在我直接去掉curl,改动代码:

    <?php
    pcntl_async_signals(true);
    $finish = false;
    register_shutdown_function(function () use (&$finish) {
        file_put_contents(__DIR__ . DIRECTORY_SEPARATOR . 'shutdown.txt', 'shutdown');
        if (!$finish) file_put_contents(__DIR__ . DIRECTORY_SEPARATOR . 'interupt.txt', 'interupt');
    });
    foreach (array(
                SIGTERM,
                SIGHUP,
                SIGUSR1,
                SIGUSR2,
                SIGINT,
                SIGQUIT,
                SIGILL,
                SIGTRAP,
                SIGABRT,
                SIGIOT,
                SIGBUS,
                SIGFPE,
    //             SIGKILL,
                SIGSEGV,
                SIGPIPE,
                SIGALRM,
                SIGCHLD,
                SIGCONT,
    //             SIGSTOP,
                SIGTSTP,
                SIGTTIN,
                SIGTTOU,
                SIGURG,
                SIGXCPU,
                SIGXFSZ,
                SIGVTALRM,
                SIGPROF,
                SIGWINCH,
                SIGIO,
                SIGPWR,
            ) as $signal) {
        pcntl_signal($signal, function ($signal) {
            file_put_contents(__DIR__ . DIRECTORY_SEPARATOR . 'signal.txt', $signal);
        });
    }
    set_time_limit(0);
    ini_set('output_buffering', 'off');
    ini_set('zlib.output_compression', false);
    header('Content-Type: text/event-stream');
    header('Cache-Control: no-cache');
    header('Connection: keep-alive');
    header('X-Accel-Buffering: no');
    if (ob_get_level() > 0) {
        @ob_end_flush();
    }
    while ($i < 20) {
        echo 'data: {"name": "mike", "i": "' . $i . '"}' . PHP_EOL . PHP_EOL;
        $i++;
        flush();
        sleep(1);
    }
    $finish   = true;
    file_put_contents('finish.txt', 'finish');

    继续操作小程序,这时候服务器产生了3个文件shutdown.txtinterupt.txtsignal.txt,其中signal.txt的内容是13,对照信号表得知是SIGPIPE信号。

    我尝试调试为什么CURLOPT_WRITEFUNCTION回调为什么会导致pcntl_signal收不到进程信号,但是没有找到原因,下次再看看。

关于SIGPIPE信号

当服务器close一个连接时,若client端接着发数据。根据TCP协议的规定,会收到一个RST响应,client再往这个服务器发送数据时,系统会发出一个SIGPIPE信号给进程,告诉进程这个连接已经断开了,不要再写了。

解决

通过SIGPIPE得知,客户端是在某种情况下关闭了连接,导致进程退出了。如果想在程序退出的时候,无论什么原因退出的都要保存一些数据,可以直接套用下方例子代码:

<?php
pcntl_async_signals(true);
$finish = false;
register_shutdown_function(function () use (&$finish) {
    // 进程退出时要做些什么
    if (!$finish) {
        // 如果意外退出时要做些什么
    }
});
set_time_limit(0);
ini_set('output_buffering', 'off');
ini_set('zlib.output_compression', false);
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
if (ob_get_level() > 0) {
    @ob_end_flush();
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://a.com/stream.php');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
curl_setopt($ch, CURLOPT_POSTFIELDS, 'aaa=1');
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/json',]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $data) {
    $len = strlen($data);
    echo $data;
    return $len;
});
$response = curl_exec($ch);
curl_close($ch);
$finish   = true;
// 程序执行完成

小程序长连接被中断时导致php进程被强制退出
http://blog.icy8.cn/posts/56831/
作者
icy8
发布于
2023年4月28日
许可协议