From f3abb812a4407b20af9fe871feb5937554a597f0 Mon Sep 17 00:00:00 2001 From: seo Date: Mon, 15 Jun 2026 11:00:07 +0900 Subject: [PATCH] Settle late TCP usage asynchronously --- README.md | 16 ++++- common.php | 190 +++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 186 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 3b212ff..9d31349 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저 - 허용된 차량 명령 TCP 전송 - monitor 화면과 AJAX 상태 갱신 - 사용량/요금 표시와 보정 metadata +- 4.95초 UI timeout과 60초 사용량 정산 분리 - TCP 실패 reason과 마지막 수신 지연 표시 - 도어/경계/시동/공조 상태 해석 - service worker와 PWA icon 제공 @@ -49,6 +50,19 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저 4. 명령 코드를 허용 목록과 대조합니다. 5. TCP 게이트웨이로 전송하고 결과와 실패 reason을 반환합니다. +## TCP timeout과 사용량 정산 + +차량 명령 성공/실패 판단은 UI 안정성을 위해 `TCP_TOTAL_TIMEOUT` 기본값인 4.95초를 사용합니다. 이 시간 안에 정상 응답이 없으면 API는 기존처럼 실패를 반환하므로 자동화와 UI는 즉시 재시도 여부를 판단할 수 있습니다. + +단, 실제 모뎀이 4.95초 이후에 늦게 응답할 수 있으므로 사용량 기록은 별도로 정산합니다. 4.95초까지 정상 응답이 없고 송신이 완료된 요청은 `pcntl_fork()`로 자식 프로세스를 만들고, 같은 TCP 소켓을 최대 `TCP_USAGE_SETTLEMENT_TIMEOUT` 기본 60초까지 더 관찰합니다. + +- 60초 안에 정상 응답이 완성되면 `late_response_after_ui_timeout`으로 송신량과 수신량을 함께 기록합니다. +- 60초까지 응답이 없으면 `late_total_timeout_60s_sent_only`로 송신 패킷만 사용량에 반영합니다. +- 60초 안에 일부 데이터만 오고 정상 포맷이 아니면 `late_invalid_or_partial_response`로 실제 수신된 바이트까지 기록합니다. +- 늦은 정상 응답이 차량 상태 포맷이면 상태 DB에도 저장합니다. + +이 구조에서 `request_id`는 UI 응답과 늦은 사용량 정산 기록을 연결하는 기준입니다. 즉 명령 재시도 로직은 4.95초 기준을 유지하고, 데이터 사용량은 최대 60초까지 늦은 수신 패킷을 반영합니다. + ## 주요 함수/모듈 - `common.php`: secret 로드와 공통 DB/API 함수 @@ -68,4 +82,4 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저 - 차량 TCP 실패 reason과 마지막 수신 지연을 확인합니다. - 명령별 rate limit과 감사 로그를 유지합니다. - 통신사 기준 데이터 사용량 보정값을 주기적으로 확인합니다. - +- 4.95초 timeout 직후에는 사용량 기록이 즉시 생기지 않을 수 있으며, 최대 60초 뒤 늦은 정산 행이 추가될 수 있습니다. diff --git a/common.php b/common.php index c296627..d20e0a2 100644 --- a/common.php +++ b/common.php @@ -15,6 +15,7 @@ define('TCP_HOST', (string)($carTcpConfig['host'] ?? '')); define('TCP_PORT', (int)($carTcpConfig['port'] ?? 3400)); define('TCP_TOTAL_TIMEOUT', (float)($carTcpConfig['total_timeout'] ?? 4.95)); +define('TCP_USAGE_SETTLEMENT_TIMEOUT', (float)($carTcpConfig['usage_settlement_timeout'] ?? 60.0)); define('MODEM', (string)($carIdentityConfig['modem'] ?? '')); define('USER', (string)($carIdentityConfig['user'] ?? '')); @@ -39,22 +40,27 @@ const CONTROL_CMD = ['ef', 'en', 'hn', 'hf', 'dl', 'du', 'tu']; const RAW_FULL_REGEX = '/(?\d{11})\/R:(?[a-z])\/E:(?[io]{5}\d{3}[io])\/D:(?[oi]{7})\/L:(?o{5})\/F:(?[ots][oi]\d{4}[oi]{4})\/S:(?[^\/]+)/'; -function db(): PDO -{ - static $pdo = null; - - if ($pdo instanceof PDO) { - return $pdo; - } - - $dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=" . DB_CHARSET; - $pdo = new PDO($dsn, DB_USER, DB_PASS, [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - ]); - - return $pdo; -} +function db(bool $fresh = false): PDO +{ + static $pdo = null; + + if (!$fresh && $pdo instanceof PDO) { + return $pdo; + } + + $dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=" . DB_CHARSET; + $conn = new PDO($dsn, DB_USER, DB_PASS, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + + if ($fresh) { + return $conn; + } + + $pdo = $conn; + return $pdo; +} function b(string $ch): int { @@ -152,6 +158,137 @@ function record_tcp_usage( ); } +function finalize_late_tcp_usage( + $fp, + string $cmd, + string $usageSource, + ?string $requestId, + int $sentBytes, + string $initialResponse, + float $deadline +): void { + ignore_user_abort(true); + @set_time_limit((int)ceil(max(5.0, TCP_USAGE_SETTLEMENT_TIMEOUT + 5.0))); + + $response = $initialResponse; + $tcpError = ''; + + while (microtime(true) < $deadline) { + $read = [$fp]; + $w = null; + $e = null; + + $remain = $deadline - microtime(true); + if ($remain <= 0) { + break; + } + + $wait = min($remain, 1.0); + $sec = (int)$wait; + $usec = (int)(($wait - $sec) * 1000000); + + $sel = @stream_select($read, $w, $e, $sec, $usec); + if ($sel === false) { + $tcpError = 'late_stream_select_failed'; + break; + } + + if ($sel > 0) { + $chunk = @fread($fp, 4096); + if ($chunk === false) { + $tcpError = 'late_read_failed'; + break; + } + + if ($chunk !== '') { + $response .= $chunk; + + if (preg_match(RAW_FULL_REGEX, trim($response))) { + break; + } + } + } + + if (feof($fp)) { + break; + } + } + + @fclose($fp); + + $receivedBytes = strlen($response); + $trimmed = trim($response); + + if ($trimmed === '') { + db_insert_data_usage(db(true), $usageSource, $cmd, $sentBytes, 0, false, 'late_total_timeout_60s_sent_only', $requestId); + return; + } + + if (!preg_match(RAW_FULL_REGEX, $trimmed)) { + db_insert_data_usage( + db(true), + $usageSource, + $cmd, + $sentBytes, + $receivedBytes, + false, + $tcpError !== '' ? $tcpError : 'late_invalid_or_partial_response', + $requestId + ); + return; + } + + db_insert_data_usage(db(true), $usageSource, $cmd, $sentBytes, $receivedBytes, true, 'late_response_after_ui_timeout', $requestId); + + $trimError = ''; + $rawTrim = make_trim($trimmed, $trimError); + if ($rawTrim === '') { + return; + } + + $data = parse_trim($rawTrim); + if (!is_valid_status_data($data)) { + return; + } + + try { + db_insert_status(db(true), $cmd, $trimmed, $rawTrim, $data); + } catch (Throwable $e) { + // 늦은 응답 상태 저장 실패가 사용량 정산을 되돌리면 안 된다. + } +} + +function schedule_late_tcp_usage_settlement( + $fp, + string $cmd, + string $usageSource, + ?string $requestId, + int $sentBytes, + string $initialResponse = '' +): bool { + if (!function_exists('pcntl_fork') || $sentBytes <= 0) { + return false; + } + + $deadline = microtime(true) + TCP_USAGE_SETTLEMENT_TIMEOUT; + $pid = @pcntl_fork(); + + if ($pid === -1) { + return false; + } + + if ($pid > 0) { + return true; + } + + if (function_exists('posix_setsid')) { + @posix_setsid(); + } + + finalize_late_tcp_usage($fp, $cmd, $usageSource, $requestId, $sentBytes, $initialResponse, $deadline); + exit(0); +} + function tcp_request( string $cmd, int &$tcpMs, @@ -261,7 +398,6 @@ function tcp_request( } $readMs = (int)round((microtime(true) - $r0) * 1000); - fclose($fp); $tcpMs = $connectMs + $readMs; $receivedBytes = strlen($response); @@ -270,16 +406,32 @@ function tcp_request( if ($tcpError === '') { $tcpError = 'total_timeout_4_95s'; } - record_tcp_usage($usageSource, $cmd, $sentBytes, $receivedBytes, false, $tcpError, $requestId); + $scheduled = $tcpError === 'total_timeout_4_95s' + && schedule_late_tcp_usage_settlement($fp, $cmd, $usageSource, $requestId, $sentBytes, $response); + if ($scheduled) { + fclose($fp); + $tcpError = 'total_timeout_4_95s_late_settlement_pending'; + } else { + fclose($fp); + record_tcp_usage($usageSource, $cmd, $sentBytes, $receivedBytes, false, $tcpError, $requestId); + } return ''; } if (!preg_match(RAW_FULL_REGEX, trim($response))) { $tcpError = 'invalid_or_partial_response'; - record_tcp_usage($usageSource, $cmd, $sentBytes, $receivedBytes, false, $tcpError, $requestId); + $scheduled = schedule_late_tcp_usage_settlement($fp, $cmd, $usageSource, $requestId, $sentBytes, $response); + if ($scheduled) { + fclose($fp); + $tcpError = 'invalid_or_partial_response_late_settlement_pending'; + } else { + fclose($fp); + record_tcp_usage($usageSource, $cmd, $sentBytes, $receivedBytes, false, $tcpError, $requestId); + } return ''; } + fclose($fp); record_tcp_usage($usageSource, $cmd, $sentBytes, $receivedBytes, true, $tcpError, $requestId); return $response;